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 "net/http"
7 "testing"
8 "time"
9
10 "github.com/carlosframework/platform/internal/objstore"
11 "github.com/carlosframework/platform/internal/usage"
12)
13
14// An app may have several stores since 2026-09-19 — its own, and one per
15// config environment. Two places have to count them ALL: the metering pass,
16// which bills for them, and the purge path, which is the only door with the
17// authority to tear them down.
18
19func provisionedRecord(bucket string) *objstore.Record {
20 now := time.Now().UTC()
21 return &objstore.Record{
22 Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: now, By: "member@example.com"},
23 Grant: &objstore.GrantBlock{Backend: "mem", Bucket: bucket, Source: objstore.GrantSourceOperator, At: now, By: "op"},
24 Provisioned: &objstore.Provisioned{Bucket: bucket, Region: "eu-west-1", IAMUser: "u-" + bucket, KeyID: "K", At: now},
25 }
26}
27
28func readStorageDay(t *testing.T, fx *consoleFixture, date string) usage.StorageDay {
29 t.Helper()
30 raw, _, err := fx.rel.Get(context.Background(), usage.StorageDayKey(date))
31 if err != nil {
32 t.Fatal(err)
33 }
34 var day usage.StorageDay
35 if err := json.Unmarshal(raw, &day); err != nil {
36 t.Fatal(err)
37 }
38 return day
39}
40
41// The bill is the sum over every store of the app. Sizes differ per bucket so
42// this fails if either store is dropped, rather than passing on a coincidence.
43func TestStoragePassSumsEveryStoreOfAnApp(t *testing.T) {
44 ctx := context.Background()
45 fx := newConsoleFixture(t)
46
47 if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, provisionedRecord("app-bucket"), ""); err != nil {
48 t.Fatal(err)
49 }
50 if _, err := objstore.PutIn(ctx, fx.rel, fixtureAcct, fixtureApp, "beta", provisionedRecord("beta-bucket"), ""); err != nil {
51 t.Fatal(err)
52 }
53 fx.srv.storeSizer = func(_ context.Context, p *objstore.Provisioned) (int64, error) {
54 switch p.Bucket {
55 case "app-bucket":
56 return 1000, nil
57 case "beta-bucket":
58 return 2000, nil
59 }
60 t.Errorf("sized an unexpected bucket %q", p.Bucket)
61 return 0, nil
62 }
63
64 if err := fx.srv.storagePass(ctx, "2026-09-19"); err != nil {
65 t.Fatal(err)
66 }
67 app := readStorageDay(t, fx, "2026-09-19").Apps[fixtureAcct+"/"+fixtureApp]
68 if app.StoreBytes != 3000 {
69 t.Errorf("StoreBytes = %d, want 3000 (1000 app + 2000 beta)", app.StoreBytes)
70 }
71 if !app.StoreProvisioned {
72 t.Error("StoreProvisioned = false, want true")
73 }
74 if app.StoreUnswept {
75 t.Error("StoreUnswept = true, but every store was sized")
76 }
77}
78
79// "Could not ask" is not "no bytes". The day object is create-only, so a
80// zero written here can never be corrected — an unreadable environment store
81// has to mark the day unswept, exactly as an unreadable app store does.
82func TestStoragePassMarksUnsweptOnAnUnreadableEnvironmentStore(t *testing.T) {
83 ctx := context.Background()
84 fx := newConsoleFixture(t)
85
86 if _, err := objstore.Put(ctx, fx.rel, fixtureAcct, fixtureApp, provisionedRecord("app-bucket"), ""); err != nil {
87 t.Fatal(err)
88 }
89 if _, err := fx.rel.Put(ctx, objstore.KeyIn(fixtureAcct, fixtureApp, "beta"), []byte("{not valid json"), ""); err != nil {
90 t.Fatal(err)
91 }
92 fx.srv.storeSizer = func(_ context.Context, p *objstore.Provisioned) (int64, error) {
93 return 1000, nil
94 }
95
96 if err := fx.srv.storagePass(ctx, "2026-09-19"); err != nil {
97 t.Fatal(err)
98 }
99 app := readStorageDay(t, fx, "2026-09-19").Apps[fixtureAcct+"/"+fixtureApp]
100 if !app.StoreUnswept {
101 t.Error("StoreUnswept = false, but one of this app's stores could not be read")
102 }
103 // The store it COULD read is still counted: a partial figure marked
104 // unswept is honest, a dropped one is not.
105 if app.StoreBytes != 1000 {
106 t.Errorf("StoreBytes = %d, want the app store's 1000", app.StoreBytes)
107 }
108}
109
110// grantStoreIn drives the real doors, so the provisioner genuinely holds a
111// bucket, an IAM user and a key for each store — the estate a purge has to
112// find. grantStore (apiscan_test.go) is this with no environment.
113func grantStoreIn(t *testing.T, fx *consoleFixture, token, env string) {
114 t.Helper()
115 declareURL, grantURL := storeURL(fx, "titogo"), storeURL(fx, "titogo")+"/grant"
116 if env != "" {
117 declareURL = storeEnvURL(fx, "titogo", env)
118 grantURL += "?environment=" + env
119 }
120 if status, out := postJSON(t, declareURL, map[string]any{}, token); status != http.StatusOK {
121 t.Fatalf("declare %q = %d %v", env, status, out)
122 }
123 if status, out := postJSON(t, grantURL, map[string]any{}, token); status != http.StatusOK {
124 t.Fatalf("grant %q = %d %v", env, status, out)
125 }
126}
127
128// Purging an app tears down EVERY store it has. release.PurgeApp refuses the
129// box-side auto-purge while any of them holds a grant or provisioned block,
130// so a purge that missed one would not leave a partial cleanup — it would
131// leave an app that can never be purged at all.
132func TestPurgeAppStoresTearsDownEveryStore(t *testing.T) {
133 ctx := context.Background()
134 fx, fake, token, _ := storeArmed(t, "code-purge-every-store")
135 grantStoreIn(t, fx, token, "")
136 grantStoreIn(t, fx, token, "beta")
137
138 if len(fake.Buckets) != 2 || len(fake.Users) != 2 {
139 t.Fatalf("fixture drift: buckets %v users %v, want one of each per store", fake.Buckets, fake.Users)
140 }
141
142 acct := &Account{ID: OpsAccountID, Sqid: fx.sqid}
143 if msg := fx.srv.purgeAppStores(ctx, acct, "titogo", "paul@keymail.dev"); msg != "" {
144 t.Fatalf("purgeAppStores refused: %s", msg)
145 }
146 if len(fake.Buckets) != 0 {
147 t.Errorf("buckets survived the purge: %v", fake.Buckets)
148 }
149 if len(fake.Users) != 0 {
150 t.Errorf("IAM users survived the purge: %v", fake.Users)
151 }
152 for _, env := range []string{"", "beta"} {
153 rec, _, err := objstore.GetIn(ctx, fx.rel, fx.sqid, "titogo", env)
154 if err != nil {
155 t.Fatal(err)
156 }
157 if rec != nil {
158 t.Errorf("store record for %q survived the purge: %+v", env, rec)
159 }
160 }
161}
162
163// A console with no storage backend cannot tear an estate down, so it must
164// refuse rather than let the prefix sweep delete the only records naming a
165// live bucket and a live credential.
166func TestPurgeAppStoresRefusesWithNoProvisioner(t *testing.T) {
167 ctx := context.Background()
168 fx, fake, token, _ := storeArmed(t, "code-purge-no-backend")
169 grantStoreIn(t, fx, token, "beta")
170 fx.srv.cfg.StoreProvisioner = nil
171
172 acct := &Account{ID: OpsAccountID, Sqid: fx.sqid}
173 msg := fx.srv.purgeAppStores(ctx, acct, "titogo", "paul@keymail.dev")
174 if msg == "" {
175 t.Fatal("purgeAppStores allowed a purge it could not carry out")
176 }
177 if len(fake.Buckets) != 1 {
178 t.Errorf("the refused purge touched the backend: %v", fake.Buckets)
179 }
180 rec, _, err := objstore.GetIn(ctx, fx.rel, fx.sqid, "titogo", "beta")
181 if err != nil {
182 t.Fatal(err)
183 }
184 if rec == nil {
185 t.Error("the refused purge deleted the record naming a live estate")
186 }
187}
188