oficina / sheets Public

test: add guarded temporary canary session bootstrap

Paul Campbell pushed by paul@keymail.dev aca6668fa2765eba5c6b70b27f37d1071fbec973 parent 0b3cfae
12 files changed, +736 −6
  • .gitignore +2 −0
  • Makefile +14 −5
  • cmd/sheets-fixture-preflight/main_test.go +1 −1
  • cmd/sheets-fixture/main.go +63 −0
  • docs/superpowers/specs/2026-09-07-canary-fixture-bootstrap.md +9 −0
  • tools/canaryfixture/config.go +79 −0
  • tools/canaryfixture/config_test.go +59 −0
  • tools/canaryfixture/paths.go +65 −0
  • tools/canaryfixture/paths_test.go +70 −0
  • tools/canaryfixture/seed.go +99 −0
  • tools/canaryfixture/seed_test.go +197 −0
  • tools/canaryfixture/session_test.go +78 −0

CI passed — run details

Intent

Human

READ-ONLY Fable preflight CODE review; no edits/deploys/secret reads. Repo /home/paulca/amadan.net/oficina/sheets-core/.claude/worktrees/overnight-fixture exact HEAD0b3cfae. Approved preflight design b340a3d review /tmp/sheets-bootstrap-fable-review3.json. Review git diff b340a3d..HEAD (space after diff): cmd/sheets-fixture-preflight/main.go and main_test.go, Makefile fixture-preflight-release, gitignore and clarified spec notes. Stdlib-only preflight validates compiled unique stamp/runID, exact actual child origin/owner/SMTP/keys and socket, raw absolute-clean canary DB basename, rejects unsupported seed/addr/LISTEN_FDS, logs only paths+stamp, then exits without opening DB/socket/files. Candidate importgraph excludes fixture; no writer yet. Full make test passed incl race; focused no-created-file/negative/importgraph tests pass. Dirty-tree build refuses; clean target built arm64 observer stamped0b3cfae-fixture-preflight-sheets-preflight-localtest (local validation only; root picks deployment scopeID). Exact-head CI record /tmp/sheets-fixture-ci.json. Root verifies empty seedargv/log/current platformcontract before actual canary use; default/prod environment untouched. Explicit APPROVE or REQUEST CHANGES for preflight CODE only; no implicit writer/candidate/deployment approval. Assess no-secret/no-cross-tenant-write guarantees and actionable gaps. Production candidate remains1aca44c; inherited atomic review is separate.

diff --git a/.gitignore b/.gitignore
index 60c268b..b6b8259 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,5 @@
/sheets-arm64
/sheets-fixture-preflight-arm64
+
+/sheets-fixture-arm64
diff --git a/Makefile b/Makefile
index 1b3084e..1b6aad4 100644
--- a/Makefile
+++ b/Makefile
@@ -16,9 +16,7 @@ build:
# A dirty tree is refused: the sha would name a commit that is not what
# is in the binary, which is worse than no stamp at all.
RELEASE_OUT ?= ./sheets-arm64
-release:
- @git diff --quiet && git diff --cached --quiet || \
- { echo "refusing to build a release from a dirty tree"; exit 1; }
+release: release-tree
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath \
-ldflags "-s -w -X github.com/carlosframework/rastrillo.BuildVersion=$$(git rev-parse --short HEAD)" \
-o $(RELEASE_OUT) ./cmd/sheets
@@ -92,10 +90,21 @@ test-race:
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
.PHONY: fixture-preflight-release
-fixture-preflight-release:
- @git diff --quiet && git diff --cached --quiet || exit 1
+fixture-preflight-release: release-tree
@case "$${FIXTURE_RUN_ID}" in ''|*[!a-z0-9-]*) exit 1;; esac
@test "$${#FIXTURE_RUN_ID}" -ge 8 && test "$${#FIXTURE_RUN_ID}" -le 64
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath \
-ldflags "-s -w -X main.buildVersion=$$(git rev-parse --short HEAD)-fixture-preflight-$${FIXTURE_RUN_ID} -X main.runID=$${FIXTURE_RUN_ID}" \
-o ./sheets-fixture-preflight-arm64 ./cmd/sheets-fixture-preflight
+
+.PHONY: release-tree fixture-release
+release-tree:
+ @test -z "$$(git status --porcelain)" || \
+ { echo "refusing to build a release from a dirty tree"; exit 1; }
+
+fixture-release: release-tree
+ @case "$${FIXTURE_RUN_ID}" in ''|*[!a-z0-9-]*) exit 1;; esac
+ @test "$${#FIXTURE_RUN_ID}" -ge 8 && test "$${#FIXTURE_RUN_ID}" -le 64
+ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath \
+ -ldflags "-s -w -X github.com/carlosframework/rastrillo.BuildVersion=$$(git rev-parse --short HEAD)-fixture-$${FIXTURE_RUN_ID} -X main.runID=$${FIXTURE_RUN_ID}" \
+ -o ./sheets-fixture-arm64 ./cmd/sheets-fixture
diff --git a/cmd/sheets-fixture-preflight/main_test.go b/cmd/sheets-fixture-preflight/main_test.go
index 5b12118..ce3ef25 100644
--- a/cmd/sheets-fixture-preflight/main_test.go
+++ b/cmd/sheets-fixture-preflight/main_test.go
@@ -61,7 +61,7 @@ func TestPreflightRefusesGuardMismatchesWithoutLeakingValues(t *testing.T) {
}
})
}
- for _, key := range []string{"SHEETS_INSTANCE_KEY", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS"} {
+ for _, key := range []string{"SHEETS_INSTANCE_KEY", "SHEETS_DEK", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS"} {
t.Run("missing-"+key, func(t *testing.T) {
env := fixtureEnv()
delete(env, key)
diff --git a/cmd/sheets-fixture/main.go b/cmd/sheets-fixture/main.go
new file mode 100644
index 0000000..cd8d204
--- /dev/null
+++ b/cmd/sheets-fixture/main.go
@@ -0,0 +1,63 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "time"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+ "amadan.net/oficina/sheets/tools/canaryfixture"
+ "github.com/carlosframework/rastrillo"
+ rdb "github.com/carlosframework/rastrillo/db"
+)
+
+var runID = ""
+
+func main() {
+ if err := run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ cfg, err := canaryfixture.Validate(os.Args[1:], os.LookupEnv, rastrillo.BuildVersion, runID, time.Now())
+ if err != nil {
+ return err
+ }
+ quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
+ opts, err := rastrillo.Resolve(rastrillo.Options{Logger: quiet})
+ if err != nil || opts.DBPath != canaryfixture.DBPath || opts.Socket != canaryfixture.SocketPath {
+ return fmt.Errorf("fixture-writer:resolve")
+ }
+ if _, err = sheets.LoadAuthEnv(quiet); err != nil {
+ return fmt.Errorf("fixture-writer:auth-env")
+ }
+ if _, err = sheets.LoadSealer(); err != nil {
+ return fmt.Errorf("fixture-writer:sealer")
+ }
+ paths, err := canaryfixture.CheckPaths()
+ if err != nil {
+ return err
+ }
+ db, err := rdb.Open(opts.DBPath, quiet)
+ if err != nil {
+ return fmt.Errorf("fixture-writer:db-open")
+ }
+ defer db.Close()
+ if err = paths.AfterOpen(); err != nil {
+ return err
+ }
+ if err = canaryfixture.Initialize(db, cfg, time.Now()); err != nil {
+ return err
+ }
+ opts.Mux = http.NewServeMux()
+ opts.DBPath = ""
+ if err = rastrillo.Serve(opts); err != nil {
+ return fmt.Errorf("fixture-writer:serve")
+ }
+ return nil
+}
diff --git a/docs/superpowers/specs/2026-09-07-canary-fixture-bootstrap.md b/docs/superpowers/specs/2026-09-07-canary-fixture-bootstrap.md
index 63ae6f3..ac45178 100644
--- a/docs/superpowers/specs/2026-09-07-canary-fixture-bootstrap.md
+++ b/docs/superpowers/specs/2026-09-07-canary-fixture-bootstrap.md
@@ -76,3 +76,12 @@ Required focused tests also include: relative DB arguments rejected before Resol
## Native seed path
The existing platform supports a first-boot seed argv on an instance record; ExecBackend.Seed invokes that release binary with the configured argv followed by --db <path>, under the same merged environment, only when no local database or replica exists. This is an existing supported lifecycle, not an HTTP auth mechanism. It can be used only if root verifies the actual instance seed configuration through the supported CLI/API and Fable reviews the separate bootstrap command. The command must retain the same origin/run/owner/empty-database/exact-DB-path guards; a one-shot native seed has no socket argument and must verify the corresponding expected route socket in root's control-plane preflight. It writes ordinary session hashes into the existing schema, exits, and introduces no fixture command into the normal candidate. The candidate's deployed manifest/instance record must not keep a bootstrap seed argv referring to unsupported candidate commands. The initial fallback remains the simpler reviewed startup-only bootstrap; no native seed configuration has been changed.
+
+
+## Recorded activation evidence and writer implementation
+
+Root observed the reviewed preflight 0b3cfae on the isolated canary with stamp 0b3cfae-fixture-preflight-r20260907-0fdf18e82c2c0178. The app log at 2026-09-07 20:47:23 UTC reported /data/overnight-sheets.bds.oncarlos.com.db and /run/carlos/overnight-sheets.bds.oncarlos.com.sock after every environment/shape guard passed. Root also confirmed the instance seed argv count is zero. The observer intentionally never served HTTP. This is runtime argv evidence, not a database-emptiness claim.
+
+The writer compiles exactly those paths in tools/canaryfixture. It checks emptiness before migrations and rechecks inside the session-insert transaction afterward. Table enumeration uses a literal sqlite_ prefix exclusion (GLOB), not SQL LIKE's underscore wildcard, so a future application table such as sqliteXfuture_fixture_guard cannot evade the check. Errors are fixed guard codes; the DB/framework logger is discarded so failed SQL cannot log session hashes. Normal candidate LoadAuthEnv and LoadSealer run before DB open. Only a fresh separate writer run ID is permitted by the deployment sequence.
+
+All three release targets share a dirty-tree check covering tracked and untracked files. The writer target uses its own <sha>-fixture-<runID> stamp. The normal candidate remains a separately built, reviewed commit whose import graph excludes every fixture package. The writer exposes only the framework health/version routes through an otherwise empty mux.
diff --git a/tools/canaryfixture/config.go b/tools/canaryfixture/config.go
new file mode 100644
index 0000000..113d83d
--- /dev/null
+++ b/tools/canaryfixture/config.go
@@ -0,0 +1,79 @@
+package canaryfixture
+
+import (
+ "encoding/base64"
+ "errors"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+const Host = "overnight-sheets.bds.oncarlos.com"
+const Origin = "https://" + Host
+const Owner = "oficina-fixture@example.test"
+const DBPath = "/data/" + Host + ".db"
+const SocketPath = "/run/carlos/" + Host + ".sock"
+
+type Config struct {
+ Hash string
+ Expires time.Time
+}
+
+func refusal(code string) error { return errors.New("fixture-writer:" + code) }
+
+func Validate(args []string, env func(string) (string, bool), version, runID string, now time.Time) (Config, error) {
+ fail := func(code string) (Config, error) { return Config{}, refusal(code) }
+ if len(runID) < 8 || len(runID) > 64 || strings.IndexFunc(runID, func(c rune) bool { return !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') }) >= 0 {
+ return fail("run-id-build")
+ }
+ sha, ok := strings.CutSuffix(version, "-fixture-"+runID)
+ if !ok || len(sha) < 7 || len(sha) > 40 || strings.IndexFunc(sha, notHex) >= 0 {
+ return fail("version-build")
+ }
+ for _, check := range []struct{ key, want, code string }{
+ {"SHEETS_ORIGIN", Origin, "origin"}, {"SHEETS_OWNERS", Owner, "owner"}, {"SHEETS_FIXTURE_RUN_ID", runID, "run-id-env"},
+ {"CARLOS_SMTP_HOST", "smtp-fixture.invalid", "smtp-host"}, {"CARLOS_SMTP_FROM", Owner, "smtp-from"}, {"CARLOS_SMTP_PORT", "587", "smtp-port"},
+ {"CARLOS_SMTP_USER", "", "smtp-user"}, {"CARLOS_SMTP_PASS", "", "smtp-pass"},
+ } {
+ value, present := env(check.key)
+ if !present || value != check.want {
+ return fail(check.code)
+ }
+ }
+ key, _ := env("SHEETS_INSTANCE_KEY")
+ if strings.TrimSpace(key) == "" {
+ return fail("instance-key")
+ }
+ dek, _ := env("SHEETS_DEK")
+ decoded, err := base64.StdEncoding.DecodeString(dek)
+ if err != nil || len(decoded) != 32 {
+ return fail("dek")
+ }
+ if fds, _ := env("LISTEN_FDS"); fds != "" {
+ return fail("listen-fds")
+ }
+ if len(args) != 4 || args[0] != "--socket" || args[2] != "--db" {
+ return fail("argv-shape")
+ }
+ if args[1] != SocketPath {
+ return fail("socket")
+ }
+ if !filepath.IsAbs(args[3]) || filepath.Clean(args[3]) != args[3] || args[3] != DBPath {
+ return fail("db-path")
+ }
+ if state, _ := env("STATE_DIRECTORY"); state != "" && filepath.Clean(state) != filepath.Dir(DBPath) {
+ return fail("state-directory")
+ }
+ hash, _ := env("SHEETS_FIXTURE_TOKEN_HASH")
+ if len(hash) != 64 || strings.IndexFunc(hash, notHex) >= 0 {
+ return fail("session-hash")
+ }
+ rawExpiry, _ := env("SHEETS_FIXTURE_EXPIRES_AT")
+ expiry, err := time.Parse(time.RFC3339, rawExpiry)
+ if err != nil || expiry.Location() != time.UTC || !expiry.After(now) || expiry.After(now.Add(2*time.Hour)) {
+ return fail("session-expiry")
+ }
+ return Config{Hash: hash, Expires: expiry}, nil
+}
+
+func notHex(c rune) bool { return !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f') }
diff --git a/tools/canaryfixture/config_test.go b/tools/canaryfixture/config_test.go
new file mode 100644
index 0000000..786efa3
--- /dev/null
+++ b/tools/canaryfixture/config_test.go
@@ -0,0 +1,59 @@
+package canaryfixture
+
+import (
+ "encoding/base64"
+ "strings"
+ "testing"
+ "time"
+)
+
+func writerEnv(now time.Time) map[string]string {
+ return map[string]string{
+ "SHEETS_ORIGIN": Origin, "SHEETS_OWNERS": Owner, "SHEETS_FIXTURE_RUN_ID": "writer-test-1234",
+ "CARLOS_SMTP_HOST": "smtp-fixture.invalid", "CARLOS_SMTP_FROM": Owner, "CARLOS_SMTP_PORT": "587",
+ "CARLOS_SMTP_USER": "", "CARLOS_SMTP_PASS": "", "SHEETS_INSTANCE_KEY": "synthetic-key",
+ "SHEETS_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)), "SHEETS_FIXTURE_TOKEN_HASH": strings.Repeat("a", 64),
+ "SHEETS_FIXTURE_EXPIRES_AT": now.Add(time.Hour).Format(time.RFC3339),
+ }
+}
+func lookupEnv(values map[string]string) func(string) (string, bool) {
+ return func(key string) (string, bool) { v, ok := values[key]; return v, ok }
+}
+func TestWriterConfigurationRequiresExactScope(t *testing.T) {
+ now := time.Now().UTC()
+ env := writerEnv(now)
+ args := []string{"--socket", SocketPath, "--db", DBPath}
+ version := "abcdef1-fixture-writer-test-1234"
+ if _, err := Validate(args, lookupEnv(env), version, "writer-test-1234", now); err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"SHEETS_ORIGIN", "SHEETS_OWNERS", "SHEETS_FIXTURE_RUN_ID", "CARLOS_SMTP_HOST", "CARLOS_SMTP_FROM", "CARLOS_SMTP_PORT", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS", "SHEETS_DEK", "SHEETS_FIXTURE_TOKEN_HASH", "SHEETS_FIXTURE_EXPIRES_AT"} {
+ t.Run(key, func(t *testing.T) {
+ bad := writerEnv(now)
+ bad[key] = "never-print-this-value"
+ _, err := Validate(args, lookupEnv(bad), version, "writer-test-1234", now)
+ if err == nil || strings.Contains(err.Error(), bad[key]) {
+ t.Fatal("guard accepted mismatch or exposed value")
+ }
+ })
+ }
+ for _, key := range []string{"SHEETS_INSTANCE_KEY", "SHEETS_DEK", "CARLOS_SMTP_USER", "CARLOS_SMTP_PASS"} {
+ bad := writerEnv(now)
+ delete(bad, key)
+ if _, err := Validate(args, lookupEnv(bad), version, "writer-test-1234", now); err == nil {
+ t.Fatal("absent guard accepted")
+ }
+ }
+ for _, bad := range [][]string{{"--socket", SocketPath, "--db", "relative.db"}, {"--socket", SocketPath, "--db", "/other/" + Host + ".db"}, {"--socket", "/run/other.sock", "--db", DBPath}, {"seed", "--db", DBPath}} {
+ if _, err := Validate(bad, lookupEnv(env), version, "writer-test-1234", now); err == nil {
+ t.Fatal("wrong activation accepted")
+ }
+ }
+ if _, err := Validate(args, lookupEnv(env), "abcdef1-fixture-preflight-writer-test-1234", "writer-test-1234", now); err == nil {
+ t.Fatal("preflight stamp accepted")
+ }
+ env["STATE_DIRECTORY"] = "/other"
+ if _, err := Validate(args, lookupEnv(env), version, "writer-test-1234", now); err == nil {
+ t.Fatal("contradictory state directory accepted")
+ }
+}
diff --git a/tools/canaryfixture/paths.go b/tools/canaryfixture/paths.go
new file mode 100644
index 0000000..4b94472
--- /dev/null
+++ b/tools/canaryfixture/paths.go
@@ -0,0 +1,65 @@
+package canaryfixture
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+)
+
+type PathCheck struct{ before os.FileInfo }
+
+func CheckPaths() (PathCheck, error) {
+ before, err := checkPath(DBPath, DBPath, false)
+ if err != nil {
+ return PathCheck{}, err
+ }
+ if _, err = checkPath(SocketPath, SocketPath, true); err != nil {
+ return PathCheck{}, err
+ }
+ return PathCheck{before: before}, nil
+}
+
+func (check PathCheck) AfterOpen() error { return check.afterOpen(DBPath) }
+
+func (check PathCheck) afterOpen(path string) error {
+ current, err := checkPath(path, path, false)
+ if err != nil {
+ return err
+ }
+ if current == nil {
+ return refusal("db-absent-after-open")
+ }
+ if check.before != nil && !os.SameFile(check.before, current) {
+ return refusal("db-replaced")
+ }
+ return nil
+}
+
+func checkPath(path, expected string, socket bool) (os.FileInfo, error) {
+ if !filepath.IsAbs(path) || filepath.Clean(path) != path || path != expected {
+ return nil, refusal("path-mismatch")
+ }
+ parent := filepath.Dir(expected)
+ resolved, err := filepath.EvalSymlinks(parent)
+ if err != nil || resolved != parent {
+ return nil, refusal("path-parent")
+ }
+ info, err := os.Lstat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, refusal("path-stat")
+ }
+ if info.Mode()&os.ModeSymlink != 0 {
+ return nil, refusal("path-symlink")
+ }
+ if socket {
+ if info.Mode()&os.ModeSocket == 0 {
+ return nil, refusal("socket-type")
+ }
+ } else if !info.Mode().IsRegular() {
+ return nil, refusal("db-type")
+ }
+ return info, nil
+}
diff --git a/tools/canaryfixture/paths_test.go b/tools/canaryfixture/paths_test.go
new file mode 100644
index 0000000..742b482
--- /dev/null
+++ b/tools/canaryfixture/paths_test.go
@@ -0,0 +1,70 @@
+package canaryfixture
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestPathGuardsRejectWrongPathsAndSymlinks(t *testing.T) {
+ root := t.TempDir()
+ expected := filepath.Join(root, "canary.db")
+ if info, err := checkPath(expected, expected, false); err != nil || info != nil {
+ t.Fatal("fresh path refused")
+ }
+ for _, wrong := range []string{"canary.db", filepath.Join(t.TempDir(), "canary.db"), root + "/../canary.db"} {
+ if _, err := checkPath(wrong, expected, false); err == nil {
+ t.Fatal("wrong path accepted")
+ }
+ }
+ target := filepath.Join(root, "target")
+ if err := os.WriteFile(target, []byte("unchanged"), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(target, expected); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := checkPath(expected, expected, false); err == nil {
+ t.Fatal("symlink target accepted")
+ }
+ data, err := os.ReadFile(target)
+ if err != nil || string(data) != "unchanged" {
+ t.Fatal("symlink target changed")
+ }
+ link := filepath.Join(t.TempDir(), "linked-parent")
+ if err := os.Symlink(root, link); err != nil {
+ t.Fatal(err)
+ }
+ linked := filepath.Join(link, "new.db")
+ if _, err := checkPath(linked, linked, false); err == nil {
+ t.Fatal("symlink parent accepted")
+ }
+ if _, err := checkPath(target, target, true); err == nil {
+ t.Fatal("regular file accepted as socket")
+ }
+}
+
+func TestPostOpenCheckRejectsReplacedDatabase(t *testing.T) {
+ root := t.TempDir()
+ path := filepath.Join(root, "canary.db")
+ if err := os.WriteFile(path, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ before, err := checkPath(path, path, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ check := PathCheck{before: before}
+ if err = check.afterOpen(path); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.Rename(path, path+".old"); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.WriteFile(path, nil, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err = check.afterOpen(path); err == nil {
+ t.Fatal("replaced database accepted")
+ }
+}
diff --git a/tools/canaryfixture/seed.go b/tools/canaryfixture/seed.go
new file mode 100644
index 0000000..93e9d89
--- /dev/null
+++ b/tools/canaryfixture/seed.go
@@ -0,0 +1,99 @@
+package canaryfixture
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+ rdb "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "gorm.io/gorm"
+)
+
+type sessionRow struct {
+ TokenHash string
+ Subject string
+ Method string
+ AuthTime string
+ CreatedAt string
+ ExpiresAt string
+}
+
+func Initialize(db *rdb.DB, cfg Config, now time.Time) error {
+ if len(cfg.Hash) != 64 || strings.IndexFunc(cfg.Hash, notHex) >= 0 {
+ return refusal("session-hash")
+ }
+ if !cfg.Expires.After(now) || cfg.Expires.After(now.Add(2*time.Hour)) {
+ return refusal("session-expiry")
+ }
+ if err := db.G.Transaction(func(tx *gorm.DB) error { _, err := emptyState(tx, cfg, now); return err }); err != nil {
+ return err
+ }
+ if _, err := migrate.Apply(context.Background(), db, sheets.BootSchema); err != nil {
+ return refusal("migration")
+ }
+ return db.G.Transaction(func(tx *gorm.DB) error {
+ exists, err := emptyState(tx, cfg, now)
+ if err != nil {
+ return err
+ }
+ if exists {
+ return nil
+ }
+ result := tx.Table("sessions").Create(map[string]any{
+ "token_hash": cfg.Hash, "subject": Owner, "method": "fixture", "auth_time": "",
+ "created_at": now.UTC().Format(time.RFC3339), "expires_at": cfg.Expires.UTC().Format(time.RFC3339),
+ })
+ if result.Error != nil || result.RowsAffected != 1 {
+ return refusal("session-insert")
+ }
+ return nil
+ })
+}
+
+func userTables(db *gorm.DB) ([]string, error) {
+ var tables []string
+ if err := db.Raw("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT GLOB 'sqlite_*' AND name <> 'rastrillo_migrations' ORDER BY name").Scan(&tables).Error; err != nil {
+ return nil, refusal("table-list")
+ }
+ return tables, nil
+}
+
+func emptyState(tx *gorm.DB, cfg Config, now time.Time) (bool, error) {
+ tables, err := userTables(tx)
+ if err != nil {
+ return false, err
+ }
+ found := false
+ for _, table := range tables {
+ if table == "sessions" {
+ var rows []sessionRow
+ if err := tx.Table("sessions").Limit(2).Find(&rows).Error; err != nil {
+ return false, refusal("session-read")
+ }
+ if len(rows) > 1 {
+ return false, refusal("sessions-not-empty")
+ }
+ if len(rows) == 1 {
+ row := rows[0]
+ created, err := time.Parse(time.RFC3339, row.CreatedAt)
+ if err != nil || created.After(now) || !cfg.Expires.After(created) || cfg.Expires.After(created.Add(2*time.Hour)) ||
+ row.TokenHash != cfg.Hash || row.Subject != Owner || row.Method != "fixture" || row.AuthTime != "" || row.ExpiresAt != cfg.Expires.UTC().Format(time.RFC3339) {
+ return false, refusal("session-mismatch")
+ }
+ found = true
+ }
+ continue
+ }
+ var count int64
+ quoted := "\"" + strings.ReplaceAll(table, "\"", "\"\"") + "\""
+ if err := tx.Raw("SELECT COUNT(*) FROM " + quoted).Scan(&count).Error; err != nil {
+ return false, refusal("table-read")
+ }
+ if count != 0 {
+ return false, refusal("database-not-empty")
+ }
+ }
+ return found, nil
+}
diff --git a/tools/canaryfixture/seed_test.go b/tools/canaryfixture/seed_test.go
new file mode 100644
index 0000000..8d9800c
--- /dev/null
+++ b/tools/canaryfixture/seed_test.go
@@ -0,0 +1,197 @@
+package canaryfixture
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "path/filepath"
+ "reflect"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/sheets/internal/sheets"
+ rdb "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
+ "gorm.io/gorm"
+)
+
+func testDB(t *testing.T) *rdb.DB {
+ t.Helper()
+ d, err := rdb.Open(filepath.Join(t.TempDir(), "test.db"), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ return d
+}
+func testConfig(t *testing.T) (Config, string, time.Time) {
+ t.Helper()
+ token, hash, err := sessions.NewToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ now := time.Now().UTC().Truncate(time.Second)
+ return Config{Hash: hash, Expires: now.Add(time.Hour)}, token, now
+}
+func readSession(t *testing.T, d *rdb.DB) sessionRow {
+ t.Helper()
+ var row sessionRow
+ if err := d.G.Table("sessions").First(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ return row
+}
+
+func TestInitializeCreatesOnlyOrdinarySessionAndIsIdempotent(t *testing.T) {
+ d := testDB(t)
+ cfg, _, now := testConfig(t)
+ if err := Initialize(d, cfg, now); err != nil {
+ t.Fatal(err)
+ }
+ row := readSession(t, d)
+ if row.TokenHash != cfg.Hash || row.Subject != Owner || row.Method != "fixture" || row.AuthTime != "" {
+ t.Fatal("unexpected session shape")
+ }
+ if err := Initialize(d, cfg, now.Add(time.Minute)); err != nil {
+ t.Fatal(err)
+ }
+ if next := readSession(t, d); !reflect.DeepEqual(row, next) {
+ t.Fatal("idempotent start changed session")
+ }
+ for _, changed := range []Config{{Hash: cfg.Hash, Expires: cfg.Expires.Add(time.Second)}, {Hash: sessions.HashToken("other"), Expires: cfg.Expires}} {
+ if err := Initialize(d, changed, now.Add(time.Minute)); err == nil {
+ t.Fatal("changed fixture accepted")
+ }
+ if next := readSession(t, d); !reflect.DeepEqual(row, next) {
+ t.Fatal("refused start changed session")
+ }
+ }
+}
+
+func TestEmptyCheckCoversBootSchemaAndFutureTables(t *testing.T) {
+ d := testDB(t)
+ cfg, _, now := testConfig(t)
+ if _, err := migrate.Apply(context.Background(), d, sheets.BootSchema); err != nil {
+ t.Fatal(err)
+ }
+ tables, err := userTables(d.G)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"auth_links", "auth_sessions", "cells", "cols", "comments", "events", "formats", "grants", "members", "rows", "sessions", "share_links", "sheets", "tab_views", "tabs"}
+ if !reflect.DeepEqual(tables, want) {
+ t.Fatalf("schema table coverage changed: %v", tables)
+ }
+ for _, table := range []string{"sheets", "members", "grants"} {
+ if err := d.G.Exec("INSERT INTO " + table + " (id) VALUES (1)").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := Initialize(d, cfg, now); err == nil {
+ t.Fatalf("accepted nonempty %s", table)
+ }
+ if err := d.G.Exec("DELETE FROM " + table + " WHERE id = 1").Error; err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := d.G.Exec("CREATE TABLE sqliteXfuture_fixture_guard (id INTEGER)").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := d.G.Exec("INSERT INTO sqliteXfuture_fixture_guard (id) VALUES (1)").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := Initialize(d, cfg, now); err == nil {
+ t.Fatal("future nonempty table ignored")
+ }
+}
+
+func TestNonemptyDatabaseRefusesBeforeMigration(t *testing.T) {
+ d := testDB(t)
+ cfg, _, now := testConfig(t)
+ if err := d.G.Exec("CREATE TABLE existing_content (value TEXT)").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := d.G.Exec("INSERT INTO existing_content (value) VALUES (?)", "preserved-content").Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := Initialize(d, cfg, now); err == nil {
+ t.Fatal("nonempty old database accepted")
+ }
+ var tables int64
+ if err := d.G.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='sessions'").Scan(&tables).Error; err != nil {
+ t.Fatal(err)
+ }
+ if tables != 0 {
+ t.Fatal("migration ran before emptiness guard")
+ }
+ var value string
+ if err := d.G.Raw("SELECT value FROM existing_content").Scan(&value).Error; err != nil {
+ t.Fatal(err)
+ }
+ if value != "preserved-content" {
+ t.Fatal("existing data changed")
+ }
+}
+
+func TestFailedInsertLeavesNoSession(t *testing.T) {
+ d := testDB(t)
+ cfg, _, now := testConfig(t)
+ if err := d.G.Callback().Create().Before("gorm:create").Register("fixture-failure", func(tx *gorm.DB) {
+ if tx.Statement.Table == "sessions" {
+ tx.AddError(refusal("injected"))
+ }
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := Initialize(d, cfg, now); err == nil {
+ t.Fatal("injected failure accepted")
+ }
+ d.G.Callback().Create().Remove("fixture-failure")
+ var count int64
+ if err := d.G.Table("sessions").Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Fatal("failed insert left session")
+ }
+ if err := Initialize(d, cfg, now); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestExpiredOrOverlongFixtureRefuses(t *testing.T) {
+ for _, duration := range []time.Duration{-time.Second, 2*time.Hour + time.Second} {
+ d := testDB(t)
+ cfg, _, now := testConfig(t)
+ cfg.Expires = now.Add(duration)
+ if err := Initialize(d, cfg, now); err == nil {
+ t.Fatal("invalid lifetime accepted")
+ }
+ }
+}
+
+func TestConcurrentIdenticalBootstrapKeepsOneSession(t *testing.T) {
+ d := testDB(t)
+ cfg, _, now := testConfig(t)
+ results := make(chan error, 2)
+ for i := 0; i < 2; i++ {
+ go func() { results <- Initialize(d, cfg, now) }()
+ }
+ for i := 0; i < 2; i++ {
+ select {
+ case err := <-results:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("bootstrap transaction deadlocked")
+ }
+ }
+ var count int64
+ if err := d.G.Table("sessions").Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != 1 {
+ t.Fatal("concurrent bootstrap created more than one session")
+ }
+}
diff --git a/tools/canaryfixture/session_test.go b/tools/canaryfixture/session_test.go
new file mode 100644
index 0000000..4dcc25a
--- /dev/null
+++ b/tools/canaryfixture/session_test.go
@@ -0,0 +1,78 @@
+package canaryfixture
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+func TestFixtureUsesRealSessionValidationAndSignout(t *testing.T) {
+ d := testDB(t)
+ cfg, token, now := testConfig(t)
+ if err := Initialize(d, cfg, now); err != nil {
+ t.Fatal(err)
+ }
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ sess, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: Origin, Logger: logger})
+ if err != nil {
+ t.Fatal(err)
+ }
+ login, err := auth.New(auth.Config{DB: d.Writer(), Origin: Origin, InstanceKey: "synthetic-test-key", Logger: logger})
+ if err != nil {
+ t.Fatal(err)
+ }
+ protected := sess.Middleware(login.RequireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ current, ok := sessions.Current(r)
+ if !ok || current.Subject != Owner {
+ t.Fatal("normal session context missing")
+ }
+ w.WriteHeader(http.StatusNoContent)
+ })))
+ get := func(value string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("GET", Origin+"/sheets", nil)
+ r.AddCookie(&http.Cookie{Name: sess.CookieName(), Value: value})
+ w := httptest.NewRecorder()
+ protected.ServeHTTP(w, r)
+ return w
+ }
+ if w := get(token); w.Code != http.StatusNoContent {
+ t.Fatalf("fixture refused: %d", w.Code)
+ }
+ if w := get("random-cookie"); w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/signin") {
+ t.Fatal("random cookie admitted")
+ }
+ r := httptest.NewRequest("POST", Origin+"/signout", nil)
+ r.Header.Set("Origin", Origin)
+ r.AddCookie(&http.Cookie{Name: sess.CookieName(), Value: token})
+ w := httptest.NewRecorder()
+ login.Signout(w, r)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("signout failed: %d", w.Code)
+ }
+ var count int64
+ if err := d.G.Table("sessions").Where("token_hash = ?", cfg.Hash).Count(&count).Error; err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Fatal("signout retained session")
+ }
+ if w = get(token); w.Code != http.StatusSeeOther || !strings.HasPrefix(w.Header().Get("Location"), "/signin") {
+ t.Fatal("old cookie survived signout")
+ }
+ if err := Initialize(d, cfg, now); err != nil {
+ t.Fatal(err)
+ }
+ if err := d.G.Exec("UPDATE sessions SET expires_at = ?", now.Add(-time.Minute).Format(time.RFC3339)).Error; err != nil {
+ t.Fatal(err)
+ }
+ if w = get(token); w.Code != http.StatusSeeOther {
+ t.Fatal("expired session admitted")
+ }
+}