| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "io" |
| 6 | "log/slog" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | // b64 is a valid, correctly-sized value for whichever secret needs one. |
| 12 | func b64(n int) string { |
| 13 | b := make([]byte, n) |
| 14 | for i := range b { |
| 15 | b[i] = byte(i) |
| 16 | } |
| 17 | return base64.StdEncoding.EncodeToString(b) |
| 18 | } |
| 19 | |
| 20 | func quietLogger() *slog.Logger { |
| 21 | return slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 22 | } |
| 23 | |
| 24 | // setSecretEnv clears every variable LoadSecrets reads, then applies the |
| 25 | // overrides. Clearing first matters: t.Setenv restores on cleanup but does |
| 26 | // not isolate a variable the developer happens to have exported, and a |
| 27 | // test that passes only on a machine with CARLOS_SMTP_HOST set is worse |
| 28 | // than no test. |
| 29 | func setSecretEnv(t *testing.T, overrides map[string]string) { |
| 30 | t.Helper() |
| 31 | complete := map[string]string{ |
| 32 | "HOME_DEK": b64(32), |
| 33 | "HOME_PEPPER": b64(32), |
| 34 | "HOME_SIGNIN_KEY": b64(32), |
| 35 | "CARLOS_SMTP_HOST": "smtp.example.invalid", |
| 36 | "CARLOS_SMTP_FROM": "home@example.invalid", |
| 37 | "CARLOS_SMTP_PASS": "hunter2", |
| 38 | } |
| 39 | for k, v := range complete { |
| 40 | if o, ok := overrides[k]; ok { |
| 41 | v = o |
| 42 | } |
| 43 | t.Setenv(k, v) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | func TestLoadSealerRefusals(t *testing.T) { |
| 48 | cases := map[string]string{ |
| 49 | "unset": "", |
| 50 | "not base64": "not-base64!!", |
| 51 | "31 bytes": b64(31), |
| 52 | "33 bytes": b64(33), |
| 53 | } |
| 54 | for name, val := range cases { |
| 55 | t.Run(name, func(t *testing.T) { |
| 56 | t.Setenv("HOME_DEK", val) |
| 57 | if _, err := LoadSealer(); err == nil { |
| 58 | t.Fatalf("LoadSealer accepted HOME_DEK=%q", val) |
| 59 | } |
| 60 | }) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func TestLoadSealerAcceptsA32ByteKey(t *testing.T) { |
| 65 | t.Setenv("HOME_DEK", b64(32)) |
| 66 | s, err := LoadSealer() |
| 67 | if err != nil { |
| 68 | t.Fatalf("LoadSealer: %v", err) |
| 69 | } |
| 70 | if s == nil { |
| 71 | t.Fatal("LoadSealer returned a nil Sealer and no error") |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | func TestLoadSecretsAcceptsCompleteConfig(t *testing.T) { |
| 76 | setSecretEnv(t, nil) |
| 77 | got, err := LoadSecrets("/tmp/home.db", quietLogger()) |
| 78 | if err != nil { |
| 79 | t.Fatalf("LoadSecrets: %v", err) |
| 80 | } |
| 81 | if got.Sealer == nil { |
| 82 | t.Error("no Sealer") |
| 83 | } |
| 84 | if len(got.Pepper) != 32 { |
| 85 | t.Errorf("Pepper is %d bytes, want 32", len(got.Pepper)) |
| 86 | } |
| 87 | if got.SigninKey == ([32]byte{}) { |
| 88 | t.Error("SigninKey is the zero value, which signin.Flow refuses") |
| 89 | } |
| 90 | if got.Mailer == nil { |
| 91 | t.Error("no Mailer") |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // Each secret missing is its own refusal, and the message must name the |
| 96 | // variable an operator has to set — an error that says "not configured" |
| 97 | // without saying which one costs a support round-trip. |
| 98 | func TestLoadSecretsRefusesEachMissingSecret(t *testing.T) { |
| 99 | for _, name := range []string{"HOME_DEK", "HOME_PEPPER", "HOME_SIGNIN_KEY", "CARLOS_SMTP_PASS"} { |
| 100 | t.Run(name, func(t *testing.T) { |
| 101 | setSecretEnv(t, map[string]string{name: ""}) |
| 102 | _, err := LoadSecrets("/tmp/home.db", quietLogger()) |
| 103 | if err == nil { |
| 104 | t.Fatalf("LoadSecrets accepted a missing %s", name) |
| 105 | } |
| 106 | if !strings.Contains(err.Error(), name) { |
| 107 | t.Errorf("error %q does not name %s", err, name) |
| 108 | } |
| 109 | }) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestLoadSecretsRefusesMalformedSecrets(t *testing.T) { |
| 114 | for _, name := range []string{"HOME_DEK", "HOME_PEPPER", "HOME_SIGNIN_KEY"} { |
| 115 | for _, bad := range []string{"not-base64!!", b64(31)} { |
| 116 | t.Run(name+"/"+bad, func(t *testing.T) { |
| 117 | setSecretEnv(t, map[string]string{name: bad}) |
| 118 | if _, err := LoadSecrets("/tmp/home.db", quietLogger()); err == nil { |
| 119 | t.Fatalf("LoadSecrets accepted %s=%q", name, bad) |
| 120 | } |
| 121 | }) |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | // mail.FromEnv falls back to a logging sender when HOST or FROM is |
| 127 | // absent, and mail.Logged's Send logs "to", to — an address into |
| 128 | // platform-held logs on every sign-in. A warning is not enough: the log |
| 129 | // is storage we cannot grep or redact. |
| 130 | func TestLoadSecretsRefusesWithoutSMTPHostOrFrom(t *testing.T) { |
| 131 | for _, name := range []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_FROM"} { |
| 132 | t.Run(name, func(t *testing.T) { |
| 133 | setSecretEnv(t, map[string]string{name: ""}) |
| 134 | _, err := LoadSecrets("/tmp/home.db", quietLogger()) |
| 135 | if err == nil { |
| 136 | t.Fatalf("LoadSecrets accepted a missing %s, so links would be logged", name) |
| 137 | } |
| 138 | if !strings.Contains(err.Error(), name) { |
| 139 | t.Errorf("error %q does not name %s", err, name) |
| 140 | } |
| 141 | }) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | // SQLite treats an empty filename as a private temporary database: the |
| 146 | // app comes up, works, and loses every person on restart. On the |
| 147 | // platform the path is never empty — cmd/home sets a relative default |
| 148 | // that Resolve joins to $STATE_DIRECTORY — so this guards a caller that |
| 149 | // builds its own Options, not the deployed boot path. |
| 150 | func TestLoadSecretsRefusesEmptyDBPath(t *testing.T) { |
| 151 | setSecretEnv(t, nil) |
| 152 | if _, err := LoadSecrets("", quietLogger()); err == nil { |
| 153 | t.Fatal("LoadSecrets accepted an empty database path") |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | // An all-zero key is well-formed: right length, valid base64, and every |
| 158 | // other check passes it. It is also the canonical placeholder, and |
| 159 | // signin.Flow refuses a zero Key per request rather than at |
| 160 | // construction — so without this the app boots, answers /healthz, and |
| 161 | // breaks sign-in for keymail addresses while leaving the magic link |
| 162 | // working, since Flow reads Key only on the keymail path. A silently |
| 163 | // half-broken sign-in is if anything worse than a wholly broken one. |
| 164 | func TestLoadSecretsRefusesAllZeroKeys(t *testing.T) { |
| 165 | zero := base64.StdEncoding.EncodeToString(make([]byte, 32)) |
| 166 | for _, name := range []string{"HOME_DEK", "HOME_PEPPER", "HOME_SIGNIN_KEY"} { |
| 167 | t.Run(name, func(t *testing.T) { |
| 168 | setSecretEnv(t, map[string]string{name: zero}) |
| 169 | _, err := LoadSecrets("/tmp/home.db", quietLogger()) |
| 170 | if err == nil { |
| 171 | t.Fatalf("LoadSecrets accepted 32 zero bytes for %s", name) |
| 172 | } |
| 173 | if !strings.Contains(err.Error(), name) { |
| 174 | t.Errorf("error %q does not name %s", err, name) |
| 175 | } |
| 176 | }) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | // New guards every construction path that is not the boot path — which |
| 181 | // is every test, and every caller written from here on. |
| 182 | func TestNewRefusesIncompleteWiring(t *testing.T) { |
| 183 | full, _ := testSecrets(t) |
| 184 | d := newTestApp(t).db |
| 185 | |
| 186 | noPepper := full |
| 187 | noPepper.Pepper = nil |
| 188 | noKey := full |
| 189 | noKey.SigninKey = [32]byte{} |
| 190 | noSealer := full |
| 191 | noSealer.Sealer = nil |
| 192 | noMailer := full |
| 193 | noMailer.Mailer = nil |
| 194 | |
| 195 | for name, s := range map[string]Secrets{ |
| 196 | "zero value": {}, |
| 197 | "no Sealer": noSealer, |
| 198 | "no pepper": noPepper, |
| 199 | "no sign key": noKey, |
| 200 | "no mailer": noMailer, |
| 201 | } { |
| 202 | t.Run(name, func(t *testing.T) { |
| 203 | if _, err := New(s, d, quietLogger()); err == nil { |
| 204 | t.Fatal("New accepted it") |
| 205 | } |
| 206 | }) |
| 207 | } |
| 208 | |
| 209 | if _, err := New(full, nil, quietLogger()); err == nil { |
| 210 | t.Fatal("New accepted a nil database") |
| 211 | } |
| 212 | if _, err := New(full, d, quietLogger()); err != nil { |
| 213 | t.Fatalf("New refused complete wiring: %v", err) |
| 214 | } |
| 215 | } |
| 216 | |