package auth import ( "bytes" "context" "log/slog" "os" "path/filepath" "strings" "testing" "time" "github.com/iceBear67/simplepages/internal/store" ) // bootstrapEnv is a database, a token path in a directory of its own, and a log // buffer to assert against. type bootstrapEnv struct { db *store.DB dir string log *slog.Logger buf *bytes.Buffer } func newBootstrapEnv(t *testing.T) *bootstrapEnv { t.Helper() var buf bytes.Buffer return &bootstrapEnv{ db: testStore(t), dir: t.TempDir(), log: slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf: &buf, } } func (e *bootstrapEnv) path() string { return filepath.Join(e.dir, "bootstrap-token") } func (e *bootstrapEnv) token(t *testing.T) string { t.Helper() raw, err := os.ReadFile(e.path()) if err != nil { t.Fatalf("read token file: %v", err) } return strings.TrimSpace(string(raw)) } func TestEnsureAdminKeyMintsOnEmptyDatabase(t *testing.T) { ctx := context.Background() e := newBootstrapEnv(t) minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log) if err != nil { t.Fatalf("EnsureAdminKey: %v", err) } if !minted { t.Fatal("minted = false on an empty database") } // The token in the file is the credential: it must actually authenticate. v := newVerifier(t, e.db) id, err := v.Verify(ctx, e.token(t)) if err != nil { t.Fatalf("the bootstrap token does not authenticate: %v", err) } if !id.IsAdmin() { t.Errorf("scope = %q, want admin", id.Scope) } if id.Name != BootstrapKeyName { t.Errorf("name = %q, want %q", id.Name, BootstrapKeyName) } // The operator is told where the file is and that it must be removed, and // the token itself never reaches the log — the file exists precisely so it // does not have to. logged := e.buf.String() if !strings.Contains(logged, e.path()) { t.Errorf("log does not name the token file: %s", logged) } if strings.Contains(logged, e.token(t)) { t.Fatal("the bootstrap token was written to the log") } if !strings.Contains(logged, id.KeyID) { t.Errorf("log does not name the key id, which is the safe half: %s", logged) } } // The file holds a live admin credential. Anything wider than 0600 hands it to // every account on the host. func TestBootstrapTokenFileIsPrivate(t *testing.T) { ctx := context.Background() e := newBootstrapEnv(t) if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil { t.Fatal(err) } fi, err := os.Lstat(e.path()) if err != nil { t.Fatal(err) } if perm := fi.Mode().Perm(); perm != 0o600 { t.Errorf("mode = %#o, want 0600", perm) } if fi.Mode()&os.ModeSymlink != 0 { t.Error("the token path is a symlink") } } func TestEnsureAdminKeyIsIdempotent(t *testing.T) { ctx := context.Background() e := newBootstrapEnv(t) if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil { t.Fatal(err) } first := e.token(t) // A restart must not mint a second admin key, and must not overwrite the // token the operator has not yet collected. minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log) if err != nil { t.Fatal(err) } if minted { t.Error("minted a second bootstrap key") } if got := e.token(t); got != first { t.Error("the token file was rewritten on the second run") } n, err := e.db.CountUsableAdminKeys(ctx) if err != nil { t.Fatal(err) } if n != 1 { t.Errorf("usable admin keys = %d, want 1", n) } // A leftover file is a live credential; the operator gets told about it. if !strings.Contains(e.buf.String(), "still present") { t.Errorf("no warning about the leftover token file: %s", e.buf.String()) } } // A revoked or expired admin key leaves the installation locked out, so // restarting must mint a fresh one rather than counting the dead key. func TestEnsureAdminKeyRecoversFromUnusableKeys(t *testing.T) { ctx := context.Background() t.Run("revoked", func(t *testing.T) { e := newBootstrapEnv(t) _, keyID := mintInto(t, e.db, store.ScopeAdmin, nil) if err := e.db.RevokeKey(ctx, keyID); err != nil { t.Fatal(err) } minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log) if err != nil { t.Fatal(err) } if !minted { t.Fatal("minted = false with only a revoked admin key") } }) t.Run("expired", func(t *testing.T) { e := newBootstrapEnv(t) token, _, hash, err := Mint() if err != nil { t.Fatal(err) } past := time.Now().Add(-time.Hour) k := &store.APIKey{ID: keyIDOf(t, token), SecretHash: hash[:], Scope: store.ScopeAdmin, Name: "old", ExpiresAt: &past} if err := e.db.CreateKey(ctx, k); err != nil { t.Fatal(err) } minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log) if err != nil { t.Fatal(err) } if !minted { t.Fatal("minted = false with only an expired admin key") } }) t.Run("project keys do not count", func(t *testing.T) { e := newBootstrapEnv(t) id := createProjectRow(t, e.db, "demo") mintInto(t, e.db, store.ScopeProject, &id) minted, err := EnsureAdminKey(ctx, e.db, e.path(), e.log) if err != nil { t.Fatal(err) } if !minted { t.Fatal("minted = false with only a project key") } }) } // The token path is attacker-controllable on a host where the data directory is // created before the server runs. Writing through a planted symlink would put a // live admin credential wherever it pointed — /etc/cron.d, another user's // ~/.ssh, a world-readable log. The rename-into-place replaces the link instead // of following it. func TestBootstrapTokenDoesNotFollowASymlink(t *testing.T) { ctx := context.Background() e := newBootstrapEnv(t) target := filepath.Join(t.TempDir(), "victim") if err := os.WriteFile(target, []byte("original\n"), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink(target, e.path()); err != nil { t.Fatal(err) } if _, err := EnsureAdminKey(ctx, e.db, e.path(), e.log); err != nil { t.Fatalf("EnsureAdminKey: %v", err) } victim, err := os.ReadFile(target) if err != nil { t.Fatal(err) } if string(victim) != "original\n" { t.Fatalf("the symlink target was overwritten with %q", victim) } fi, err := os.Lstat(e.path()) if err != nil { t.Fatal(err) } if fi.Mode()&os.ModeSymlink != 0 { t.Fatal("the token path is still a symlink; the token went somewhere else") } if perm := fi.Mode().Perm(); perm != 0o600 { t.Errorf("mode = %#o, want 0600", perm) } if !strings.HasPrefix(e.token(t), Prefix+"_") { t.Errorf("the token file does not hold a token") } } // A directory in the way must fail loudly rather than leave the server running // with an admin key nobody can use. func TestBootstrapTokenReportsAnUnwritablePath(t *testing.T) { ctx := context.Background() e := newBootstrapEnv(t) blocked := filepath.Join(e.dir, "blocked") if err := os.Mkdir(blocked, 0o755); err != nil { t.Fatal(err) } if _, err := EnsureAdminKey(ctx, e.db, blocked, e.log); err == nil { t.Fatal("EnsureAdminKey = nil, want an error when the token cannot be stored") } // And no key was created: a key whose token was never delivered is just a // row nobody can authenticate with. n, err := e.db.CountUsableAdminKeys(ctx) if err != nil { t.Fatal(err) } if n != 0 { t.Errorf("usable admin keys = %d, want 0", n) } } // keyIDOf recovers the public half of a token, which is all a test needs to // store the row. func keyIDOf(t *testing.T, token string) string { t.Helper() keyID, _, err := Parse(token) if err != nil { t.Fatalf("Parse: %v", err) } return keyID } // createProjectRow inserts a project so a project-scoped key has something to // point at, and returns its id. func createProjectRow(t *testing.T, db *store.DB, name string) int64 { t.Helper() p := store.DefaultProject(name) if err := db.CreateProject(context.Background(), p); err != nil { t.Fatalf("CreateProject: %v", err) } return p.ID }