package console import ( "database/sql" "path/filepath" "testing" "time" _ "modernc.org/sqlite" ) func nowForTest() time.Time { return time.Unix(1700000100, 0) } // The apps table is REBUILT on upgrade (SQLite cannot ALTER a primary key, // and "unique within an account" is not something PRIMARY KEY(app) can // express). A table rebuild is where data quietly dies, so this seeds the // pre-accounts shape by hand, re-opens through the real migration, and // asserts every row survived with every column intact. func TestAppsRebuildPreservesEveryRow(t *testing.T) { path := filepath.Join(t.TempDir(), "console.db") // The exact pre-multi-account shape: PRIMARY KEY(app), no account_id. raw, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } if _, err := raw.Exec(`CREATE TABLE apps ( app TEXT PRIMARY KEY, created_by TEXT NOT NULL, created_at INTEGER NOT NULL );`); err != nil { t.Fatal(err) } seed := map[string][2]any{ "kass": {"paul@keymail.dev", int64(1700000000)}, "keymail": {"paul@keymail.dev", int64(1700000001)}, "woodstar": {"sam@keymail.dev", int64(1700000002)}, } for app, row := range seed { if _, err := raw.Exec(`INSERT INTO apps (app, created_by, created_at) VALUES (?, ?, ?)`, app, row[0], row[1]); err != nil { t.Fatal(err) } } if err := raw.Close(); err != nil { t.Fatal(err) } // Two opens: the rebuild must also be idempotent, so a console that // restarts twice does not rebuild twice (or lose anything on the second). for pass := 1; pass <= 2; pass++ { st, err := Open(path) if err != nil { t.Fatalf("pass %d: open: %v", pass, err) } rows, err := st.db.Query(`SELECT app, account_id, created_by, created_at FROM apps ORDER BY app`) if err != nil { t.Fatalf("pass %d: %v", pass, err) } got := map[string][3]any{} for rows.Next() { var app, by string var acct, at int64 if err := rows.Scan(&app, &acct, &by, &at); err != nil { t.Fatal(err) } got[app] = [3]any{acct, by, at} } rows.Close() st.Close() if len(got) != len(seed) { t.Fatalf("pass %d: %d rows survived, want %d: %+v", pass, len(got), len(seed), got) } for app, want := range seed { g, ok := got[app] if !ok { t.Errorf("pass %d: row %q vanished in the rebuild", pass, app) continue } // account_id defaults to 1 (ops): before accounts existed, every // app was in ops by definition. if g[0] != int64(OpsAccountID) { t.Errorf("pass %d: %s account_id = %v, want %d", pass, app, g[0], OpsAccountID) } if g[1] != want[0] { t.Errorf("pass %d: %s created_by = %v, want %v", pass, app, g[1], want[0]) } if g[2] != want[1] { t.Errorf("pass %d: %s created_at = %v, want %v", pass, app, g[2], want[1]) } } } // And the point of the rebuild: the same name in two accounts. st, err := Open(path) if err != nil { t.Fatal(err) } defer st.Close() if err := st.CreateAccount(42, "cdef", "Other", "sam@keymail.dev", nowForTest()); err != nil { t.Fatal(err) } if created, err := st.CreateAppIn(42, "kass", "sam@keymail.dev", nowForTest()); err != nil || !created { t.Fatalf("same name in another account = %v %v, want it to be free", created, err) } } // TestAppFlagsSurviveTheAppsKeyRebuild: control_plane and build_probe are // added by the ALTER block, which runs BEFORE migrateAppsKey's table rebuild // (store.go:189 vs :231). The rebuild copies an ENUMERATED column list, so a // column missing from apps_new is silently dropped on any database that has // not been rebuilt yet — a self-hoster jumping versions. deleted_at and // placement are in both places for this reason (store.go:280); these must be // too, and this test is what proves it rather than a comment. func TestAppFlagsSurviveTheAppsKeyRebuild(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "old.db") // A pre-rebuild database: PRIMARY KEY(app), the shape migrateAppsKey exists // to replace. Built by hand because no released binary produces it any more. raw, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } if _, err := raw.Exec(`CREATE TABLE apps ( app TEXT PRIMARY KEY, account_id INTEGER NOT NULL DEFAULT 1, created_by TEXT NOT NULL, created_at INTEGER NOT NULL, deleted_at INTEGER NOT NULL DEFAULT 0, placement TEXT NOT NULL DEFAULT '' );`); err != nil { t.Fatal(err) } if _, err := raw.Exec(`INSERT INTO apps (app, account_id, created_by, created_at) VALUES ('console', 1, 'paul@keymail.dev', 1)`); err != nil { t.Fatal(err) } if err := raw.Close(); err != nil { t.Fatal(err) } st, err := Open(path) if err != nil { t.Fatalf("Open (which runs migrate) on a pre-rebuild database: %v", err) } defer st.Close() if err := st.SetAppControlPlane(OpsAccountID, "console", true); err != nil { t.Fatalf("SetAppControlPlane: %v — the column did not survive the rebuild", err) } if err := st.SetAppBuildProbe(OpsAccountID, "console", "/healthz"); err != nil { t.Fatalf("SetAppBuildProbe: %v", err) } cp, probe, err := st.AppFlags(OpsAccountID, "console") if err != nil { t.Fatalf("AppFlags: %v", err) } if !cp || probe != "/healthz" { t.Fatalf("AppFlags = %v %q, want true \"/healthz\"", cp, probe) } } // TestAppFlagsDefaultOff: every row that existed before this change means // "not the control plane, no probe" — the additive-default idiom the kind and // service-principal columns used. func TestAppFlagsDefaultOff(t *testing.T) { fx := newConsoleFixture(t) if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil { t.Fatal(err) } cp, probe, err := fx.st.AppFlags(OpsAccountID, "titogo") if err != nil { t.Fatal(err) } if cp || probe != "" { t.Fatalf("a freshly claimed app = %v %q, want false \"\"", cp, probe) } }