package console import ( "context" "encoding/json" "errors" "math" "net/http" "net/url" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/metrics" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/usage" ) // putStoreJSON marshals v and writes it straight to the fixture bucket at // key, create-only. Named distinctly from apiconfig_test.go's putJSON (an // HTTP PUT helper against the running server) — same package, so a same- // named bucket-level helper would collide with it. func putStoreJSON(t *testing.T, s release.Store, key string, v any) { t.Helper() raw, err := json.Marshal(v) if err != nil { t.Fatal(err) } if _, err := s.Put(context.Background(), key, raw, ""); err != nil { t.Fatal(err) } } // fixtureAcct/fixtureApp are the account and app buildFixtures (view_test.go) // actually ships releases under: release.OpsAccount ("bab") owns both "kass" // and "otherapp" routes, but only "kass" gets shipped artifacts // (apps/bab/kass/releases/...) — "otherapp" has a registry route and no // bucket prefix at all, so it never appears in a storage sweep. const ( fixtureAcct = release.OpsAccount fixtureApp = "kass" ) func TestEconomicsPassWritesCensusOnce(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC) if err := fx.srv.economicsPass(ctx, now); err != nil { t.Fatal(err) } raw, _, err := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11")) if err != nil { t.Fatal(err) } var day usage.CensusDay if err := json.Unmarshal(raw, &day); err != nil { t.Fatal(err) } // buildFixtures (view_test.go) seeds three routes with no Account set — // release.RouteAccount normalizes all of them to release.OpsAccount — two // distinct apps (kass, otherapp) and three routes total (kass instance, // kass static docs, otherapp instance). want := usage.CensusAccount{Apps: 2, Routes: 3} if got := day.Accounts[release.OpsAccount]; got != want { t.Fatalf("day.Accounts[%q] = %+v, want %+v", release.OpsAccount, got, want) } // Idempotence: a second run the same day must not error or rewrite. before := string(raw) if err := fx.srv.economicsPass(ctx, now.Add(time.Hour)); err != nil { t.Fatal(err) } after, _, _ := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11")) if string(after) != before { t.Fatal("second pass rewrote the census") } } // TestCensusPassRefusesMissingRegistry: a console booted with an unmounted // or misconfigured registry volume must refuse the census rather than read // registry.Open's freshly-created, empty sqlite DB as "zero routes" and // write a permanent, uncorrectable all-zero day (create-only). It must also // leave no stray file behind at the path it didn't open. func TestCensusPassRefusesMissingRegistry(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() missing := filepath.Join(t.TempDir(), "nonexistent-registry.db") fx.srv.cfg.Registry = missing if err := fx.srv.censusPass(ctx, "2026-08-11"); err == nil { t.Fatal("censusPass with a missing registry succeeded, want an error") } if _, err := os.Stat(missing); err == nil { t.Fatal("censusPass created a file at the missing registry path") } if _, _, err := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11")); err == nil { t.Fatal("censusPass with a missing registry wrote a census object") } } func TestStoreAccountsListsAll(t *testing.T) { fx := newConsoleFixture(t) accts, err := fx.st.Accounts() if err != nil { t.Fatal(err) } if len(accts) == 0 { t.Fatal("Accounts() returned nothing — the fixture's ops account should appear") } var ops *Account for i := range accts { a := accts[i] if a.ID == 0 || a.Sqid == "" || a.Name == "" { t.Fatalf("incomplete account row: %+v", a) } if a.Sqid == release.OpsAccount { ops = &accts[i] } } if ops == nil { t.Fatal("Accounts() is missing the fixture's built-in ops account") } if ops.Name != "ops" { t.Fatalf("ops account name = %q, want %q", ops.Name, "ops") } // A soft-deleted account, tombstoned the way the store's own member-facing // delete path does it (DeleteAccount sets deleted_at), must disappear // from Accounts() — pinning the deviation from the brief's literal query. id := testAccountID() sqid := "test-deleted-1" now := time.Now() if err := fx.st.CreateAccount(id, sqid, "Deadco", "paul@keymail.dev", now); err != nil { t.Fatal(err) } if err := fx.st.DeleteAccount(id, now); err != nil { t.Fatal(err) } accts, err = fx.st.Accounts() if err != nil { t.Fatal(err) } for _, a := range accts { if a.Sqid == sqid { t.Fatalf("Accounts() returned a soft-deleted account: %+v", a) } } } func TestStoragePassSizesAppsAndDegradesStores(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() // The fixture bucket already has releases under apps/// from // buildFixtures; add a known-size object under one of those app prefixes // and read its acct/app from the fixture rather than hardcoding. if _, err := fx.rel.Put(ctx, "apps/"+fixtureAcct+"/"+fixtureApp+"/releases/x.tgz", make([]byte, 2048), ""); err != nil { t.Fatal(err) } fx.srv.storeSizer = func(ctx context.Context, p *objstore.Provisioned) (int64, error) { return 0, errors.New("endpoint down") } if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil { t.Fatal(err) } raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11")) if err != nil { t.Fatal(err) } var day usage.StorageDay if err := json.Unmarshal(raw, &day); err != nil { t.Fatal(err) } app := day.Apps[fixtureAcct+"/"+fixtureApp] if app.BucketBytes < 2048 { t.Fatalf("bucket bytes = %d, want >= 2048", app.BucketBytes) } // buildFixtures provisions no object store for this app — the injected // storeSizer (which always errors) must never even be consulted, so the // app must not come back marked unswept. if app.StoreUnswept { t.Fatal("app with no provisioned store came back StoreUnswept") } if app.StoreProvisioned { t.Fatal("app with no provisioned store came back StoreProvisioned") } // Idempotence: a second run the same day must not error or rewrite. before := string(raw) if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil { t.Fatal(err) } after, _, _ := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11")) if string(after) != before { t.Fatal("second storage pass rewrote the day") } } func TestStoragePassMarksEmptyStoreProvisioned(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() rec := &objstore.Record{ Provisioned: &objstore.Provisioned{ Bucket: "test-empty-tenant-bucket", Region: "us-east-1", }, } if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, rec, ""); err != nil { t.Fatal(err) } fx.srv.storeSizer = func(context.Context, *objstore.Provisioned) (int64, error) { return 0, nil } if err := fx.srv.storagePass(ctx, "2026-08-12"); err != nil { t.Fatal(err) } raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-12")) if err != nil { t.Fatal(err) } var day usage.StorageDay if err := json.Unmarshal(raw, &day); err != nil { t.Fatal(err) } app := day.Apps[fixtureAcct+"/"+fixtureApp] if !app.StoreProvisioned || app.StoreBytes != 0 || app.StoreUnswept { t.Fatalf("empty provisioned store = %+v", app) } } // TestStoragePassMarksUnreachableStoreUnswept: buildFixtures seeds no // provisioned object store, so this test writes a minimal objstore.Record // with a Provisioned block directly and confirms a sizer failure marks the // day's entry StoreUnswept rather than lying with a zero byte count. func TestStoragePassMarksUnreachableStoreUnswept(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() rec := &objstore.Record{ Provisioned: &objstore.Provisioned{ Bucket: "test-tenant-bucket", Region: "us-east-1", }, } if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, rec, ""); err != nil { t.Fatal(err) } fx.srv.storeSizer = func(ctx context.Context, p *objstore.Provisioned) (int64, error) { return 0, errors.New("endpoint down") } if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil { t.Fatal(err) } raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11")) if err != nil { t.Fatal(err) } var day usage.StorageDay if err := json.Unmarshal(raw, &day); err != nil { t.Fatal(err) } app := day.Apps[fixtureAcct+"/"+fixtureApp] if !app.StoreUnswept { t.Fatalf("app with unreachable store = %+v, want StoreUnswept", app) } if !app.StoreProvisioned { t.Fatalf("app with unreachable provisioned store = %+v, want StoreProvisioned", app) } if app.StoreBytes != 0 { t.Fatalf("app.StoreBytes = %d, want 0 when unswept", app.StoreBytes) } } // TestStoragePassDegradesOnStoreRecordReadError: objstore.Get's "absent" // return is (nil, "", nil) — a non-nil error means a REAL failure (bucket // read, malformed store.json), not "no store". A pass that treated the two // alike would write StoreBytes:0/StoreUnswept:false into a create-only // object, forever indistinguishable from an app that never had a store. func TestStoragePassDegradesOnStoreRecordReadError(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() // Corrupt the record directly so objstore.Get's json.Unmarshal fails — // the fixture bucket makes this easier than injecting a wrapped store // whose Get errors, and it exercises the exact malformed-store.json case // the finding names. if _, err := fx.rel.Put(ctx, objstore.Key(fixtureAcct, fixtureApp), []byte("{not valid json"), ""); err != nil { t.Fatal(err) } if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil { t.Fatal(err) } raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11")) if err != nil { t.Fatal(err) } var day usage.StorageDay if err := json.Unmarshal(raw, &day); err != nil { t.Fatal(err) } app := day.Apps[fixtureAcct+"/"+fixtureApp] if !app.StoreUnswept { t.Fatalf("app with an unreadable store record = %+v, want StoreUnswept", app) } if app.StoreBytes != 0 { t.Fatalf("app.StoreBytes = %d, want 0 when unswept", app.StoreBytes) } } // TestStatementsPassGolden pins the canonical meter derivations end to end // off bucket objects alone — no live boxes needed. cpu avg 0.5 for 60 // samples (60s apart) in one hour is 0.5 core-hours; mem avg 1e9 bytes over // the same 60 samples is 1 GB-hour and, from the same sample count, // 1 instance-hour. The route is unit-backed (Host=a.example.com, no // Route.Unit), so unitOwner must match it via activator.UnitName, exactly // the forward-construction OwnerAppMetrics uses. func TestStatementsPassGolden(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC) reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", Status: "ready", App: fixtureApp, Version: "v1"}); err != nil { t.Fatal(err) } // One box, one day of metrics: unit carlos-app@a.example.com with // cpu avg 0.5 for 60 samples in hour 09 (= 0.5 core-hours), mem avg // 1e9 bytes (= 1 GB-hour, 1 instance-hour). cpu, mem := 0.5, 1e9 roll := metrics.DayRollup{V: 1, Box: "box1", Date: "2026-08-10", Hours: map[string]metrics.HourAgg{ "09": {Units: map[string]map[string]metrics.Agg{ "carlos-app@a.example.com.service": { "cpu": {Avg: cpu, N: 60}, "mem_bytes": {Avg: mem, N: 60}, }, }}, }} putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-10"), roll) // Edge day: 2 GB out, 4000 requests for the same route's account. putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-08-10"), usage.EdgeDay{ V: 1, Box: "box1", Date: "2026-08-10", Hours: map[string]map[string]usage.EdgeRow{"09": { "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9, HopRequests: 1000, HopBytesOut: 5e8}, }}, }) if err := fx.srv.statementsPass(ctx, now); err != nil { t.Fatal(err) } raw, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08")) if err != nil { t.Fatal(err) } var st usage.Statement if err := json.Unmarshal(raw, &st); err != nil { t.Fatal(err) } approxF := func(name string, got, want float64) { t.Helper() if math.Abs(got-want) > 1e-9 { t.Fatalf("%s = %v, want %v", name, got, want) } } approxF("cpu", st.Meters[usage.MeterCPUCoreHours], 0.5) approxF("mem", st.Meters[usage.MeterMemGBHours], 1.0) approxF("instance_hours", st.Meters[usage.MeterInstanceHours], 1.0) approxF("egress", st.Meters[usage.MeterEgressGB], 2.0) approxF("requests", st.Meters[usage.MeterRequests1k], 4.0) // The hop subset rides along as its own meters (#288) — inside egress // (the 2.0 GB above already includes the 0.5 GB), never on top of it. approxF("backhaul", st.Meters[usage.MeterBackhaulGB], 0.5) approxF("backhaul requests", st.Meters[usage.MeterBackhaulRequests1k], 1.0) if st.DaysInMonth != 31 || st.CoveredDays < 1 { t.Fatalf("coverage = %d/%d", st.CoveredDays, st.DaysInMonth) } } // TestStatementsPassZeroUsageAccountListed: an account with a live route but // no metrics/edge/storage data this month still gets a statement — the // accounts page must be able to list every account, not just ones with // usage — and unitOwner falls a completely unmatched unit through to // release.OpsAccount rather than a bare "ops" literal. func TestStatementsPassZeroUsageAccountListed(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC) if err := fx.srv.statementsPass(ctx, now); err != nil { t.Fatal(err) } raw, _, err := fx.rel.Get(ctx, usage.StatementKey(release.OpsAccount, "2026-08")) if err != nil { t.Fatal(err) } var st usage.Statement if err := json.Unmarshal(raw, &st); err != nil { t.Fatal(err) } if st.Account != release.OpsAccount { t.Fatalf("st.Account = %q, want %q", st.Account, release.OpsAccount) } for meter, v := range st.Meters { if v != 0 { t.Fatalf("zero-usage statement has non-zero %s = %v", meter, v) } } } // TestUnitOwnerUnmatchedFallsToOpsAccount pins the correction over the // brief's literal text: the fallback is release.OpsAccount ("bab"), never // the bare string "ops". func TestUnitOwnerUnmatchedFallsToOpsAccount(t *testing.T) { acct, app, ok := unitOwner(nil, "console.service") if ok { t.Fatalf("unitOwner matched an empty route list: acct=%q app=%q", acct, app) } if acct != release.OpsAccount { t.Fatalf("unmatched unit acct = %q, want release.OpsAccount (%q)", acct, release.OpsAccount) } if app != "" { t.Fatalf("unmatched unit app = %q, want empty", app) } } // TestUnitOwnerMatchesStampedUnitColumn: a route with a non-empty Unit // column matches that exact unit name, independent of Host. func TestUnitOwnerMatchesStampedUnitColumn(t *testing.T) { routes := []registry.Route{ {Host: "bespoke.example.com", App: "bespoke", Unit: "carlos-bespoke.service"}, } acct, app, ok := unitOwner(routes, "carlos-bespoke.service") if !ok || app != "bespoke" || acct != release.OpsAccount { t.Fatalf("unitOwner(stamped unit) = %q %q %v", acct, app, ok) } } // TestStatementsPassRejectsInvalidStorageAccountSegment: a legacy // unqualified app prefix (apps//releases/, no account segment) makes // storagePass mint a StorageDay key whose segment before "/" is an APP // name, not an account sqid. release.ValidAccount is letters-only 3-16 // chars, so a plain lowercase app name like "kass" happens to pass it too // — this fixture uses "my-app" (a hyphen, legal for release.ValidApp, // illegal for release.ValidAccount) as the bogus account segment, the same // shape storagePass would actually produce for an app whose name isn't // letters-only. statementsPass must not mint a statement under that bogus // key; the bytes must land on release.OpsAccount instead. func TestStatementsPassRejectsInvalidStorageAccountSegment(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC) if release.ValidAccount("my-app") { t.Fatal("test fixture assumption broken: \"my-app\" passes release.ValidAccount") } putStoreJSON(t, fx.rel, usage.StorageDayKey("2026-08-10"), usage.StorageDay{ V: 1, Date: "2026-08-10", Apps: map[string]usage.AppStorage{ "my-app/releases": {BucketBytes: 31 * 1e9}, // 1 GB-month over 31 days }, }) if err := fx.srv.statementsPass(ctx, now); err != nil { t.Fatal(err) } if _, _, err := fx.rel.Get(ctx, usage.StatementKey("my-app", "2026-08")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("statement minted under the bogus account key \"my-app\": err=%v", err) } raw, _, err := fx.rel.Get(ctx, usage.StatementKey(release.OpsAccount, "2026-08")) if err != nil { t.Fatal(err) } var st usage.Statement if err := json.Unmarshal(raw, &st); err != nil { t.Fatal(err) } if got, want := st.Meters[usage.MeterStorageGBMonth], 1.0; math.Abs(got-want) > 1e-9 { t.Fatalf("ops storage_gb_month = %v, want %v (invalid segment's bytes must land here)", got, want) } } // TestRollupPresenceHours pins rollupPresenceHours's exact rule (task-3 // brief, clamp added in review fix round 1): per present hour (a key in // Hours), Host["cpu"].N minutes/60 when present; else the max N across any // other Host agg; else — Host itself carrying no aggs at all — a flat // 60-minute (1.0h) fallback; each hour's contribution then clamps to at // most 1.0. Three hours: Host["cpu"].N=60 (-> 1.0h), an empty Host map // (-> 1.0h fallback), and Host["cpu"].N=90 — more than 60 samples in one // hour, e.g. a mid-hour agent restart — which must still clamp to 1.0h, not // 1.5h; total 3.0. func TestRollupPresenceHours(t *testing.T) { d := metrics.DayRollup{V: 1, Box: "bx", Date: "2026-08-10", Hours: map[string]metrics.HourAgg{ "09": {Host: map[string]metrics.Agg{"cpu": {N: 60}}}, "10": {Host: map[string]metrics.Agg{}}, "11": {Host: map[string]metrics.Agg{"cpu": {N: 90}}}, }} if got, want := rollupPresenceHours(d), 3.0; math.Abs(got-want) > 1e-9 { t.Fatalf("rollupPresenceHours = %v, want %v", got, want) } } // TestStatementsPassFleetMeters: a fleet box's day rollups price into the // owning account's fleet_box_hours_server/device meters, split by the // literal "device" tag — bx-server (untagged) contributes to the server // meter, bx-pi (tags "pi,device") to the device meter. Rollups are written // under fleets// — the SAME prefix a door-backed fleet box's write // ACL admits (internal/edge/storedoor.go aclWrite) and resolveStatusPrefix // (internal/host/agent.go) defaults a fleet box's publishing to — proving // fleetMeters reads the real path, not the platform root. func TestStatementsPassFleetMeters(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC) reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } if _, err := reg.CreateFleet(registry.Fleet{Name: "fx", Account: "bae", Bucket: "fx-data"}); err != nil { t.Fatal(err) } tokenHash := strings.Repeat("a", 64) if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-server", TokenHash: tokenHash}); err != nil { t.Fatal(err) } if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-pi", TokenHash: tokenHash}); err != nil { t.Fatal(err) } // AddFleetBox's INSERT does not carry tags (schema default '') — a box's // tags are set through the dedicated SetFleetBoxTags writer. if err := reg.SetFleetBoxTags("fx", "bx-pi", "pi,device"); err != nil { t.Fatal(err) } fleetPrefix := "fleets/fx/" serverHours := map[string]metrics.HourAgg{} for h := 0; h < 24; h++ { serverHours[hourKey(h)] = metrics.HourAgg{Host: map[string]metrics.Agg{"cpu": {N: 60}}} } putStoreJSON(t, fx.rel, fleetPrefix+metrics.DayKey("bx-server", "2026-08-10"), metrics.DayRollup{ V: 1, Box: "bx-server", Date: "2026-08-10", Hours: serverHours, }) piHours := map[string]metrics.HourAgg{} for h := 0; h < 12; h++ { piHours[hourKey(h)] = metrics.HourAgg{Host: map[string]metrics.Agg{"cpu": {N: 60}}} } putStoreJSON(t, fx.rel, fleetPrefix+metrics.DayKey("bx-pi", "2026-08-10"), metrics.DayRollup{ V: 1, Box: "bx-pi", Date: "2026-08-10", Hours: piHours, }) if err := fx.srv.statementsPass(ctx, now); err != nil { t.Fatal(err) } raw, _, err := fx.rel.Get(ctx, usage.StatementKey("bae", "2026-08")) if err != nil { t.Fatal(err) } var st usage.Statement if err := json.Unmarshal(raw, &st); err != nil { t.Fatal(err) } if got, want := st.Meters[usage.MeterFleetBoxHoursServer], 24.0; math.Abs(got-want) > 1e-9 { t.Fatalf("fleet_box_hours_server = %v, want %v", got, want) } if got, want := st.Meters[usage.MeterFleetBoxHoursDevice], 12.0; math.Abs(got-want) > 1e-9 { t.Fatalf("fleet_box_hours_device = %v, want %v", got, want) } } // hourKey formats an hour 0-23 as the DayRollup.Hours key shape ("00".."23"). func hourKey(h int) string { return time.Date(2000, 1, 1, h, 0, 0, 0, time.UTC).Format("15") } // TestAccountTierStore: SetAccountTier writes a validated tier that // AccountTiers then reports keyed by sqid; an invalid tier is rejected with // no write; an unknown account id is a silent no-op, matching this store's // UPDATE...WHERE convention elsewhere (e.g. ReapAccount's deleted_at guard) // rather than a distinguishable error a caller would need to branch on. func TestAccountTierStore(t *testing.T) { s := openTestStore(t) now := time.Now() id := testAccountID() sqid := "test-tier-1" if err := s.CreateAccount(id, sqid, "Tierco", "paul@keymail.dev", now); err != nil { t.Fatal(err) } // Freshly created account defaults to the empty tier. tiers, err := s.AccountTiers() if err != nil { t.Fatal(err) } if got, want := tiers[sqid], ""; got != want { t.Fatalf("fresh account tier = %q, want %q", got, want) } if err := s.SetAccountTier(id, "pool"); err != nil { t.Fatal(err) } tiers, err = s.AccountTiers() if err != nil { t.Fatal(err) } if got, want := tiers[sqid], "pool"; got != want { t.Fatalf("account tier after SetAccountTier = %q, want %q", got, want) } // Invalid tier: rejected, no write. if err := s.SetAccountTier(id, "bogus"); err == nil { t.Fatal("SetAccountTier with an invalid tier returned nil error, want a rejection") } tiers, err = s.AccountTiers() if err != nil { t.Fatal(err) } if got, want := tiers[sqid], "pool"; got != want { t.Fatalf("account tier after a rejected invalid write = %q, want unchanged %q", got, want) } // Unknown account id: no-op, no error. if err := s.SetAccountTier(id+999999, "fleet"); err != nil { t.Fatalf("SetAccountTier on an unknown id returned an error, want a silent no-op: %v", err) } // A deleted account is excluded from AccountTiers, same filter as // Accounts(). if err := s.DeleteAccount(id, now); err != nil { t.Fatal(err) } tiers, err = s.AccountTiers() if err != nil { t.Fatal(err) } if _, ok := tiers[sqid]; ok { t.Fatalf("AccountTiers still reports a deleted account: %+v", tiers) } } // TestEconomicsTierDoor: POST /fleet/economics/tier is registered without a // method prefix (server.go), same reasoning as // TestEconomicsWriteDoorsRejectNonPOST — a wrong-method request must be // byte-identical to an unknown route, both signed out and signed in as a // non-operator, before the operator gate, before CSRF. An operator POST // resolves the account by sqid (via AccountBySqid — already // viewer-independent, since it filters deleted_at rather than membership), // sets its tier and redirects to /fleet/economics/accounts. func TestEconomicsTierDoor(t *testing.T) { fx := newConsoleFixture(t) out := newBrowser(t) ghostStatus, _, ghostBody := get(t, out, fx.ts.URL+"/this-route-does-not-exist") const path = "/fleet/economics/tier" // Signed out: GET is the ghost 404. if status, _, body := get(t, out, fx.ts.URL+path); status != ghostStatus || body != ghostBody { t.Fatalf("signed-out GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) } // Signed out: POST is the same ghost 404 too (method check runs before // the session gate, but a signed-out POST must still be indistinguishable // from an unknown route — no session cookie means no CSRF token to send // at all). signedOutForm := url.Values{"csrf": {""}, "account": {fx.sqid}, "tier": {"pool"}} if status, _, body := postForm(t, out, fx.ts.URL+path, signedOutForm); status != ghostStatus || body != ghostBody { t.Fatalf("signed-out POST %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) } c := signInPaul(t, fx) csrf := sessionToken(t, fx, c) // Signed in, not an operator: GET is the ghost 404 (method-first). if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody { t.Fatalf("non-operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) } // Signed in, not an operator: POST is the same ghost 404 (gate before // CSRF). opForm := url.Values{"csrf": {csrf}, "account": {fx.sqid}, "tier": {"pool"}} if status, _, body := postForm(t, c, fx.ts.URL+path, opForm); status != ghostStatus || body != ghostBody { t.Fatalf("non-operator POST %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) } if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } // Operator GET: still the ghost 404 (method check runs first). if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody { t.Fatalf("operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) } // Operator POST, wrong CSRF: 403, same shape TestScenarioSaveGateAndValidation // pins for the sibling doors — the gate runs AFTER the operator check, // so this is a real 403, not the ghost 404. badCSRFForm := url.Values{"csrf": {"not-the-real-token"}, "account": {fx.sqid}, "tier": {"pool"}} if status, _, _ := postForm(t, c, fx.ts.URL+path, badCSRFForm); status != http.StatusForbidden { t.Fatalf("operator POST with wrong CSRF = %d, want %d", status, http.StatusForbidden) } tiers, err := fx.st.AccountTiers() if err != nil { t.Fatal(err) } if got := tiers[fx.sqid]; got != "" { t.Fatalf("account tier after a wrong-CSRF POST = %q, want unchanged %q", got, "") } // Operator POST, happy path: sets the tier and redirects. status, loc, _ := postForm(t, c, fx.ts.URL+path, opForm) if status != http.StatusSeeOther || loc != "/fleet/economics/accounts" { t.Fatalf("operator tier POST = %d -> %q, want 303 -> /fleet/economics/accounts", status, loc) } tiers, err = fx.st.AccountTiers() if err != nil { t.Fatal(err) } if got, want := tiers[fx.sqid], "pool"; got != want { t.Fatalf("account tier after operator POST = %q, want %q", got, want) } }