Seal values at rest, load the four secrets, and the plaintext gate that proves it
§13 step 2, Task 1 of the plan. - internal/home/seal: ported from oficina/sheets-core unchanged apart from the package doc and the HKDF info string, which becomes "oficina/home:". Two apps in one suite disagreeing about their envelope format is a bug waiting to differ. - internal/home/sealtest: gate 1's mechanism. CanaryAddress is lowercase and address-shaped, and is in Canaries — an upper-case canary cannot see the lowercased copy the blind index and signin's limiter key on, and a canary outside Canaries is one the gate never scans for, so a gate seeded with it passes while proving nothing. The gate needs a database to scan, which is Task 2; what lands here is the mechanism and the tests that prove it can fail. - internal/home/keys.go: LoadSealer and LoadSecrets. All four secrets in one place, refusing rather than degrading, plus two absences that are silent and are not secrets: no SMTP host or from address (mail.FromEnv falls back to mail.Logged, whose Send logs "to", to — an address into platform-held logs on every sign-in) and no --db path (SQLite treats an empty filename as a private temporary database, so the app works and loses every person on restart). - The containment guard now covers sealtest as well as logtest. Both import testing from a non-test file. Verified by breaking it: a production import of sealtest fails the guard, and the failure was observed before the file was removed. Three things a reviewer should look at, all deliberate: 1. x/crypto is pinned to v0.55.0, matching sheets-core, because v0.56.0 requires go 1.26.0 and `go get` silently raised this module's go directive to match. The directive is back at 1.25.0. 2. No loopback relaxation on the mail refusal. Sheets' LoadAuthEnv has one, keyed on its instance origin; the home has no origin variable at this step, so the equivalent would have to invent one, and a carve-out that disables the leak guard is not a thing to invent in passing. 3. Task 1 wires no database handle, though the plan's step 5c mentions one. The schema, the first writes and the secure_delete connection hook are all Task 2, and an App holding a handle it never reads is dead wiring. LoadSecrets still validates the path. CONSEQUENCE: cmd/home no longer boots without the four secrets, the SMTP host and from address, and a --db path. A deploy must provision all of them first; until then the previously built version is what serves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E4uVkB6xnZJYuxTooPiUn4
14 files changed,
+844
−46
cmd/home/main.go+29 −12go.mod+2 −1go.sum+4 −2internal/guardstest/logtest_containment_test.go+22 −11internal/home/app.go+20 −17internal/home/app_test.go+2 −2internal/home/keys.go+162 −0internal/home/keys_test.go+153 −0internal/home/logs_test.go+1 −1internal/home/seal/seal.go+99 −0internal/home/seal/seal_test.go+52 −0internal/home/sealtest/sealtest.go+98 −0internal/home/sealtest/sealtest_test.go+119 −0internal/home/testapp_test.go+81 −0
Intent
Human
Great, and a summary at the end of what was blocking us. Check to see if it was built since.
diff --git a/cmd/home/main.go b/cmd/home/main.go| index 93252d3..93f9641 100644 |
| --- a/cmd/home/main.go |
| +++ b/cmd/home/main.go |
| @@ -1,8 +1,7 @@ |
| // Command home is the Oficina suite home binary. |
| // |
| -// This is a deployable skeleton: it satisfies the platform activation |
| -// contract and serves internal/home's one placeholder page. None of |
| -// the home's real functionality exists yet — see |
| +// It serves internal/home's one placeholder page and, as of §13 step 2, |
| +// refuses to start without the configuration sign-in will need. See |
| // docs/superpowers/specs/2026-08-30-home-design.md and |
| // internal/home/page.go. |
| // |
| @@ -15,14 +14,18 @@ |
| // argv, LISTEN_FDS, $STATE_DIRECTORY, GET /healthz, GET /api/version, |
| // SIGTERM drain, baseline security headers. |
| // |
| -// Unlike Sheets (internal/sheets, one SQLite file per instance), this |
| -// skeleton opens no database: it has no models and nothing to persist |
| -// yet. rastrillo.Options.DBPath stays unset, so |
| -// rastrillo.Handler/Serve never call OpenDB at all (see serve.go: |
| -// "var db *sql.DB; ... if opts.DBPath != \"\" { db, err = OpenDB(...) }") |
| -// — there is no db.Open call to make and no handle to blank before |
| -// Serve. When the home gains real storage, this file grows the same |
| -// Resolve -> db.Open -> build mux -> blank DBPath -> Serve shape Sheets |
| +// THIS BINARY NO LONGER BOOTS ON ITS OWN. LoadSecrets refuses without |
| +// HOME_DEK, HOME_PEPPER, HOME_SIGNIN_KEY, CARLOS_SMTP_PASS, the SMTP |
| +// host and from address, and a --db path. That is deliberate — every |
| +// one of those absences produces an app that answers /healthz and is |
| +// broken or leaking — but it means a deploy of this binary must |
| +// provision all of them first. Until then the previous build is what |
| +// serves. |
| +// |
| +// It still opens no database handle. The --db path is validated here |
| +// and passed to rastrillo, and storage itself arrives with the schema |
| +// in the next step of the plan; this file then grows the same |
| +// Resolve -> db.Open -> migrate -> build mux -> Serve shape Sheets |
| // uses. |
| package main |
| @@ -45,7 +48,21 @@ func main() { |
| os.Exit(1) |
| } |
| - app := home.New() |
| + // Every secret and setting whose absence is silent is validated |
| + // before anything serves. Each refusal here is a case where |
| + // continuing would produce an app that answers /healthz perfectly |
| + // well and is broken or leaking: no DEK and addresses persist in the |
| + // clear, no pepper and every person row is orphaned, no SMTP host |
| + // and magic links are written to platform-held logs instead of |
| + // emailed, no --db and SQLite opens a private temporary database |
| + // that loses every person on restart. |
| + secrets, err := home.LoadSecrets(opts.DBPath, logger) |
| + if err != nil { |
| + logger.Error("config", "err", err) |
| + os.Exit(1) |
| + } |
| + |
| + app := home.New(secrets) |
| // rastrillo.Options.Mux wants a *http.ServeMux; the app's own |
| // router is a chi.Router (an http.Handler), so it mounts under one. |
diff --git a/go.mod b/go.mod| index 9772394..cf7bf48 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,6 +5,7 @@ go 1.25.0 |
| require ( |
| github.com/carlosframework/rastrillo v0.22.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| + golang.org/x/crypto v0.55.0 |
| ) |
| require ( |
| @@ -13,7 +14,7 @@ require ( |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| - golang.org/x/sys v0.46.0 // indirect |
| + golang.org/x/sys v0.47.0 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
| modernc.org/memory v1.11.0 // indirect |
diff --git a/go.sum b/go.sum| index 884bf08..d2591f7 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -16,13 +16,15 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF |
| github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= |
| +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= |
| golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= |
| +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
diff --git a/internal/guardstest/logtest_containment_test.go b/internal/guardstest/logtest_containment_test.go| index 3c12ac6..6eb9b4e 100644 |
| --- a/internal/guardstest/logtest_containment_test.go |
| +++ b/internal/guardstest/logtest_containment_test.go |
| @@ -5,15 +5,24 @@ import ( |
| "testing" |
| ) |
| -// logtestImport is gate 9's test helper package. Its own doc comment |
| -// already says this — "this package must never be imported from |
| -// non-test code: doing so would drag the testing package into the |
| -// production binary" — but a comment is not a guard. |
| -const logtestImport = "amadan.net/oficina/home/internal/home/logtest" |
| +// testOnlyPackages are the helper packages that import testing from a |
| +// non-test file. Each one's doc comment already says so — "this package |
| +// must never be imported from non-test code: doing so would drag the |
| +// testing package into the production binary" — but a comment is not a |
| +// guard. |
| +// |
| +// It is a slice rather than a single constant because there are now two: |
| +// gate 9's logtest and gate 1's sealtest. A guard that covers one of two |
| +// identical hazards is the shape of bug this repo keeps finding. |
| +var testOnlyPackages = []string{ |
| + "amadan.net/oficina/home/internal/home/logtest", |
| + "amadan.net/oficina/home/internal/home/sealtest", |
| +} |
| -// TestLogtestOnlyImportedByTests fails on any non-_test.go file that |
| -// imports logtest, or anything under it: a future logtest subpackage |
| -// must inherit the rule rather than fall outside it on an exact match. |
| +// TestTestOnlyPackagesNotImportedByProductionCode fails on any |
| +// non-_test.go file that imports one of them, or anything under it: a |
| +// future subpackage must inherit the rule rather than fall outside it on |
| +// an exact match. |
| // |
| // An earlier version of this comment said the import registers test |
| // flags on the default flag set. It does not: since Go 1.13 those are |
| @@ -22,7 +31,7 @@ const logtestImport = "amadan.net/oficina/home/internal/home/logtest" |
| // linking testing into a deployed binary is binary hygiene we do not |
| // want to argue about per-release, and nothing announces it at build |
| // time. |
| -func TestLogtestOnlyImportedByTests(t *testing.T) { |
| +func TestTestOnlyPackagesNotImportedByProductionCode(t *testing.T) { |
| root := repoRoot(t) |
| for _, path := range goFiles(t, root) { |
| if strings.HasSuffix(path, "_test.go") { |
| @@ -30,8 +39,10 @@ func TestLogtestOnlyImportedByTests(t *testing.T) { |
| } |
| rel := relPath(t, root, path) |
| for _, imp := range fileImports(t, path) { |
| - if imp == logtestImport || strings.HasPrefix(imp, logtestImport+"/") { |
| - t.Errorf("%s: imports %q from non-test code (logtest imports testing, so it must stay test-only)", rel, logtestImport) |
| + for _, want := range testOnlyPackages { |
| + if imp == want || strings.HasPrefix(imp, want+"/") { |
| + t.Errorf("%s: imports %q from non-test code (it imports testing, so it must stay test-only)", rel, want) |
| + } |
| } |
| } |
| } |
diff --git a/internal/home/app.go b/internal/home/app.go| index d62bd2a..94b8f23 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -4,10 +4,11 @@ |
| // docs/superpowers/specs/2026-08-30-home-design.md (the design) for |
| // what this service is and, just as importantly, is not. |
| // |
| -// This package is a deployable skeleton, not the home: it wires the |
| -// platform contract and serves one placeholder page. None of the |
| -// home's actual functionality — sign-in, directory, assertions, |
| -// provisioning — is implemented yet. See page.go. |
| +// This package is not yet the home: it wires the platform contract, |
| +// validates the configuration sign-in needs (keys.go) and seals values |
| +// at rest (seal/), but serves only one placeholder page. Sign-in, |
| +// directory, assertions and provisioning are not implemented yet. See |
| +// page.go. |
| package home |
| import ( |
| @@ -18,20 +19,22 @@ import ( |
| // App holds the wiring. One per process. |
| // |
| -// It holds no database handle: the skeleton has no state to keep, and |
| -// AGENTS.md's "no address at rest" tests have nothing to guard until |
| -// real storage exists. Kept as a struct (rather than a bare package |
| -// func) so App/New/Router matches the Sheets app's shape at |
| -// internal/sheets/app.go — see New's comment. |
| -type App struct{} |
| +// It holds no database handle yet: storage arrives with the schema in |
| +// the next step of the plan. What it does hold is the validated |
| +// Secrets, because every value this app will persist is sealed on the |
| +// way in and there is no code path that should reach for a raw key. |
| +type App struct { |
| + secrets Secrets |
| +} |
| -// New builds an App. It takes no arguments today because there is |
| -// nothing yet to wire in — no database, no session store, no identity |
| -// plugin. Kept as a constructor rather than a free function so this |
| -// package reads like Sheets' internal/sheets.New(db *gorm.DB) *App: |
| -// adding real wiring later is a one-line signature change, not a |
| -// reshape. |
| -func New() *App { return &App{} } |
| +// New builds an App on validated secrets. |
| +// |
| +// Secrets is a value, not a pointer, and there is no zero-value escape |
| +// hatch: an App without a Sealer would seal nothing and persist |
| +// addresses in the clear, which is the failure this whole step exists |
| +// to make impossible. Callers get their Secrets from LoadSecrets, which |
| +// refuses rather than degrades. |
| +func New(secrets Secrets) *App { return &App{secrets: secrets} } |
| // Router returns the mounted handler. |
| func (a *App) Router() http.Handler { |
diff --git a/internal/home/app_test.go b/internal/home/app_test.go| index 2c639aa..e2b3b25 100644 |
| --- a/internal/home/app_test.go |
| +++ b/internal/home/app_test.go |
| @@ -10,7 +10,7 @@ import ( |
| // HTML — the placeholder content itself is not asserted here so this |
| // test does not need updating every time the wording changes. |
| func TestIndex(t *testing.T) { |
| - app := New() |
| + app, _ := newTestApp(t) |
| req := httptest.NewRequest(http.MethodGet, "/", nil) |
| rec := httptest.NewRecorder() |
| app.Router().ServeHTTP(rec, req) |
| @@ -28,7 +28,7 @@ func TestIndex(t *testing.T) { |
| // ahead of this one and wins (see the comment on Router in app.go); this |
| // test only proves the app's router itself is wired and reachable. |
| func TestHealthz(t *testing.T) { |
| - app := New() |
| + app, _ := newTestApp(t) |
| req := httptest.NewRequest(http.MethodGet, "/healthz", nil) |
| rec := httptest.NewRecorder() |
| app.Router().ServeHTTP(rec, req) |
diff --git a/internal/home/keys.go b/internal/home/keys.go| new file mode 100644 |
| index 0000000..db03f3a |
| --- /dev/null |
| +++ b/internal/home/keys.go |
| @@ -0,0 +1,162 @@ |
| +package home |
| + |
| +import ( |
| + "encoding/base64" |
| + "errors" |
| + "fmt" |
| + "log/slog" |
| + "os" |
| + "strings" |
| + |
| + "github.com/carlosframework/rastrillo/mail" |
| + |
| + "amadan.net/oficina/home/internal/home/seal" |
| +) |
| + |
| +// Secrets is everything the home needs from platform config, already |
| +// validated. Building it is the only place these variable names appear. |
| +// |
| +// Every field here is a refusal rather than a default. The failure they |
| +// guard against is the same one each time: an app that boots, answers |
| +// /healthz perfectly well, and is broken or leaking in a way nobody |
| +// notices until it matters. A generated DEK makes every previously |
| +// sealed value unreadable; a generated pepper orphans every person row, |
| +// because the blind index is the only way back from an address to a |
| +// person. Neither is recoverable, and both look like data loss long |
| +// after the boot that caused them. |
| +type Secrets struct { |
| + // Sealer seals addresses, team display names and queue payloads at |
| + // rest. From HOME_DEK. |
| + Sealer *seal.Sealer |
| + |
| + // Pepper keys the blind index — HMAC(pepper, lowercased address). |
| + // From HOME_PEPPER. |
| + Pepper []byte |
| + |
| + // SigninKey seals signin.Flow's pending blob. It must come from |
| + // config and never from an instance data key: sign-in runs before |
| + // any session exists. From HOME_SIGNIN_KEY. |
| + // |
| + // signin.Flow.Key is a [32]byte and Flow refuses a zero one with |
| + // ErrNoKey, so the size is the package's requirement, not ours. |
| + SigninKey [32]byte |
| + |
| + // Mailer delivers magic links. |
| + Mailer mail.Sender |
| +} |
| + |
| +// LoadSealer reads HOME_DEK and returns the process Sealer. |
| +// |
| +// It refuses to boot rather than generating a key. A generated key would |
| +// let the app start with every previously sealed link unreadable, which |
| +// looks like data loss and is discovered late. |
| +func LoadSealer() (*seal.Sealer, error) { |
| + dek, err := key32("HOME_DEK") |
| + if err != nil { |
| + return nil, err |
| + } |
| + return seal.New(dek[:]) |
| +} |
| + |
| +// key32 reads a base64 environment variable that must decode to exactly |
| +// 32 bytes, naming the variable in every error. |
| +// |
| +// Exactly, not "at least": a short value is the interesting case, and |
| +// accepting one would let a truncated secret — a copy-paste that lost |
| +// its trailing characters — key real ciphertext with silently less |
| +// entropy than intended. |
| +func key32(name string) ([32]byte, error) { |
| + var out [32]byte |
| + raw := strings.TrimSpace(os.Getenv(name)) |
| + if raw == "" { |
| + return out, fmt.Errorf("%s is not set", name) |
| + } |
| + b, err := base64.StdEncoding.DecodeString(raw) |
| + if err != nil { |
| + return out, fmt.Errorf("%s is not valid base64: %w", name, err) |
| + } |
| + if len(b) != 32 { |
| + return out, fmt.Errorf("%s decodes to %d bytes, want exactly 32", name, len(b)) |
| + } |
| + copy(out[:], b) |
| + return out, nil |
| +} |
| + |
| +// LoadSecrets reads the four secrets and the two settings whose absence |
| +// is silent, and refuses to boot on any of them. |
| +// |
| +// HOME_DEK seals link state, team names and queue payloads |
| +// HOME_PEPPER the blind index |
| +// HOME_SIGNIN_KEY seals signin.Flow's Keymail pending blob |
| +// CARLOS_SMTP_PASS the mailer's credential |
| +// |
| +// dbPath is passed in rather than read from the environment because |
| +// rastrillo.Resolve owns it (it comes from -db), and it is checked here |
| +// so that every boot refusal lives in one function an operator can read. |
| +func LoadSecrets(dbPath string, logger *slog.Logger) (Secrets, error) { |
| + if logger == nil { |
| + logger = slog.Default() |
| + } |
| + |
| + // Resolve sets DBPath only from -db, and SQLite treats an empty |
| + // filename as a private temporary database: the app comes up, works, |
| + // and loses every person on restart. There is no warning for this |
| + // anywhere below us, which is why it is checked here. |
| + if strings.TrimSpace(dbPath) == "" { |
| + return Secrets{}, errors.New("no database path: pass --db, or SQLite opens a private temporary database and every person is lost on restart") |
| + } |
| + |
| + var s Secrets |
| + |
| + dek, err := key32("HOME_DEK") |
| + if err != nil { |
| + return Secrets{}, err |
| + } |
| + s.Sealer, err = seal.New(dek[:]) |
| + if err != nil { |
| + return Secrets{}, fmt.Errorf("HOME_DEK: %w", err) |
| + } |
| + |
| + pepper, err := key32("HOME_PEPPER") |
| + if err != nil { |
| + return Secrets{}, err |
| + } |
| + s.Pepper = pepper[:] |
| + |
| + s.SigninKey, err = key32("HOME_SIGNIN_KEY") |
| + if err != nil { |
| + return Secrets{}, err |
| + } |
| + |
| + // mail.FromEnv falls back to mail.Logged with a warning when HOST or |
| + // FROM is absent, and Logged's Send logs "to", to — an address into |
| + // platform-held logs on every single sign-in. A warning is not |
| + // enough: that log is storage we cannot grep or redact, so this is |
| + // the one fallback we refuse instead of accept. |
| + // |
| + // CARLOS_SMTP_PASS is a different failure and is required for a |
| + // different reason: it does not leak anything, it just means every |
| + // link fails at send time on an app that otherwise looks healthy. |
| + // |
| + // Deliberately no loopback relaxation. Sheets' LoadAuthEnv allows |
| + // one for local development, keyed on its instance origin being an |
| + // http loopback host. The home has no origin variable at this step, |
| + // so the equivalent carve-out would have to invent one, and a |
| + // carve-out that turns off the leak guard is not a thing to invent |
| + // in passing. Local development without SMTP is deferred until |
| + // there is somewhere to put it. |
| + var missing []string |
| + for _, name := range []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_FROM", "CARLOS_SMTP_PASS"} { |
| + if strings.TrimSpace(os.Getenv(name)) == "" { |
| + missing = append(missing, name) |
| + } |
| + } |
| + if len(missing) > 0 { |
| + return Secrets{}, fmt.Errorf( |
| + "mail is not configured: %s not set, so magic links would be written to the log instead of emailed. Run `carlos email enable --app home`", |
| + strings.Join(missing, ", ")) |
| + } |
| + s.Mailer = mail.FromEnv("CARLOS", logger) |
| + |
| + return s, nil |
| +} |
diff --git a/internal/home/keys_test.go b/internal/home/keys_test.go| new file mode 100644 |
| index 0000000..8cd05f1 |
| --- /dev/null |
| +++ b/internal/home/keys_test.go |
| @@ -0,0 +1,153 @@ |
| +package home |
| + |
| +import ( |
| + "encoding/base64" |
| + "io" |
| + "log/slog" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// b64 is a valid, correctly-sized value for whichever secret needs one. |
| +func b64(n int) string { |
| + b := make([]byte, n) |
| + for i := range b { |
| + b[i] = byte(i) |
| + } |
| + return base64.StdEncoding.EncodeToString(b) |
| +} |
| + |
| +func quietLogger() *slog.Logger { |
| + return slog.New(slog.NewTextHandler(io.Discard, nil)) |
| +} |
| + |
| +// setSecretEnv clears every variable LoadSecrets reads, then applies the |
| +// overrides. Clearing first matters: t.Setenv restores on cleanup but does |
| +// not isolate a variable the developer happens to have exported, and a |
| +// test that passes only on a machine with CARLOS_SMTP_HOST set is worse |
| +// than no test. |
| +func setSecretEnv(t *testing.T, overrides map[string]string) { |
| + t.Helper() |
| + complete := map[string]string{ |
| + "HOME_DEK": b64(32), |
| + "HOME_PEPPER": b64(32), |
| + "HOME_SIGNIN_KEY": b64(32), |
| + "CARLOS_SMTP_HOST": "smtp.example.invalid", |
| + "CARLOS_SMTP_FROM": "home@example.invalid", |
| + "CARLOS_SMTP_PASS": "hunter2", |
| + } |
| + for k, v := range complete { |
| + if o, ok := overrides[k]; ok { |
| + v = o |
| + } |
| + t.Setenv(k, v) |
| + } |
| +} |
| + |
| +func TestLoadSealerRefusals(t *testing.T) { |
| + cases := map[string]string{ |
| + "unset": "", |
| + "not base64": "not-base64!!", |
| + "31 bytes": b64(31), |
| + "33 bytes": b64(33), |
| + } |
| + for name, val := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + t.Setenv("HOME_DEK", val) |
| + if _, err := LoadSealer(); err == nil { |
| + t.Fatalf("LoadSealer accepted HOME_DEK=%q", val) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLoadSealerAcceptsA32ByteKey(t *testing.T) { |
| + t.Setenv("HOME_DEK", b64(32)) |
| + s, err := LoadSealer() |
| + if err != nil { |
| + t.Fatalf("LoadSealer: %v", err) |
| + } |
| + if s == nil { |
| + t.Fatal("LoadSealer returned a nil Sealer and no error") |
| + } |
| +} |
| + |
| +func TestLoadSecretsAcceptsCompleteConfig(t *testing.T) { |
| + setSecretEnv(t, nil) |
| + got, err := LoadSecrets("/tmp/home.db", quietLogger()) |
| + if err != nil { |
| + t.Fatalf("LoadSecrets: %v", err) |
| + } |
| + if got.Sealer == nil { |
| + t.Error("no Sealer") |
| + } |
| + if len(got.Pepper) != 32 { |
| + t.Errorf("Pepper is %d bytes, want 32", len(got.Pepper)) |
| + } |
| + if got.SigninKey == ([32]byte{}) { |
| + t.Error("SigninKey is the zero value, which signin.Flow refuses") |
| + } |
| + if got.Mailer == nil { |
| + t.Error("no Mailer") |
| + } |
| +} |
| + |
| +// Each secret missing is its own refusal, and the message must name the |
| +// variable an operator has to set — an error that says "not configured" |
| +// without saying which one costs a support round-trip. |
| +func TestLoadSecretsRefusesEachMissingSecret(t *testing.T) { |
| + for _, name := range []string{"HOME_DEK", "HOME_PEPPER", "HOME_SIGNIN_KEY", "CARLOS_SMTP_PASS"} { |
| + t.Run(name, func(t *testing.T) { |
| + setSecretEnv(t, map[string]string{name: ""}) |
| + _, err := LoadSecrets("/tmp/home.db", quietLogger()) |
| + if err == nil { |
| + t.Fatalf("LoadSecrets accepted a missing %s", name) |
| + } |
| + if !strings.Contains(err.Error(), name) { |
| + t.Errorf("error %q does not name %s", err, name) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLoadSecretsRefusesMalformedSecrets(t *testing.T) { |
| + for _, name := range []string{"HOME_DEK", "HOME_PEPPER", "HOME_SIGNIN_KEY"} { |
| + for _, bad := range []string{"not-base64!!", b64(31)} { |
| + t.Run(name+"/"+bad, func(t *testing.T) { |
| + setSecretEnv(t, map[string]string{name: bad}) |
| + if _, err := LoadSecrets("/tmp/home.db", quietLogger()); err == nil { |
| + t.Fatalf("LoadSecrets accepted %s=%q", name, bad) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// mail.FromEnv falls back to a logging sender when HOST or FROM is |
| +// absent, and mail.Logged's Send logs "to", to — an address into |
| +// platform-held logs on every sign-in. A warning is not enough: the log |
| +// is storage we cannot grep or redact. |
| +func TestLoadSecretsRefusesWithoutSMTPHostOrFrom(t *testing.T) { |
| + for _, name := range []string{"CARLOS_SMTP_HOST", "CARLOS_SMTP_FROM"} { |
| + t.Run(name, func(t *testing.T) { |
| + setSecretEnv(t, map[string]string{name: ""}) |
| + _, err := LoadSecrets("/tmp/home.db", quietLogger()) |
| + if err == nil { |
| + t.Fatalf("LoadSecrets accepted a missing %s, so links would be logged", name) |
| + } |
| + if !strings.Contains(err.Error(), name) { |
| + t.Errorf("error %q does not name %s", err, name) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Resolve sets DBPath only from -db, and SQLite treats an empty filename |
| +// as a private temporary database: the app comes up, works, and loses |
| +// every person on restart. |
| +func TestLoadSecretsRefusesEmptyDBPath(t *testing.T) { |
| + setSecretEnv(t, nil) |
| + if _, err := LoadSecrets("", quietLogger()); err == nil { |
| + t.Fatal("LoadSecrets accepted an empty database path") |
| + } |
| +} |
diff --git a/internal/home/logs_test.go b/internal/home/logs_test.go| index d2d0309..2ddc2c3 100644 |
| --- a/internal/home/logs_test.go |
| +++ b/internal/home/logs_test.go |
| @@ -59,7 +59,7 @@ func TestNoContentInLogs(t *testing.T) { |
| log.SetFlags(prevFlags) |
| }) |
| - app := New() |
| + app, _ := newTestApp(t) |
| router := app.Router() |
| routes, ok := router.(chi.Routes) |
diff --git a/internal/home/seal/seal.go b/internal/home/seal/seal.go| new file mode 100644 |
| index 0000000..3f69fe8 |
| --- /dev/null |
| +++ b/internal/home/seal/seal.go |
| @@ -0,0 +1,99 @@ |
| +// Package seal is the home's only encryption surface. |
| +// |
| +// The one rule: an address, a team display name and a provisioning queue |
| +// payload are sealed at rest, so the database file — and every WAL frame |
| +// Litestream ships from it — never holds one in the clear. The DEK arrives |
| +// as platform config and is never written to the database it decrypts. |
| +// |
| +// Purpose keys are derived with HKDF so that a value sealed under one |
| +// purpose cannot be opened under another. That is not paranoia: these are |
| +// adjacent columns of the same row, and a bug that swapped them would be |
| +// invisible without this. |
| +// |
| +// Ported from oficina/sheets-core's internal/sheets/seal, deliberately |
| +// unchanged apart from this doc and the HKDF info string. Two apps in one |
| +// suite disagreeing about their envelope format is a bug waiting to |
| +// differ. |
| +package seal |
| + |
| +import ( |
| + "crypto/aes" |
| + "crypto/cipher" |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "errors" |
| + "hash" |
| + "io" |
| + |
| + "golang.org/x/crypto/hkdf" |
| +) |
| + |
| +func sha256New() hash.Hash { return sha256.New() } |
| + |
| +// Sealer holds the data encryption key. One per process. |
| +type Sealer struct{ dek []byte } |
| + |
| +// New returns a Sealer. The DEK must be exactly 32 bytes. |
| +func New(dek []byte) (*Sealer, error) { |
| + if len(dek) != 32 { |
| + return nil, errors.New("seal: DEK must be 32 bytes") |
| + } |
| + c := make([]byte, 32) |
| + copy(c, dek) |
| + return &Sealer{dek: c}, nil |
| +} |
| + |
| +// purposeKey derives the AEAD key for purpose via HKDF over the DEK. A |
| +// fixed 32-byte read is always inside HKDF-SHA256's 8160-byte expand |
| +// limit, so this cannot fail today — but the error is still checked and |
| +// propagated: this is the app's only encryption surface, and a silently |
| +// discarded read here would degrade into a short or zero-padded key if |
| +// the key length ever changed, which is the kind of bug that leaves |
| +// ciphertext looking perfectly fine while being trivially breakable. |
| +func (s *Sealer) purposeKey(purpose string) ([]byte, error) { |
| + key := make([]byte, 32) |
| + r := hkdf.New(sha256New, s.dek, nil, []byte("oficina/home:"+purpose)) |
| + if _, err := io.ReadFull(r, key); err != nil { |
| + return nil, err |
| + } |
| + return key, nil |
| +} |
| + |
| +// aeadFor derives the purpose key and returns its AEAD. |
| +func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) { |
| + key, err := s.purposeKey(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + block, err := aes.NewCipher(key) |
| + if err != nil { |
| + return nil, err |
| + } |
| + return cipher.NewGCM(block) |
| +} |
| + |
| +// Seal encrypts plaintext under purpose. The nonce is prepended. |
| +func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) { |
| + aead, err := s.aeadFor(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + nonce := make([]byte, aead.NonceSize()) |
| + if _, err := rand.Read(nonce); err != nil { |
| + return nil, err |
| + } |
| + return aead.Seal(nonce, nonce, plaintext, nil), nil |
| +} |
| + |
| +// Open decrypts a value sealed under the same purpose. |
| +func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) { |
| + aead, err := s.aeadFor(purpose) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if len(ct) < aead.NonceSize() { |
| + return nil, errors.New("seal: ciphertext too short") |
| + } |
| + nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():] |
| + return aead.Open(nil, nonce, body, nil) |
| +} |
diff --git a/internal/home/seal/seal_test.go b/internal/home/seal/seal_test.go| new file mode 100644 |
| index 0000000..b546515 |
| --- /dev/null |
| +++ b/internal/home/seal/seal_test.go |
| @@ -0,0 +1,52 @@ |
| +package seal |
| + |
| +import ( |
| + "bytes" |
| + "testing" |
| +) |
| + |
| +func testDEK() []byte { |
| + d := make([]byte, 32) |
| + for i := range d { |
| + d[i] = byte(i) |
| + } |
| + return d |
| +} |
| + |
| +func TestSealOpenRoundTrip(t *testing.T) { |
| + s, err := New(testDEK()) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + ct, err := s.Seal("signin.address", []byte("person@example.invalid")) |
| + if err != nil { |
| + t.Fatalf("Seal: %v", err) |
| + } |
| + if bytes.Contains(ct, []byte("example.invalid")) { |
| + t.Fatal("ciphertext contains the plaintext") |
| + } |
| + pt, err := s.Open("signin.address", ct) |
| + if err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + if string(pt) != "person@example.invalid" { |
| + t.Fatalf("Open = %q", pt) |
| + } |
| +} |
| + |
| +// A value sealed under one purpose must not open under another. These are |
| +// adjacent columns of the same row; a bug that swapped them would be |
| +// invisible without this. |
| +func TestWrongPurposeFails(t *testing.T) { |
| + s, _ := New(testDEK()) |
| + ct, _ := s.Seal("signin.address", []byte("secret")) |
| + if _, err := s.Open("team.name", ct); err == nil { |
| + t.Fatal("opened under the wrong purpose") |
| + } |
| +} |
| + |
| +func TestRejectsShortDEK(t *testing.T) { |
| + if _, err := New(make([]byte, 31)); err == nil { |
| + t.Fatal("accepted a 31-byte DEK") |
| + } |
| +} |
diff --git a/internal/home/sealtest/sealtest.go b/internal/home/sealtest/sealtest.go| new file mode 100644 |
| index 0000000..bfc9f1a |
| --- /dev/null |
| +++ b/internal/home/sealtest/sealtest.go |
| @@ -0,0 +1,98 @@ |
| +// Package sealtest is the plaintext gate: it proves, by reading raw |
| +// bytes, that a database file never holds a sealed value in the clear. |
| +// |
| +// It lives in its own package — not seal, and not a _test.go file — so |
| +// that store and handler tests, which are the ones that actually write |
| +// rows, can import it directly. AssertNoPlaintext takes a *testing.T, so |
| +// this package must never be imported from non-test code: doing so would |
| +// drag the testing package into the production binary. That rule is a |
| +// guard, not a comment — see internal/guardstest. |
| +package sealtest |
| + |
| +import ( |
| + "bytes" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// CanaryAddress is the address a test signs in with before running |
| +// AssertNoPlaintext. |
| +// |
| +// It is lowercase and address-shaped on purpose, and both halves matter. |
| +// Lowercase, because the blind index and signin's rate limiter both key |
| +// on strings.ToLower(address), so the lowercased copy is the one likeliest |
| +// to reach a store or an error string — an upper-case canary cannot see |
| +// it. Address-shaped, because the gate signs in with this value, and an |
| +// address parser rejects a string with two @ signs, which would make the |
| +// gate fail for the wrong reason. |
| +// |
| +// example.invalid is reserved by RFC 2606 and can never be delivered to. |
| +const CanaryAddress = "canary-8f3a@example.invalid" |
| + |
| +// Canaries are the strings a test seeds into a database before running |
| +// AssertNoPlaintext. Any of them appearing in the raw file bytes is a |
| +// plaintext leak. |
| +// |
| +// One per sealed purpose. A canary that is not in this slice is a value |
| +// AssertNoPlaintext never scans for, so a gate seeded with it passes |
| +// while proving nothing. |
| +var Canaries = [][]byte{ |
| + []byte(CanaryAddress), |
| + []byte("CANARY-TEAM-NAME-2b71"), |
| + []byte("CANARY-QUEUE-PAYLOAD-c40d"), |
| +} |
| + |
| +// leaked reports the first canary found in b, or nil if none is present. |
| +// Split out from AssertNoPlaintext so the detection logic can be |
| +// unit-tested directly, without needing a live *testing.T failure to |
| +// observe. |
| +func leaked(b []byte) []byte { |
| + for _, c := range Canaries { |
| + if bytes.Contains(b, c) { |
| + return c |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// findLeak scans dbPath's main file, its WAL and its SHM for a canary. It |
| +// checks the WAL because Litestream ships WAL frames continuously, and a |
| +// value written plainly reaches the bucket before any delete would remove |
| +// it from the main file. A nil canary means no leak was found. |
| +// |
| +// Split out from AssertNoPlaintext so the scan itself — including the |
| +// WAL/SHM handling — can be unit-tested directly, without routing a |
| +// deliberately-failing case through a live *testing.T (a failing subtest |
| +// always fails its parent, which would make this package's own tests |
| +// unable to ever go green). |
| +func findLeak(dbPath string) (canary []byte, file string, err error) { |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + p := dbPath + suffix |
| + b, err := os.ReadFile(p) |
| + if os.IsNotExist(err) { |
| + continue |
| + } |
| + if err != nil { |
| + return nil, "", fmt.Errorf("read %s: %w", filepath.Base(p), err) |
| + } |
| + if c := leaked(b); c != nil { |
| + return c, filepath.Base(p), nil |
| + } |
| + } |
| + return nil, "", nil |
| +} |
| + |
| +// AssertNoPlaintext greps a database file, its WAL and its SHM for |
| +// canaries. Any of them appearing in raw bytes is a plaintext leak. |
| +func AssertNoPlaintext(t *testing.T, dbPath string) { |
| + t.Helper() |
| + c, f, err := findLeak(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c != nil { |
| + t.Fatalf("plaintext %q found in %s", c, f) |
| + } |
| +} |
diff --git a/internal/home/sealtest/sealtest_test.go b/internal/home/sealtest/sealtest_test.go| new file mode 100644 |
| index 0000000..f38198f |
| --- /dev/null |
| +++ b/internal/home/sealtest/sealtest_test.go |
| @@ -0,0 +1,119 @@ |
| +package sealtest |
| + |
| +import ( |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/seal" |
| +) |
| + |
| +func testDEK() []byte { |
| + d := make([]byte, 32) |
| + for i := range d { |
| + d[i] = byte(i) |
| + } |
| + return d |
| +} |
| + |
| +// leaked is the detection core AssertNoPlaintext relies on. Exercising it |
| +// directly is the permanent proof that the gate CAN fail: these two tests |
| +// show it correctly flags a canary and correctly ignores clean bytes, |
| +// without needing to crash the test process to observe a failure. |
| +func TestLeakedDetectsCanary(t *testing.T) { |
| + for _, c := range Canaries { |
| + if got := leaked(c); got == nil { |
| + t.Fatalf("leaked(%q) = nil, want a match", c) |
| + } |
| + } |
| +} |
| + |
| +func TestLeakedIgnoresCleanBytes(t *testing.T) { |
| + if got := leaked([]byte("nothing here but person refs and blind index digests")); got != nil { |
| + t.Fatalf("leaked(clean) = %q, want nil", got) |
| + } |
| +} |
| + |
| +// The address canary is the one the gate signs in with, so it has to be |
| +// in the slice the gate scans, and it has to survive an address parser. |
| +// Both of those have been got wrong before: a canary outside Canaries |
| +// makes the gate pass while proving nothing, and an upper-case one cannot |
| +// see the lowercased copy that the blind index and signin's limiter key |
| +// on. |
| +func TestCanaryAddressIsScannedLowercaseAndAddressShaped(t *testing.T) { |
| + if leaked([]byte(CanaryAddress)) == nil { |
| + t.Error("CanaryAddress is not in Canaries, so the gate would never scan for it") |
| + } |
| + if CanaryAddress != strings.ToLower(CanaryAddress) { |
| + t.Errorf("CanaryAddress %q is not lowercase", CanaryAddress) |
| + } |
| + if n := strings.Count(CanaryAddress, "@"); n != 1 { |
| + t.Errorf("CanaryAddress %q has %d @ signs, want exactly 1", CanaryAddress, n) |
| + } |
| + if !strings.HasSuffix(CanaryAddress, ".invalid") { |
| + t.Errorf("CanaryAddress %q is not in a reserved domain, so it could be delivered to", CanaryAddress) |
| + } |
| +} |
| + |
| +// TestAssertNoPlaintextPassesOnSealedData is the gate's normal-operation |
| +// path: a canary sealed through the real Sealer must not be readable in |
| +// the raw bytes the gate scans. |
| +func TestAssertNoPlaintextPassesOnSealedData(t *testing.T) { |
| + s, err := seal.New(testDEK()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ct, err := s.Seal("signin.address", []byte(CanaryAddress)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + dbPath := filepath.Join(t.TempDir(), "sealed.db") |
| + if err := os.WriteFile(dbPath, ct, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + AssertNoPlaintext(t, dbPath) |
| +} |
| + |
| +// TestAssertNoPlaintextToleratesMissingSidecars proves the gate does not |
| +// require a WAL or SHM file to exist (most callers won't have one), while |
| +// still being willing to read them when they do — see TestFindLeakChecksWAL. |
| +func TestAssertNoPlaintextToleratesMissingSidecars(t *testing.T) { |
| + dbPath := filepath.Join(t.TempDir(), "clean.db") |
| + if err := os.WriteFile(dbPath, []byte("not a leak"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + AssertNoPlaintext(t, dbPath) |
| +} |
| + |
| +// TestFindLeakChecksWAL proves the gate's detection core reads the WAL |
| +// sidecar, not just the main file. Litestream ships WAL frames |
| +// continuously, so a value written plainly reaches the bucket before any |
| +// delete would remove it from the main file — a leak that only ever |
| +// touched the WAL must still be caught. This test seeds the canary into |
| +// the WAL only; a scan that checked just the main file would wrongly miss |
| +// it. (This is tested against findLeak rather than AssertNoPlaintext |
| +// itself: a failing subtest always fails its parent in Go's testing |
| +// package, so routing a deliberately-positive leak case through a live |
| +// *testing.T would leave this package permanently red.) |
| +func TestFindLeakChecksWAL(t *testing.T) { |
| + dir := t.TempDir() |
| + dbPath := filepath.Join(dir, "leaky.db") |
| + if err := os.WriteFile(dbPath, []byte("clean main file"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(dbPath+"-wal", Canaries[1], 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + c, f, err := findLeak(dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c == nil { |
| + t.Fatal("did not detect a canary present only in the WAL") |
| + } |
| + if f != "leaky.db-wal" { |
| + t.Fatalf("found in %q, want leaky.db-wal", f) |
| + } |
| +} |
diff --git a/internal/home/testapp_test.go b/internal/home/testapp_test.go| new file mode 100644 |
| index 0000000..1c2acb6 |
| --- /dev/null |
| +++ b/internal/home/testapp_test.go |
| @@ -0,0 +1,81 @@ |
| +package home |
| + |
| +import ( |
| + "context" |
| + "sync" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/seal" |
| +) |
| + |
| +// captureMailer is the Mailer every test App runs on. It records instead |
| +// of sending, and it is safe for concurrent use because mail.Sender's |
| +// contract requires that of implementations. |
| +type captureMailer struct { |
| + mu sync.Mutex |
| + sent []sentMail |
| +} |
| + |
| +type sentMail struct{ To, Subject, Body string } |
| + |
| +func (m *captureMailer) Send(_ context.Context, to, subject, body string) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.sent = append(m.sent, sentMail{To: to, Subject: subject, Body: body}) |
| + return nil |
| +} |
| + |
| +// last returns the most recent mail, or false if none was sent. |
| +func (m *captureMailer) last() (sentMail, bool) { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + if len(m.sent) == 0 { |
| + return sentMail{}, false |
| + } |
| + return m.sent[len(m.sent)-1], true |
| +} |
| + |
| +// testSecrets builds the Secrets a test App runs on. The keys are fixed |
| +// rather than random so a failure reproduces byte for byte. |
| +// |
| +// These are test values and are never the deployed ones: LoadSecrets is |
| +// the only path that reads real config, and it refuses rather than |
| +// generating anything. |
| +func testSecrets(t *testing.T) (Secrets, *captureMailer) { |
| + t.Helper() |
| + dek := make([]byte, 32) |
| + for i := range dek { |
| + dek[i] = byte(i) |
| + } |
| + sealer, err := seal.New(dek) |
| + if err != nil { |
| + t.Fatalf("seal.New: %v", err) |
| + } |
| + pepper := make([]byte, 32) |
| + for i := range pepper { |
| + pepper[i] = byte(0x40 + i) |
| + } |
| + var signinKey [32]byte |
| + for i := range signinKey { |
| + signinKey[i] = byte(0x80 + i) |
| + } |
| + mailer := &captureMailer{} |
| + return Secrets{ |
| + Sealer: sealer, |
| + Pepper: pepper, |
| + SigninKey: signinKey, |
| + Mailer: mailer, |
| + }, mailer |
| +} |
| + |
| +// newTestApp builds an App on test secrets. |
| +// |
| +// It starts small and grows. Later steps of the plan add the database |
| +// and its migrations, the LinkStore, and the signin.Flow; each extends |
| +// this helper rather than replacing it, so every test written before |
| +// then keeps compiling. |
| +func newTestApp(t *testing.T) (*App, *captureMailer) { |
| + t.Helper() |
| + secrets, mailer := testSecrets(t) |
| + return New(secrets), mailer |
| +} |