oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
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.
12func 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
20func 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.
29func 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
47func 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
64func 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
75func 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.
98func 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
113func 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.
130func 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.
150func 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.
164func 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.
182func 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