carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "math"
8 "net/http"
9 "net/url"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/carlosframework/platform/internal/metrics"
17 "github.com/carlosframework/platform/internal/objstore"
18 "github.com/carlosframework/platform/internal/registry"
19 "github.com/carlosframework/platform/internal/release"
20 "github.com/carlosframework/platform/internal/usage"
21)
22
23// putStoreJSON marshals v and writes it straight to the fixture bucket at
24// key, create-only. Named distinctly from apiconfig_test.go's putJSON (an
25// HTTP PUT helper against the running server) — same package, so a same-
26// named bucket-level helper would collide with it.
27func putStoreJSON(t *testing.T, s release.Store, key string, v any) {
28 t.Helper()
29 raw, err := json.Marshal(v)
30 if err != nil {
31 t.Fatal(err)
32 }
33 if _, err := s.Put(context.Background(), key, raw, ""); err != nil {
34 t.Fatal(err)
35 }
36}
37
38// fixtureAcct/fixtureApp are the account and app buildFixtures (view_test.go)
39// actually ships releases under: release.OpsAccount ("bab") owns both "kass"
40// and "otherapp" routes, but only "kass" gets shipped artifacts
41// (apps/bab/kass/releases/...) — "otherapp" has a registry route and no
42// bucket prefix at all, so it never appears in a storage sweep.
43const (
44 fixtureAcct = release.OpsAccount
45 fixtureApp = "kass"
46)
47
48func TestEconomicsPassWritesCensusOnce(t *testing.T) {
49 fx := newConsoleFixture(t)
50 ctx := context.Background()
51 now := time.Date(2026, 8, 11, 3, 0, 0, 0, time.UTC)
52
53 if err := fx.srv.economicsPass(ctx, now); err != nil {
54 t.Fatal(err)
55 }
56 raw, _, err := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11"))
57 if err != nil {
58 t.Fatal(err)
59 }
60 var day usage.CensusDay
61 if err := json.Unmarshal(raw, &day); err != nil {
62 t.Fatal(err)
63 }
64 // buildFixtures (view_test.go) seeds three routes with no Account set —
65 // release.RouteAccount normalizes all of them to release.OpsAccount — two
66 // distinct apps (kass, otherapp) and three routes total (kass instance,
67 // kass static docs, otherapp instance).
68 want := usage.CensusAccount{Apps: 2, Routes: 3}
69 if got := day.Accounts[release.OpsAccount]; got != want {
70 t.Fatalf("day.Accounts[%q] = %+v, want %+v", release.OpsAccount, got, want)
71 }
72 // Idempotence: a second run the same day must not error or rewrite.
73 before := string(raw)
74 if err := fx.srv.economicsPass(ctx, now.Add(time.Hour)); err != nil {
75 t.Fatal(err)
76 }
77 after, _, _ := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11"))
78 if string(after) != before {
79 t.Fatal("second pass rewrote the census")
80 }
81}
82
83// TestCensusPassRefusesMissingRegistry: a console booted with an unmounted
84// or misconfigured registry volume must refuse the census rather than read
85// registry.Open's freshly-created, empty sqlite DB as "zero routes" and
86// write a permanent, uncorrectable all-zero day (create-only). It must also
87// leave no stray file behind at the path it didn't open.
88func TestCensusPassRefusesMissingRegistry(t *testing.T) {
89 fx := newConsoleFixture(t)
90 ctx := context.Background()
91 missing := filepath.Join(t.TempDir(), "nonexistent-registry.db")
92 fx.srv.cfg.Registry = missing
93
94 if err := fx.srv.censusPass(ctx, "2026-08-11"); err == nil {
95 t.Fatal("censusPass with a missing registry succeeded, want an error")
96 }
97 if _, err := os.Stat(missing); err == nil {
98 t.Fatal("censusPass created a file at the missing registry path")
99 }
100 if _, _, err := fx.rel.Get(ctx, usage.CensusDayKey("2026-08-11")); err == nil {
101 t.Fatal("censusPass with a missing registry wrote a census object")
102 }
103}
104
105func TestStoreAccountsListsAll(t *testing.T) {
106 fx := newConsoleFixture(t)
107 accts, err := fx.st.Accounts()
108 if err != nil {
109 t.Fatal(err)
110 }
111 if len(accts) == 0 {
112 t.Fatal("Accounts() returned nothing — the fixture's ops account should appear")
113 }
114 var ops *Account
115 for i := range accts {
116 a := accts[i]
117 if a.ID == 0 || a.Sqid == "" || a.Name == "" {
118 t.Fatalf("incomplete account row: %+v", a)
119 }
120 if a.Sqid == release.OpsAccount {
121 ops = &accts[i]
122 }
123 }
124 if ops == nil {
125 t.Fatal("Accounts() is missing the fixture's built-in ops account")
126 }
127 if ops.Name != "ops" {
128 t.Fatalf("ops account name = %q, want %q", ops.Name, "ops")
129 }
130
131 // A soft-deleted account, tombstoned the way the store's own member-facing
132 // delete path does it (DeleteAccount sets deleted_at), must disappear
133 // from Accounts() — pinning the deviation from the brief's literal query.
134 id := testAccountID()
135 sqid := "test-deleted-1"
136 now := time.Now()
137 if err := fx.st.CreateAccount(id, sqid, "Deadco", "paul@keymail.dev", now); err != nil {
138 t.Fatal(err)
139 }
140 if err := fx.st.DeleteAccount(id, now); err != nil {
141 t.Fatal(err)
142 }
143 accts, err = fx.st.Accounts()
144 if err != nil {
145 t.Fatal(err)
146 }
147 for _, a := range accts {
148 if a.Sqid == sqid {
149 t.Fatalf("Accounts() returned a soft-deleted account: %+v", a)
150 }
151 }
152}
153
154func TestStoragePassSizesAppsAndDegradesStores(t *testing.T) {
155 fx := newConsoleFixture(t)
156 ctx := context.Background()
157
158 // The fixture bucket already has releases under apps/<acct>/<app>/ from
159 // buildFixtures; add a known-size object under one of those app prefixes
160 // and read its acct/app from the fixture rather than hardcoding.
161 if _, err := fx.rel.Put(ctx, "apps/"+fixtureAcct+"/"+fixtureApp+"/releases/x.tgz", make([]byte, 2048), ""); err != nil {
162 t.Fatal(err)
163 }
164
165 fx.srv.storeSizer = func(ctx context.Context, p *objstore.Provisioned) (int64, error) {
166 return 0, errors.New("endpoint down")
167 }
168
169 if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil {
170 t.Fatal(err)
171 }
172 raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11"))
173 if err != nil {
174 t.Fatal(err)
175 }
176 var day usage.StorageDay
177 if err := json.Unmarshal(raw, &day); err != nil {
178 t.Fatal(err)
179 }
180 app := day.Apps[fixtureAcct+"/"+fixtureApp]
181 if app.BucketBytes < 2048 {
182 t.Fatalf("bucket bytes = %d, want >= 2048", app.BucketBytes)
183 }
184 // buildFixtures provisions no object store for this app — the injected
185 // storeSizer (which always errors) must never even be consulted, so the
186 // app must not come back marked unswept.
187 if app.StoreUnswept {
188 t.Fatal("app with no provisioned store came back StoreUnswept")
189 }
190 if app.StoreProvisioned {
191 t.Fatal("app with no provisioned store came back StoreProvisioned")
192 }
193
194 // Idempotence: a second run the same day must not error or rewrite.
195 before := string(raw)
196 if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil {
197 t.Fatal(err)
198 }
199 after, _, _ := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11"))
200 if string(after) != before {
201 t.Fatal("second storage pass rewrote the day")
202 }
203}
204
205func TestStoragePassMarksEmptyStoreProvisioned(t *testing.T) {
206 fx := newConsoleFixture(t)
207 ctx := context.Background()
208
209 rec := &objstore.Record{
210 Provisioned: &objstore.Provisioned{
211 Bucket: "test-empty-tenant-bucket",
212 Region: "us-east-1",
213 },
214 }
215 if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, rec, ""); err != nil {
216 t.Fatal(err)
217 }
218 fx.srv.storeSizer = func(context.Context, *objstore.Provisioned) (int64, error) {
219 return 0, nil
220 }
221
222 if err := fx.srv.storagePass(ctx, "2026-08-12"); err != nil {
223 t.Fatal(err)
224 }
225 raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-12"))
226 if err != nil {
227 t.Fatal(err)
228 }
229 var day usage.StorageDay
230 if err := json.Unmarshal(raw, &day); err != nil {
231 t.Fatal(err)
232 }
233 app := day.Apps[fixtureAcct+"/"+fixtureApp]
234 if !app.StoreProvisioned || app.StoreBytes != 0 || app.StoreUnswept {
235 t.Fatalf("empty provisioned store = %+v", app)
236 }
237}
238
239// TestStoragePassMarksUnreachableStoreUnswept: buildFixtures seeds no
240// provisioned object store, so this test writes a minimal objstore.Record
241// with a Provisioned block directly and confirms a sizer failure marks the
242// day's entry StoreUnswept rather than lying with a zero byte count.
243func TestStoragePassMarksUnreachableStoreUnswept(t *testing.T) {
244 fx := newConsoleFixture(t)
245 ctx := context.Background()
246
247 rec := &objstore.Record{
248 Provisioned: &objstore.Provisioned{
249 Bucket: "test-tenant-bucket",
250 Region: "us-east-1",
251 },
252 }
253 if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, rec, ""); err != nil {
254 t.Fatal(err)
255 }
256
257 fx.srv.storeSizer = func(ctx context.Context, p *objstore.Provisioned) (int64, error) {
258 return 0, errors.New("endpoint down")
259 }
260
261 if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil {
262 t.Fatal(err)
263 }
264 raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11"))
265 if err != nil {
266 t.Fatal(err)
267 }
268 var day usage.StorageDay
269 if err := json.Unmarshal(raw, &day); err != nil {
270 t.Fatal(err)
271 }
272 app := day.Apps[fixtureAcct+"/"+fixtureApp]
273 if !app.StoreUnswept {
274 t.Fatalf("app with unreachable store = %+v, want StoreUnswept", app)
275 }
276 if !app.StoreProvisioned {
277 t.Fatalf("app with unreachable provisioned store = %+v, want StoreProvisioned", app)
278 }
279 if app.StoreBytes != 0 {
280 t.Fatalf("app.StoreBytes = %d, want 0 when unswept", app.StoreBytes)
281 }
282}
283
284// TestStoragePassDegradesOnStoreRecordReadError: objstore.Get's "absent"
285// return is (nil, "", nil) — a non-nil error means a REAL failure (bucket
286// read, malformed store.json), not "no store". A pass that treated the two
287// alike would write StoreBytes:0/StoreUnswept:false into a create-only
288// object, forever indistinguishable from an app that never had a store.
289func TestStoragePassDegradesOnStoreRecordReadError(t *testing.T) {
290 fx := newConsoleFixture(t)
291 ctx := context.Background()
292
293 // Corrupt the record directly so objstore.Get's json.Unmarshal fails —
294 // the fixture bucket makes this easier than injecting a wrapped store
295 // whose Get errors, and it exercises the exact malformed-store.json case
296 // the finding names.
297 if _, err := fx.rel.Put(ctx, objstore.Key(fixtureAcct, fixtureApp), []byte("{not valid json"), ""); err != nil {
298 t.Fatal(err)
299 }
300
301 if err := fx.srv.storagePass(ctx, "2026-08-11"); err != nil {
302 t.Fatal(err)
303 }
304 raw, _, err := fx.rel.Get(ctx, usage.StorageDayKey("2026-08-11"))
305 if err != nil {
306 t.Fatal(err)
307 }
308 var day usage.StorageDay
309 if err := json.Unmarshal(raw, &day); err != nil {
310 t.Fatal(err)
311 }
312 app := day.Apps[fixtureAcct+"/"+fixtureApp]
313 if !app.StoreUnswept {
314 t.Fatalf("app with an unreadable store record = %+v, want StoreUnswept", app)
315 }
316 if app.StoreBytes != 0 {
317 t.Fatalf("app.StoreBytes = %d, want 0 when unswept", app.StoreBytes)
318 }
319}
320
321// TestStatementsPassGolden pins the canonical meter derivations end to end
322// off bucket objects alone — no live boxes needed. cpu avg 0.5 for 60
323// samples (60s apart) in one hour is 0.5 core-hours; mem avg 1e9 bytes over
324// the same 60 samples is 1 GB-hour and, from the same sample count,
325// 1 instance-hour. The route is unit-backed (Host=a.example.com, no
326// Route.Unit), so unitOwner must match it via activator.UnitName, exactly
327// the forward-construction OwnerAppMetrics uses.
328func TestStatementsPassGolden(t *testing.T) {
329 fx := newConsoleFixture(t)
330 ctx := context.Background()
331 now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC)
332
333 reg, err := registry.Open(fx.regPath)
334 if err != nil {
335 t.Fatal(err)
336 }
337 if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance",
338 Status: "ready", App: fixtureApp, Version: "v1"}); err != nil {
339 t.Fatal(err)
340 }
341
342 // One box, one day of metrics: unit carlos-app@a.example.com with
343 // cpu avg 0.5 for 60 samples in hour 09 (= 0.5 core-hours), mem avg
344 // 1e9 bytes (= 1 GB-hour, 1 instance-hour).
345 cpu, mem := 0.5, 1e9
346 roll := metrics.DayRollup{V: 1, Box: "box1", Date: "2026-08-10", Hours: map[string]metrics.HourAgg{
347 "09": {Units: map[string]map[string]metrics.Agg{
348 "carlos-app@a.example.com.service": {
349 "cpu": {Avg: cpu, N: 60},
350 "mem_bytes": {Avg: mem, N: 60},
351 },
352 }},
353 }}
354 putStoreJSON(t, fx.rel, metrics.DayKey("box1", "2026-08-10"), roll)
355
356 // Edge day: 2 GB out, 4000 requests for the same route's account.
357 putStoreJSON(t, fx.rel, usage.EdgeDayKey("box1", "2026-08-10"), usage.EdgeDay{
358 V: 1, Box: "box1", Date: "2026-08-10",
359 Hours: map[string]map[string]usage.EdgeRow{"09": {
360 "a.example.com": {Account: fixtureAcct, App: fixtureApp, Requests: 4000, BytesOut: 2e9, HopRequests: 1000, HopBytesOut: 5e8},
361 }},
362 })
363
364 if err := fx.srv.statementsPass(ctx, now); err != nil {
365 t.Fatal(err)
366 }
367
368 raw, _, err := fx.rel.Get(ctx, usage.StatementKey(fixtureAcct, "2026-08"))
369 if err != nil {
370 t.Fatal(err)
371 }
372 var st usage.Statement
373 if err := json.Unmarshal(raw, &st); err != nil {
374 t.Fatal(err)
375 }
376 approxF := func(name string, got, want float64) {
377 t.Helper()
378 if math.Abs(got-want) > 1e-9 {
379 t.Fatalf("%s = %v, want %v", name, got, want)
380 }
381 }
382 approxF("cpu", st.Meters[usage.MeterCPUCoreHours], 0.5)
383 approxF("mem", st.Meters[usage.MeterMemGBHours], 1.0)
384 approxF("instance_hours", st.Meters[usage.MeterInstanceHours], 1.0)
385 approxF("egress", st.Meters[usage.MeterEgressGB], 2.0)
386 approxF("requests", st.Meters[usage.MeterRequests1k], 4.0)
387 // The hop subset rides along as its own meters (#288) — inside egress
388 // (the 2.0 GB above already includes the 0.5 GB), never on top of it.
389 approxF("backhaul", st.Meters[usage.MeterBackhaulGB], 0.5)
390 approxF("backhaul requests", st.Meters[usage.MeterBackhaulRequests1k], 1.0)
391 if st.DaysInMonth != 31 || st.CoveredDays < 1 {
392 t.Fatalf("coverage = %d/%d", st.CoveredDays, st.DaysInMonth)
393 }
394}
395
396// TestStatementsPassZeroUsageAccountListed: an account with a live route but
397// no metrics/edge/storage data this month still gets a statement — the
398// accounts page must be able to list every account, not just ones with
399// usage — and unitOwner falls a completely unmatched unit through to
400// release.OpsAccount rather than a bare "ops" literal.
401func TestStatementsPassZeroUsageAccountListed(t *testing.T) {
402 fx := newConsoleFixture(t)
403 ctx := context.Background()
404 now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC)
405
406 if err := fx.srv.statementsPass(ctx, now); err != nil {
407 t.Fatal(err)
408 }
409 raw, _, err := fx.rel.Get(ctx, usage.StatementKey(release.OpsAccount, "2026-08"))
410 if err != nil {
411 t.Fatal(err)
412 }
413 var st usage.Statement
414 if err := json.Unmarshal(raw, &st); err != nil {
415 t.Fatal(err)
416 }
417 if st.Account != release.OpsAccount {
418 t.Fatalf("st.Account = %q, want %q", st.Account, release.OpsAccount)
419 }
420 for meter, v := range st.Meters {
421 if v != 0 {
422 t.Fatalf("zero-usage statement has non-zero %s = %v", meter, v)
423 }
424 }
425}
426
427// TestUnitOwnerUnmatchedFallsToOpsAccount pins the correction over the
428// brief's literal text: the fallback is release.OpsAccount ("bab"), never
429// the bare string "ops".
430func TestUnitOwnerUnmatchedFallsToOpsAccount(t *testing.T) {
431 acct, app, ok := unitOwner(nil, "console.service")
432 if ok {
433 t.Fatalf("unitOwner matched an empty route list: acct=%q app=%q", acct, app)
434 }
435 if acct != release.OpsAccount {
436 t.Fatalf("unmatched unit acct = %q, want release.OpsAccount (%q)", acct, release.OpsAccount)
437 }
438 if app != "" {
439 t.Fatalf("unmatched unit app = %q, want empty", app)
440 }
441}
442
443// TestUnitOwnerMatchesStampedUnitColumn: a route with a non-empty Unit
444// column matches that exact unit name, independent of Host.
445func TestUnitOwnerMatchesStampedUnitColumn(t *testing.T) {
446 routes := []registry.Route{
447 {Host: "bespoke.example.com", App: "bespoke", Unit: "carlos-bespoke.service"},
448 }
449 acct, app, ok := unitOwner(routes, "carlos-bespoke.service")
450 if !ok || app != "bespoke" || acct != release.OpsAccount {
451 t.Fatalf("unitOwner(stamped unit) = %q %q %v", acct, app, ok)
452 }
453}
454
455// TestStatementsPassRejectsInvalidStorageAccountSegment: a legacy
456// unqualified app prefix (apps/<app>/releases/, no account segment) makes
457// storagePass mint a StorageDay key whose segment before "/" is an APP
458// name, not an account sqid. release.ValidAccount is letters-only 3-16
459// chars, so a plain lowercase app name like "kass" happens to pass it too
460// — this fixture uses "my-app" (a hyphen, legal for release.ValidApp,
461// illegal for release.ValidAccount) as the bogus account segment, the same
462// shape storagePass would actually produce for an app whose name isn't
463// letters-only. statementsPass must not mint a statement under that bogus
464// key; the bytes must land on release.OpsAccount instead.
465func TestStatementsPassRejectsInvalidStorageAccountSegment(t *testing.T) {
466 fx := newConsoleFixture(t)
467 ctx := context.Background()
468 now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC)
469
470 if release.ValidAccount("my-app") {
471 t.Fatal("test fixture assumption broken: \"my-app\" passes release.ValidAccount")
472 }
473
474 putStoreJSON(t, fx.rel, usage.StorageDayKey("2026-08-10"), usage.StorageDay{
475 V: 1, Date: "2026-08-10", Apps: map[string]usage.AppStorage{
476 "my-app/releases": {BucketBytes: 31 * 1e9}, // 1 GB-month over 31 days
477 },
478 })
479
480 if err := fx.srv.statementsPass(ctx, now); err != nil {
481 t.Fatal(err)
482 }
483
484 if _, _, err := fx.rel.Get(ctx, usage.StatementKey("my-app", "2026-08")); !errors.Is(err, os.ErrNotExist) {
485 t.Fatalf("statement minted under the bogus account key \"my-app\": err=%v", err)
486 }
487
488 raw, _, err := fx.rel.Get(ctx, usage.StatementKey(release.OpsAccount, "2026-08"))
489 if err != nil {
490 t.Fatal(err)
491 }
492 var st usage.Statement
493 if err := json.Unmarshal(raw, &st); err != nil {
494 t.Fatal(err)
495 }
496 if got, want := st.Meters[usage.MeterStorageGBMonth], 1.0; math.Abs(got-want) > 1e-9 {
497 t.Fatalf("ops storage_gb_month = %v, want %v (invalid segment's bytes must land here)", got, want)
498 }
499}
500
501// TestRollupPresenceHours pins rollupPresenceHours's exact rule (task-3
502// brief, clamp added in review fix round 1): per present hour (a key in
503// Hours), Host["cpu"].N minutes/60 when present; else the max N across any
504// other Host agg; else — Host itself carrying no aggs at all — a flat
505// 60-minute (1.0h) fallback; each hour's contribution then clamps to at
506// most 1.0. Three hours: Host["cpu"].N=60 (-> 1.0h), an empty Host map
507// (-> 1.0h fallback), and Host["cpu"].N=90 — more than 60 samples in one
508// hour, e.g. a mid-hour agent restart — which must still clamp to 1.0h, not
509// 1.5h; total 3.0.
510func TestRollupPresenceHours(t *testing.T) {
511 d := metrics.DayRollup{V: 1, Box: "bx", Date: "2026-08-10", Hours: map[string]metrics.HourAgg{
512 "09": {Host: map[string]metrics.Agg{"cpu": {N: 60}}},
513 "10": {Host: map[string]metrics.Agg{}},
514 "11": {Host: map[string]metrics.Agg{"cpu": {N: 90}}},
515 }}
516 if got, want := rollupPresenceHours(d), 3.0; math.Abs(got-want) > 1e-9 {
517 t.Fatalf("rollupPresenceHours = %v, want %v", got, want)
518 }
519}
520
521// TestStatementsPassFleetMeters: a fleet box's day rollups price into the
522// owning account's fleet_box_hours_server/device meters, split by the
523// literal "device" tag — bx-server (untagged) contributes to the server
524// meter, bx-pi (tags "pi,device") to the device meter. Rollups are written
525// under fleets/<fleet>/ — the SAME prefix a door-backed fleet box's write
526// ACL admits (internal/edge/storedoor.go aclWrite) and resolveStatusPrefix
527// (internal/host/agent.go) defaults a fleet box's publishing to — proving
528// fleetMeters reads the real path, not the platform root.
529func TestStatementsPassFleetMeters(t *testing.T) {
530 fx := newConsoleFixture(t)
531 ctx := context.Background()
532 now := time.Date(2026, 8, 11, 6, 0, 0, 0, time.UTC)
533
534 reg, err := registry.Open(fx.regPath)
535 if err != nil {
536 t.Fatal(err)
537 }
538 if _, err := reg.CreateFleet(registry.Fleet{Name: "fx", Account: "bae", Bucket: "fx-data"}); err != nil {
539 t.Fatal(err)
540 }
541 tokenHash := strings.Repeat("a", 64)
542 if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-server", TokenHash: tokenHash}); err != nil {
543 t.Fatal(err)
544 }
545 if err := reg.AddFleetBox(registry.FleetBox{Fleet: "fx", Label: "bx-pi", TokenHash: tokenHash}); err != nil {
546 t.Fatal(err)
547 }
548 // AddFleetBox's INSERT does not carry tags (schema default '') — a box's
549 // tags are set through the dedicated SetFleetBoxTags writer.
550 if err := reg.SetFleetBoxTags("fx", "bx-pi", "pi,device"); err != nil {
551 t.Fatal(err)
552 }
553
554 fleetPrefix := "fleets/fx/"
555 serverHours := map[string]metrics.HourAgg{}
556 for h := 0; h < 24; h++ {
557 serverHours[hourKey(h)] = metrics.HourAgg{Host: map[string]metrics.Agg{"cpu": {N: 60}}}
558 }
559 putStoreJSON(t, fx.rel, fleetPrefix+metrics.DayKey("bx-server", "2026-08-10"), metrics.DayRollup{
560 V: 1, Box: "bx-server", Date: "2026-08-10", Hours: serverHours,
561 })
562
563 piHours := map[string]metrics.HourAgg{}
564 for h := 0; h < 12; h++ {
565 piHours[hourKey(h)] = metrics.HourAgg{Host: map[string]metrics.Agg{"cpu": {N: 60}}}
566 }
567 putStoreJSON(t, fx.rel, fleetPrefix+metrics.DayKey("bx-pi", "2026-08-10"), metrics.DayRollup{
568 V: 1, Box: "bx-pi", Date: "2026-08-10", Hours: piHours,
569 })
570
571 if err := fx.srv.statementsPass(ctx, now); err != nil {
572 t.Fatal(err)
573 }
574
575 raw, _, err := fx.rel.Get(ctx, usage.StatementKey("bae", "2026-08"))
576 if err != nil {
577 t.Fatal(err)
578 }
579 var st usage.Statement
580 if err := json.Unmarshal(raw, &st); err != nil {
581 t.Fatal(err)
582 }
583 if got, want := st.Meters[usage.MeterFleetBoxHoursServer], 24.0; math.Abs(got-want) > 1e-9 {
584 t.Fatalf("fleet_box_hours_server = %v, want %v", got, want)
585 }
586 if got, want := st.Meters[usage.MeterFleetBoxHoursDevice], 12.0; math.Abs(got-want) > 1e-9 {
587 t.Fatalf("fleet_box_hours_device = %v, want %v", got, want)
588 }
589}
590
591// hourKey formats an hour 0-23 as the DayRollup.Hours key shape ("00".."23").
592func hourKey(h int) string {
593 return time.Date(2000, 1, 1, h, 0, 0, 0, time.UTC).Format("15")
594}
595
596// TestAccountTierStore: SetAccountTier writes a validated tier that
597// AccountTiers then reports keyed by sqid; an invalid tier is rejected with
598// no write; an unknown account id is a silent no-op, matching this store's
599// UPDATE...WHERE convention elsewhere (e.g. ReapAccount's deleted_at guard)
600// rather than a distinguishable error a caller would need to branch on.
601func TestAccountTierStore(t *testing.T) {
602 s := openTestStore(t)
603 now := time.Now()
604 id := testAccountID()
605 sqid := "test-tier-1"
606 if err := s.CreateAccount(id, sqid, "Tierco", "paul@keymail.dev", now); err != nil {
607 t.Fatal(err)
608 }
609
610 // Freshly created account defaults to the empty tier.
611 tiers, err := s.AccountTiers()
612 if err != nil {
613 t.Fatal(err)
614 }
615 if got, want := tiers[sqid], ""; got != want {
616 t.Fatalf("fresh account tier = %q, want %q", got, want)
617 }
618
619 if err := s.SetAccountTier(id, "pool"); err != nil {
620 t.Fatal(err)
621 }
622 tiers, err = s.AccountTiers()
623 if err != nil {
624 t.Fatal(err)
625 }
626 if got, want := tiers[sqid], "pool"; got != want {
627 t.Fatalf("account tier after SetAccountTier = %q, want %q", got, want)
628 }
629
630 // Invalid tier: rejected, no write.
631 if err := s.SetAccountTier(id, "bogus"); err == nil {
632 t.Fatal("SetAccountTier with an invalid tier returned nil error, want a rejection")
633 }
634 tiers, err = s.AccountTiers()
635 if err != nil {
636 t.Fatal(err)
637 }
638 if got, want := tiers[sqid], "pool"; got != want {
639 t.Fatalf("account tier after a rejected invalid write = %q, want unchanged %q", got, want)
640 }
641
642 // Unknown account id: no-op, no error.
643 if err := s.SetAccountTier(id+999999, "fleet"); err != nil {
644 t.Fatalf("SetAccountTier on an unknown id returned an error, want a silent no-op: %v", err)
645 }
646
647 // A deleted account is excluded from AccountTiers, same filter as
648 // Accounts().
649 if err := s.DeleteAccount(id, now); err != nil {
650 t.Fatal(err)
651 }
652 tiers, err = s.AccountTiers()
653 if err != nil {
654 t.Fatal(err)
655 }
656 if _, ok := tiers[sqid]; ok {
657 t.Fatalf("AccountTiers still reports a deleted account: %+v", tiers)
658 }
659}
660
661// TestEconomicsTierDoor: POST /fleet/economics/tier is registered without a
662// method prefix (server.go), same reasoning as
663// TestEconomicsWriteDoorsRejectNonPOST — a wrong-method request must be
664// byte-identical to an unknown route, both signed out and signed in as a
665// non-operator, before the operator gate, before CSRF. An operator POST
666// resolves the account by sqid (via AccountBySqid — already
667// viewer-independent, since it filters deleted_at rather than membership),
668// sets its tier and redirects to /fleet/economics/accounts.
669func TestEconomicsTierDoor(t *testing.T) {
670 fx := newConsoleFixture(t)
671 out := newBrowser(t)
672 ghostStatus, _, ghostBody := get(t, out, fx.ts.URL+"/this-route-does-not-exist")
673
674 const path = "/fleet/economics/tier"
675
676 // Signed out: GET is the ghost 404.
677 if status, _, body := get(t, out, fx.ts.URL+path); status != ghostStatus || body != ghostBody {
678 t.Fatalf("signed-out GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
679 }
680 // Signed out: POST is the same ghost 404 too (method check runs before
681 // the session gate, but a signed-out POST must still be indistinguishable
682 // from an unknown route — no session cookie means no CSRF token to send
683 // at all).
684 signedOutForm := url.Values{"csrf": {""}, "account": {fx.sqid}, "tier": {"pool"}}
685 if status, _, body := postForm(t, out, fx.ts.URL+path, signedOutForm); status != ghostStatus || body != ghostBody {
686 t.Fatalf("signed-out POST %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
687 }
688
689 c := signInPaul(t, fx)
690 csrf := sessionToken(t, fx, c)
691
692 // Signed in, not an operator: GET is the ghost 404 (method-first).
693 if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody {
694 t.Fatalf("non-operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
695 }
696
697 // Signed in, not an operator: POST is the same ghost 404 (gate before
698 // CSRF).
699 opForm := url.Values{"csrf": {csrf}, "account": {fx.sqid}, "tier": {"pool"}}
700 if status, _, body := postForm(t, c, fx.ts.URL+path, opForm); status != ghostStatus || body != ghostBody {
701 t.Fatalf("non-operator POST %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
702 }
703
704 if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
705 t.Fatal(err)
706 }
707
708 // Operator GET: still the ghost 404 (method check runs first).
709 if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody {
710 t.Fatalf("operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody)
711 }
712
713 // Operator POST, wrong CSRF: 403, same shape TestScenarioSaveGateAndValidation
714 // pins for the sibling doors — the gate runs AFTER the operator check,
715 // so this is a real 403, not the ghost 404.
716 badCSRFForm := url.Values{"csrf": {"not-the-real-token"}, "account": {fx.sqid}, "tier": {"pool"}}
717 if status, _, _ := postForm(t, c, fx.ts.URL+path, badCSRFForm); status != http.StatusForbidden {
718 t.Fatalf("operator POST with wrong CSRF = %d, want %d", status, http.StatusForbidden)
719 }
720 tiers, err := fx.st.AccountTiers()
721 if err != nil {
722 t.Fatal(err)
723 }
724 if got := tiers[fx.sqid]; got != "" {
725 t.Fatalf("account tier after a wrong-CSRF POST = %q, want unchanged %q", got, "")
726 }
727
728 // Operator POST, happy path: sets the tier and redirects.
729 status, loc, _ := postForm(t, c, fx.ts.URL+path, opForm)
730 if status != http.StatusSeeOther || loc != "/fleet/economics/accounts" {
731 t.Fatalf("operator tier POST = %d -> %q, want 303 -> /fleet/economics/accounts", status, loc)
732 }
733 tiers, err = fx.st.AccountTiers()
734 if err != nil {
735 t.Fatal(err)
736 }
737 if got, want := tiers[fx.sqid], "pool"; got != want {
738 t.Fatalf("account tier after operator POST = %q, want %q", got, want)
739 }
740}
741