package main import ( "context" "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/auth" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/config" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/httpx" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/setup" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/users" ) // stubEnroller satisfies setup.Enroller — this test never runs a ceremony. type stubEnroller struct{} func (stubEnroller) TOTPBegin(http.ResponseWriter, *http.Request, string) error { return nil } func (stubEnroller) TOTPVerify(http.ResponseWriter, *http.Request, string) error { return nil } // setupYAML is a fresh-instance config: setup mode, every secret a placeholder. const setupYAML = `setup: true server: listen: "127.0.0.1:8080" base_url: "https://myday.example.com" app: locale: "en-GB" database: path: "DBPATH" secrets: cookie_secret: "CHANGE_ME_32_BYTES_RANDOM" csrf_key: "CHANGE_ME_32_BYTES_RANDOM" totp_encryption_key: "CHANGE_ME_32_BYTES_RANDOM" ` // TestTOTPKeySurvivesSetupAndRestart pins the D-1 invariant (M2c): the TOTP key // the process GENERATES AT STARTUP is the key the wizard persists, so a restart // adopts the same key and the secrets enrolled during setup still unseal. A // regression here silently destroys every future TOTP secret. func TestTOTPKeySurvivesSetupAndRestart(t *testing.T) { ctx := context.Background() dir := t.TempDir() cfgPath := filepath.Join(dir, config.FileName) dbPath := filepath.Join(dir, "myday.db") body := strings.Replace(setupYAML, "DBPATH", dbPath, 1) if err := os.WriteFile(cfgPath, []byte(body), 0o600); err != nil { t.Fatalf("write config: %v", err) } cfg, err := config.Load(cfgPath) if err != nil { t.Fatalf("config.Load: %v", err) } // Startup (D-1 + M1s): every placeholder is replaced with a strong value // the PROCESS will use — the wizard's ceremony must not run on a shipped // placeholder key. key, generated, err := prepareTOTPKey(cfg) if err != nil { t.Fatalf("prepareTOTPKey: %v", err) } if !generated || config.SecretIsPlaceholder(key) || len(key) != auth.KeySize { t.Fatalf("the TOTP key was not generated (generated=%v len=%d)", generated, len(key)) } cfg.Secrets.TOTPEncryptionKey = key // run() adopts it in setup mode if _, err := prepareSigningKeys(cfg); err != nil { t.Fatalf("prepareSigningKeys: %v", err) } // First run: the wizard enrolls the admin and seals its TOTP secret with // the startup key. db, err := store.Open(cfg.Database.Path) if err != nil { t.Fatalf("store.Open: %v", err) } if err := db.Migrate(); err != nil { t.Fatalf("Migrate: %v", err) } aud := audit.New(db.DB) us := users.New(db.DB, aud) if _, err := us.Create(ctx, 0, "admin@example.com", "Claus", users.RoleAdmin); err != nil { t.Fatalf("create admin: %v", err) } sealer, err := auth.NewAEADSealer([]byte(key)) if err != nil { t.Fatalf("NewAEADSealer: %v", err) } ids := auth.New(db.DB, aud, auth.WithSealer(sealer)).Identities() idID, _, err := ids.Mint(ctx, 1) if err != nil { t.Fatalf("Mint: %v", err) } if err := ids.Activate(ctx, idID); err != nil { t.Fatalf("Activate: %v", err) } const secret = "JBSWY3DPEHPK3PXP" if err := ids.PutTOTPSecret(ctx, idID, []byte(secret)); err != nil { t.Fatalf("PutTOTPSecret: %v", err) } // The wizard persists the config (`finish`). pre, err := httpx.NewPreSessionCSRF([]byte(cfg.Secrets.CookieSecret), false) if err != nil { t.Fatalf("NewPreSessionCSRF: %v", err) } wiz, err := setup.New(setup.Deps{ Config: cfg, ConfigPath: cfgPath, Users: us, Identities: ids, Enroll: stubEnroller{}, CSRF: pre, Audit: aud, Printout: io.Discard, }) if err != nil { t.Fatalf("setup.New: %v", err) } form := url.Values{"app.locale": {"en-GB"}, "app.timezone": {"Europe/London"}} req := httptest.NewRequest("POST", "/setup?do=setup&action=finish", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() if err := wiz.Finish(rec, req); err != nil { t.Fatalf("Finish: %v", err) } if rec.Code != http.StatusSeeOther { t.Fatalf("Finish = %d, want 303", rec.Code) } if err := db.Close(); err != nil { t.Fatalf("close db: %v", err) } // Restart: the persisted config must carry the SAME TOTP key, and the // sealed secret must still open with it (D-1 — the whole point). restored, err := config.Load(cfgPath) if err != nil { t.Fatalf("the persisted config does not load after setup: %v", err) } if restored.Setup { t.Error("the persisted config still says setup: true") } if restored.Secrets.TOTPEncryptionKey != key { t.Fatalf("the persisted TOTP key differs from the runtime key (D-1):\n got %q\nwant %q", restored.Secrets.TOTPEncryptionKey, key) } db2, err := store.Open(restored.Database.Path) if err != nil { t.Fatalf("reopen db: %v", err) } defer func() { _ = db2.Close() }() sealer2, err := auth.NewAEADSealer([]byte(restored.Secrets.TOTPEncryptionKey)) if err != nil { t.Fatalf("restart sealer: %v", err) } ids2 := auth.New(db2.DB, nil, auth.WithSealer(sealer2)).Identities() got, err := ids2.TOTPSecret(ctx, idID) if err != nil { t.Fatalf("the TOTP secret no longer unseals after a restart (D-1/M2c): %v", err) } if string(got) != secret { t.Errorf("TOTP secret after restart = %q, want %q", got, secret) } }