Files
pages/internal/auth/bootstrap_test.go
T
2026-08-15 07:13:00 +00:00

286 lines
7.7 KiB
Go

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
}