| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/objstore" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | "github.com/carlosframework/platform/internal/svcrole" |
| 17 | ) |
| 18 | |
| 19 | func storeProvisionURL(fx *consoleFixture, app string) string { |
| 20 | return storeURL(fx, app) + "/provision" |
| 21 | } |
| 22 | |
| 23 | func approveStoreAccount(t *testing.T, fx *consoleFixture, by string) { |
| 24 | t.Helper() |
| 25 | putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{ |
| 26 | Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Minute)}, |
| 27 | Decision: &objstore.Decision{Approved: true, By: by, At: time.Now()}, |
| 28 | }) |
| 29 | } |
| 30 | |
| 31 | type pausedBucketProvisioner struct { |
| 32 | objstore.Provisioner |
| 33 | entered chan struct{} |
| 34 | release chan struct{} |
| 35 | once sync.Once |
| 36 | } |
| 37 | |
| 38 | func (p *pausedBucketProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) { |
| 39 | p.once.Do(func() { |
| 40 | close(p.entered) |
| 41 | select { |
| 42 | case <-p.release: |
| 43 | case <-ctx.Done(): |
| 44 | } |
| 45 | }) |
| 46 | return p.Provisioner.EnsureBucket(ctx, name) |
| 47 | } |
| 48 | |
| 49 | type provisionResult struct { |
| 50 | status int |
| 51 | out map[string]any |
| 52 | } |
| 53 | |
| 54 | func provisionAsync(t *testing.T, fx *consoleFixture, token string) <-chan provisionResult { |
| 55 | t.Helper() |
| 56 | ch := make(chan provisionResult, 1) |
| 57 | go func() { |
| 58 | defer close(ch) |
| 59 | status, raw := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| 60 | out := map[string]any{} |
| 61 | if err := json.Unmarshal([]byte(raw), &out); err != nil { |
| 62 | out["raw"] = raw |
| 63 | } |
| 64 | ch <- provisionResult{status: status, out: out} |
| 65 | }() |
| 66 | return ch |
| 67 | } |
| 68 | |
| 69 | func waitPaused(t *testing.T, p *pausedBucketProvisioner) { |
| 70 | t.Helper() |
| 71 | select { |
| 72 | case <-p.entered: |
| 73 | case <-time.After(3 * time.Second): |
| 74 | t.Fatal("provision did not reach the paused backend") |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | func TestAPIStoreProvisionNeedsAnApprovedEntitlement(t *testing.T) { |
| 79 | fx, fake, token, _ := storeArmed(t, "code-store-provision-unapproved") |
| 80 | declareStore(t, fx, token) |
| 81 | |
| 82 | status, _ := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| 83 | if status < 400 { |
| 84 | t.Fatalf("provision without approval = %d, want a refusal", status) |
| 85 | } |
| 86 | untouched(t, fake, "an unapproved provision") |
| 87 | if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil { |
| 88 | t.Fatalf("an unapproved provision wrote a grant: %+v", rec.Grant) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestAPIStoreProvisionWithoutABackendDoesNotGrant(t *testing.T) { |
| 93 | fx, fake, token, _ := storeArmed(t, "code-store-provision-nobackend") |
| 94 | declareStore(t, fx, token) |
| 95 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 96 | fx.srv.cfg.StoreProvisioner = nil |
| 97 | |
| 98 | status, _ := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| 99 | if status != http.StatusServiceUnavailable { |
| 100 | t.Fatalf("provision without a backend = %d, want 503", status) |
| 101 | } |
| 102 | untouched(t, fake, "a backend-less provision") |
| 103 | if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil { |
| 104 | t.Fatalf("a backend-less provision wrote a grant: %+v", rec.Grant) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestAPIStoreProvisionAttributesAccountApproval(t *testing.T) { |
| 109 | fx, fake, token, priv := storeArmed(t, "code-store-provision-happy") |
| 110 | declareStore(t, fx, token) |
| 111 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 112 | fx.srv.cfg.StoreOperators = nil |
| 113 | |
| 114 | status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| 115 | if status != http.StatusOK { |
| 116 | t.Fatalf("provision = %d %v, want 200", status, out) |
| 117 | } |
| 118 | rec := storedRecord(t, fx, "titogo") |
| 119 | if rec.Provisioned == nil || rec.Grant == nil { |
| 120 | t.Fatalf("provisioned record = %+v", rec) |
| 121 | } |
| 122 | if rec.Grant.Source != objstore.GrantSourceAccountApproval || rec.Grant.By != "operator@keymail.dev" { |
| 123 | t.Fatalf("grant = %+v, want the account approval source and deciding operator", rec.Grant) |
| 124 | } |
| 125 | u := fake.Users[rec.Provisioned.IAMUser] |
| 126 | secret, ok := u.Keys[rec.Provisioned.KeyID] |
| 127 | if !ok { |
| 128 | t.Fatalf("recorded key %q is absent from backend keys %v", rec.Provisioned.KeyID, u.Keys) |
| 129 | } |
| 130 | if got := openSecret(t, priv, appVars(t, fx, "titogo")["CARLOS_STORE_SECRET_KEY"]); got != secret { |
| 131 | t.Fatalf("delivered secret does not match the recorded backend key") |
| 132 | } |
| 133 | if entitlementBlock(t, out)["decision"] == nil { |
| 134 | t.Fatalf("provision reply omitted the entitlement: %v", out) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | func TestAPIStoreProvisionCompletedStoreIsANoOp(t *testing.T) { |
| 139 | fx, fake, token, _ := storeArmed(t, "code-store-provision-noop") |
| 140 | declareStore(t, fx, token) |
| 141 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 142 | if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| 143 | t.Fatalf("first provision = %d %v", status, out) |
| 144 | } |
| 145 | before := storedRecord(t, fx, "titogo").Provisioned.KeyID |
| 146 | if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| 147 | t.Fatalf("second provision = %d %v", status, out) |
| 148 | } |
| 149 | after := storedRecord(t, fx, "titogo").Provisioned.KeyID |
| 150 | if after != before || len(fake.Users["carlos-store-bab-titogo"].Keys) != 1 { |
| 151 | t.Fatalf("no-op changed key %q to %q or minted extras: %v", before, after, fake.Users) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | func TestAPIStoreProvisionRetriesAFailedGrantWithoutChangingBucket(t *testing.T) { |
| 156 | fx, fake, token, _ := storeArmed(t, "code-store-provision-retry") |
| 157 | declareStore(t, fx, token) |
| 158 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 159 | fake.FailNext["EnsureBucket"] = errors.New("backend detail must stay private") |
| 160 | |
| 161 | status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| 162 | if status != http.StatusBadGateway { |
| 163 | t.Fatalf("failed provision = %d %v, want 502", status, out) |
| 164 | } |
| 165 | if strings.Contains(str(t, out, "error"), "backend detail") { |
| 166 | t.Fatalf("provision response leaked the backend failure: %v", out) |
| 167 | } |
| 168 | failed := storedRecord(t, fx, "titogo") |
| 169 | if failed.Grant == nil || failed.Provisioned != nil { |
| 170 | t.Fatalf("failed provision record = %+v, want grant-only", failed) |
| 171 | } |
| 172 | bucket := failed.Grant.Bucket |
| 173 | if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| 174 | t.Fatalf("retry provision = %d %v, want 200", status, out) |
| 175 | } |
| 176 | got := storedRecord(t, fx, "titogo") |
| 177 | if got.Provisioned == nil || got.Provisioned.Bucket != bucket || got.Grant.Bucket != bucket { |
| 178 | t.Fatalf("retry changed or failed to provision bucket %q: %+v", bucket, got) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestAPIStoreProvisionSerializesAndRereadsEntitlement(t *testing.T) { |
| 183 | fx, fake, token, _ := storeArmed(t, "code-store-provision-revoke") |
| 184 | declareStore(t, fx, token) |
| 185 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 186 | paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})} |
| 187 | fx.srv.cfg.StoreProvisioner = paused |
| 188 | defer func() { |
| 189 | select { |
| 190 | case <-paused.release: |
| 191 | default: |
| 192 | close(paused.release) |
| 193 | } |
| 194 | }() |
| 195 | |
| 196 | first := provisionAsync(t, fx, token) |
| 197 | waitPaused(t, paused) |
| 198 | second := provisionAsync(t, fx, token) |
| 199 | waitStoreProvisionQueued(t, fx) |
| 200 | e, etag, err := objstore.GetEntitlement(context.Background(), fx.rel, fx.sqid) |
| 201 | if err != nil { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | e.Decision = &objstore.Decision{Approved: false, By: "operator@keymail.dev", At: time.Now(), Reason: "capacity review"} |
| 205 | if _, err := objstore.PutEntitlement(context.Background(), fx.rel, fx.sqid, e, etag); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | close(paused.release) |
| 209 | if got := <-first; got.status != http.StatusOK { |
| 210 | t.Fatalf("first provision = %d %v, want 200", got.status, got.out) |
| 211 | } |
| 212 | if got := <-second; got.status < 400 { |
| 213 | t.Fatalf("queued provision after revocation = %d %v, want a refusal", got.status, got.out) |
| 214 | } |
| 215 | status, out := getJSON(t, storeURL(fx, "titogo"), token) |
| 216 | if status != http.StatusOK || block(t, entitlementBlock(t, out), "decision")["approved"] != false { |
| 217 | t.Fatalf("declined status = %d %v", status, out) |
| 218 | } |
| 219 | rec := storedRecord(t, fx, "titogo") |
| 220 | if rec.Provisioned == nil || len(fake.Users[rec.Provisioned.IAMUser].Keys) != 1 { |
| 221 | t.Fatalf("serialized provisions left the wrong estate: record=%+v users=%v", rec, fake.Users) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestAPIStoreDeclareSamePrefixDuringProvisionIsNoOp(t *testing.T) { |
| 226 | fx, fake, token, _ := storeArmed(t, "code-store-provision-declare-race") |
| 227 | declareStore(t, fx, token) |
| 228 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 229 | paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})} |
| 230 | fx.srv.cfg.StoreProvisioner = paused |
| 231 | defer func() { |
| 232 | select { |
| 233 | case <-paused.release: |
| 234 | default: |
| 235 | close(paused.release) |
| 236 | } |
| 237 | }() |
| 238 | result := provisionAsync(t, fx, token) |
| 239 | waitPaused(t, paused) |
| 240 | |
| 241 | before, beforeETag, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| 242 | if err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token) |
| 246 | if status != http.StatusOK { |
| 247 | t.Fatalf("same-prefix declare = %d %v, want 200", status, out) |
| 248 | } |
| 249 | after, afterETag, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| 250 | if err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if afterETag != beforeETag || after.Declared.By != before.Declared.By || !after.Declared.At.Equal(before.Declared.At) { |
| 254 | t.Fatalf("same-prefix declare changed record: etag %q -> %q, declared %+v -> %+v", beforeETag, afterETag, before.Declared, after.Declared) |
| 255 | } |
| 256 | close(paused.release) |
| 257 | if got := <-result; got.status != http.StatusOK { |
| 258 | t.Fatalf("provision after no-op declare = %d %v, want 200", got.status, got.out) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func TestAPIStoreAdminServiceDeclarationKeepsServiceAttribution(t *testing.T) { |
| 263 | fx, _, _, _ := storeArmed(t, "code-store-provision-service") |
| 264 | admin := serviceToken(t, fx.st, OpsAccountID, "storebot", "", svcrole.Admin) |
| 265 | |
| 266 | status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, admin) |
| 267 | if status != http.StatusOK { |
| 268 | t.Fatalf("admin service declare = %d %v, want 200", status, out) |
| 269 | } |
| 270 | if got := storedRecord(t, fx, "titogo").Declared.By; got != "service:bab/storebot" { |
| 271 | t.Fatalf("declared.by = %q, want service attribution", got) |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | func waitStoreProvisionQueued(t *testing.T, fx *consoleFixture) { |
| 276 | t.Helper() |
| 277 | deadline := time.Now().Add(3 * time.Second) |
| 278 | for { |
| 279 | fx.srv.storeWalks.mu.Lock() |
| 280 | e := fx.srv.storeWalks.m[storeWalkKey(fx.sqid, "titogo", "")] |
| 281 | refs := 0 |
| 282 | if e != nil { |
| 283 | refs = e.refs |
| 284 | } |
| 285 | fx.srv.storeWalks.mu.Unlock() |
| 286 | if refs == 2 { |
| 287 | return |
| 288 | } |
| 289 | if time.Now().After(deadline) { |
| 290 | t.Fatalf("second provision never queued: refs=%d", refs) |
| 291 | } |
| 292 | time.Sleep(time.Millisecond) |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | func TestAPIStoreConcurrentProvisionsMintOneDeliveredKey(t *testing.T) { |
| 297 | fx, fake, token, priv := storeArmed(t, "code-store-provision-two") |
| 298 | declareStore(t, fx, token) |
| 299 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 300 | paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})} |
| 301 | fx.srv.cfg.StoreProvisioner = paused |
| 302 | defer func() { |
| 303 | select { |
| 304 | case <-paused.release: |
| 305 | default: |
| 306 | close(paused.release) |
| 307 | } |
| 308 | }() |
| 309 | first := provisionAsync(t, fx, token) |
| 310 | waitPaused(t, paused) |
| 311 | second := provisionAsync(t, fx, token) |
| 312 | waitStoreProvisionQueued(t, fx) |
| 313 | close(paused.release) |
| 314 | for _, result := range []<-chan provisionResult{first, second} { |
| 315 | select { |
| 316 | case got := <-result: |
| 317 | if got.status != http.StatusOK { |
| 318 | t.Fatalf("provision = %d %v", got.status, got.out) |
| 319 | } |
| 320 | case <-time.After(3 * time.Second): |
| 321 | t.Fatal("provision did not finish") |
| 322 | } |
| 323 | } |
| 324 | rec := storedRecord(t, fx, "titogo") |
| 325 | if rec.Provisioned == nil { |
| 326 | t.Fatal("store was not provisioned") |
| 327 | } |
| 328 | keys := fake.Users[rec.Provisioned.IAMUser].Keys |
| 329 | if rec.Provisioned.KeyID != "MEMKEY1" || len(keys) != 1 { |
| 330 | t.Fatalf("wanted exactly one mint: record=%+v keys=%v", rec.Provisioned, keys) |
| 331 | } |
| 332 | vars := appVars(t, fx, "titogo") |
| 333 | if openSecret(t, priv, vars["CARLOS_STORE_ACCESS_KEY"]) != rec.Provisioned.KeyID { |
| 334 | t.Fatal("delivered key differs from record") |
| 335 | } |
| 336 | if openSecret(t, priv, vars["CARLOS_STORE_SECRET_KEY"]) != keys[rec.Provisioned.KeyID] { |
| 337 | t.Fatal("delivered secret differs from backend") |
| 338 | } |
| 339 | } |
| 340 | |
| 341 | func TestAPIStoreDeclareReportsEntitlementWithoutProvisioning(t *testing.T) { |
| 342 | for _, approved := range []bool{false, true} { |
| 343 | for _, backend := range []bool{false, true} { |
| 344 | t.Run(fmt.Sprintf("approved=%t/backend=%t", approved, backend), func(t *testing.T) { |
| 345 | fx, fake, token, _ := storeArmed(t, "code-store-declare-entitlement") |
| 346 | if approved { |
| 347 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 348 | } |
| 349 | if !backend { |
| 350 | fx.srv.cfg.StoreProvisioner = nil |
| 351 | fx.srv.cfg.StoreDeployment = objstore.Deployment{} |
| 352 | } |
| 353 | status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token) |
| 354 | if status != http.StatusOK { |
| 355 | t.Fatalf("declare = %d %v", status, out) |
| 356 | } |
| 357 | if _, present := out["entitlement"]; !present { |
| 358 | t.Fatal("reply omitted entitlement") |
| 359 | } |
| 360 | if approved { |
| 361 | if !block(t, entitlementBlock(t, out), "decision")["approved"].(bool) { |
| 362 | t.Fatal("approval missing") |
| 363 | } |
| 364 | } else if out["entitlement"] != nil { |
| 365 | t.Fatalf("unexpected entitlement: %v", out) |
| 366 | } |
| 367 | if (out["backend"] != "") != backend { |
| 368 | t.Fatalf("backend mismatch: %v", out) |
| 369 | } |
| 370 | if out["grant"] != nil || out["provisioned"] != nil { |
| 371 | t.Fatalf("declare minted estate: %v", out) |
| 372 | } |
| 373 | untouched(t, fake, "declare") |
| 374 | }) |
| 375 | } |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | func TestAPIStoreProvisionMemberAndServiceGates(t *testing.T) { |
| 380 | fx, fake, token, _ := storeArmed(t, "code-store-provision-gates") |
| 381 | declareStore(t, fx, token) |
| 382 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 383 | stranger := cliToken(t, fx, "stranger@keymail.dev", "code-store-provision-stranger") |
| 384 | status, body := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, stranger) |
| 385 | if status != http.StatusNotFound || body != "404 page not found\n" { |
| 386 | t.Fatalf("stranger = %d %q", status, body) |
| 387 | } |
| 388 | for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance, svcrole.Operate} { |
| 389 | svc := serviceToken(t, fx.st, OpsAccountID, "denied-"+string(role), "", role) |
| 390 | status, _ := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, svc) |
| 391 | if status != http.StatusForbidden { |
| 392 | t.Fatalf("service %s = %d", role, status) |
| 393 | } |
| 394 | } |
| 395 | untouched(t, fake, "refused provision") |
| 396 | admin := serviceToken(t, fx.st, OpsAccountID, "store-admin", "titogo", svcrole.Admin) |
| 397 | status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, admin) |
| 398 | if status != http.StatusOK { |
| 399 | t.Fatalf("admin provision = %d %v", status, out) |
| 400 | } |
| 401 | if storedRecord(t, fx, "titogo").Grant.By != "operator@keymail.dev" { |
| 402 | t.Fatal("service replaced approving actor") |
| 403 | } |
| 404 | auditedBy(t, fx, "titogo", "store-provision", "service:bab/store-admin", "provisioned") |
| 405 | } |
| 406 | |
| 407 | func TestAPIStoreProvisionRequiresDeclarationAndSealKey(t *testing.T) { |
| 408 | fx, fake, token, _ := storeArmed(t, "code-store-provision-preconditions") |
| 409 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 410 | status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| 411 | if status != http.StatusConflict { |
| 412 | t.Fatalf("undeclared = %d %v", status, out) |
| 413 | } |
| 414 | declareStore(t, fx, token) |
| 415 | fx.srv.cfg.SecretsPub = "" |
| 416 | status, out = postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| 417 | if status != http.StatusServiceUnavailable { |
| 418 | t.Fatalf("unsealable = %d %v", status, out) |
| 419 | } |
| 420 | untouched(t, fake, "unsealable provision") |
| 421 | } |
| 422 | |
| 423 | func TestAPIStoreProvisionCannotAdoptABucket(t *testing.T) { |
| 424 | fx, fake, token, _ := storeArmed(t, "code-store-provision-no-adoption") |
| 425 | declareStore(t, fx, token) |
| 426 | approveStoreAccount(t, fx, "operator@keymail.dev") |
| 427 | status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{"bucket": "someone-elses-data"}, token) |
| 428 | if status != http.StatusOK && status != http.StatusBadRequest { |
| 429 | t.Fatalf("provision with adoption input = %d %v", status, out) |
| 430 | } |
| 431 | if _, exists := fake.Buckets["someone-elses-data"]; exists { |
| 432 | t.Fatal("member provision adopted caller bucket") |
| 433 | } |
| 434 | rec := storedRecord(t, fx, "titogo") |
| 435 | if rec.Grant != nil && rec.Grant.Bucket != objstore.MintedBucket(fx.srv.cfg.StoreDeployment, fx.sqid, "titogo") { |
| 436 | t.Fatalf("grant chose caller bucket: %+v", rec.Grant) |
| 437 | } |
| 438 | } |
| 439 | |
| 440 | func TestAPIStoreProvisionFailsClosedOnUnreadableApproval(t *testing.T) { |
| 441 | fx, fake, token, _ := storeArmed(t, "code-store-provision-unreadable") |
| 442 | declareStore(t, fx, token) |
| 443 | if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), []byte("private backend details"), ""); err != nil { |
| 444 | t.Fatal(err) |
| 445 | } |
| 446 | status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| 447 | if status != http.StatusBadGateway { |
| 448 | t.Fatalf("unreadable approval = %d %v", status, out) |
| 449 | } |
| 450 | if strings.Contains(str(t, out, "error"), "private") { |
| 451 | t.Fatal("approval error leaked backend details") |
| 452 | } |
| 453 | untouched(t, fake, "unreadable approval") |
| 454 | } |
| 455 | |