store: an approved account gets its bucket from create alone
Stage 5 of #381, and the stage where behaviour changes. Everything before it was dormant. carlos store create becomes two doors rather than one. It declares, as it always did; then, if the account is approved and the deployment has a backend, it provisions in the same breath and the member never learns the word "grant". An unapproved account gets exactly what it got before — a declaration, recorded and waiting — but is now told that applying is a thing it can do, rather than being left to discover four days later that a person had to act. The grant block records WHY it exists: an account-approval grant carries the approving operator as its actor and account-approval as its source, so the record answers "who decided this" for both roads into it. The per-app operator grant verb is untouched, for unapproved accounts, for --bucket adoption, and for repair. The provision path rereads the entitlement after taking the per-app walk mutex, so an approval revoked while a provision queued behind another one denies rather than proceeding on a decision that no longer holds. A grant written but not yet provisioned still runs to completion — that was the first blocker of the 2026-09-03 review, and the regression test for it is here. Built by codex (gpt-6-astra) from the brief committed at 7c93be2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CfUNFHm89j2mj5q7vBHsfH
8 files changed,
+724
−44
apimode.go+39 −8internal/console/apistore.go+29 −24internal/console/apistore_provision.go+76 −0internal/console/apistore_provision_test.go+454 −0internal/console/apistore_test.go+7 −8internal/console/authcli.go+1 −0store.go+21 −4store_test.go+97 −0
diff --git a/apimode.go b/apimode.go| index 59556c81b..e346114ac 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -36,6 +36,7 @@ import ( |
| "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/caps" |
| "github.com/carlosframework/platform/internal/ledger" |
| + "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/schedule" |
| ) |
| @@ -1532,6 +1533,7 @@ type apiStoreGrantBlock struct { |
| Bucket string `json:"bucket,omitempty"` // the resolved bucket (minted or adopted; the record does not distinguish) |
| At time.Time `json:"at"` |
| By string `json:"by"` |
| + Source string `json:"source"` |
| } |
| type apiStoreProvisioned struct { |
| @@ -1546,16 +1548,19 @@ type apiStoreProvisioned struct { |
| } |
| type apiStoreReply struct { |
| - App string `json:"app"` |
| - Backend string `json:"backend"` |
| - Declared *apiStoreDeclared `json:"declared"` |
| - Grant *apiStoreGrantBlock `json:"grant"` |
| - Provisioned *apiStoreProvisioned `json:"provisioned"` |
| - EnvNames []string `json:"env_names"` |
| - PendingFinish bool `json:"pending_finish"` |
| - MissingVars []string `json:"missing_vars"` |
| + App string `json:"app"` |
| + Backend string `json:"backend"` |
| + Declared *apiStoreDeclared `json:"declared"` |
| + Grant *apiStoreGrantBlock `json:"grant"` |
| + Provisioned *apiStoreProvisioned `json:"provisioned"` |
| + EnvNames []string `json:"env_names"` |
| + PendingFinish bool `json:"pending_finish"` |
| + MissingVars []string `json:"missing_vars"` |
| + Entitlement *objstore.Entitlement `json:"entitlement"` |
| } |
| +const storeProvisionHTTPTimeout = 75 * time.Second |
| + |
| // storeCreate asks the console to declare (or re-declare) a store for app, |
| // under envPrefix ("" takes the console's own default — objstore. |
| // DefaultEnvPrefix). Mirrors restart's shape (apimode.go:438): postJSON, a |
| @@ -1583,6 +1588,32 @@ func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, envPrefix |
| return out, nil |
| } |
| +// storeProvision runs the account-approved provisioning walk. The server may |
| +// spend its full 60-second storeGrantTimeout on backend work, so this one JSON |
| +// door gets a scoped client with a slightly longer deadline; mutating the |
| +// session's ordinary 30-second client would silently widen every later call. |
| +func (s *apiSession) storeProvision(ctx context.Context, a apiAcct, app string) (apiStoreReply, error) { |
| + clone := *s |
| + client := *s.c |
| + httpc := *s.c.httpc |
| + httpc.Timeout = storeProvisionHTTPTimeout |
| + client.httpc = &httpc |
| + clone.c = &client |
| + |
| + status, body, err := clone.postJSON(ctx, apiPath(a, app, "/store/provision"), struct{}{}) |
| + if err != nil { |
| + return apiStoreReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiStoreReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiStoreReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiStoreReply{}, fmt.Errorf("store create: unreadable provision response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| // storeGet asks the console for app's store record (`carlos store status`). |
| // A 404 here is a real diagnosis, not the byte-identical membership 404 |
| // apiAccountApp gives — handleAPIStoreGet answers it with an actual |
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| index b218cb409..eb222bf10 100644 |
| --- a/internal/console/apistore.go |
| +++ b/internal/console/apistore.go |
| @@ -8,10 +8,9 @@ |
| // THREE AUTHORS, TWO AUTHORITIES. A store record has three blocks for three |
| // actors (spec §4): the member DECLARES, a deployment operator GRANTS, and |
| // the console's own walk records what it PROVISIONED. Declaring and rotating |
| -// are MEMBER verbs — they ask for, or cycle, an estate that costs the |
| -// deployment nothing new — and they need a bearer token and nothing else. |
| -// Granting is the one verb that spends money in the DEPLOYMENT's own cloud |
| -// account, so it needs an address on CARLOS_STORE_OPERATORS as well |
| +// are member verbs. Provisioning also admits members once a deployment |
| +// operator has approved their account (apistore_provision.go). |
| +// Per-app granting needs an address on CARLOS_STORE_OPERATORS as well |
| // (Config.StoreOperators). That check runs AFTER the member gate on purpose: |
| // a 403 handed to a stranger would confirm which accounts and apps exist. |
| // This is the deployment's first explicit operator-identity gate (spec §3). |
| @@ -111,6 +110,10 @@ func storeEffectivePrefix(rec *objstore.Record) string { |
| // apps domain: a deployment-wide fact about the platform, carrying nothing |
| // about any tenant, and every member of it can already see the consequences. |
| func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) { |
| + entitlement, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) |
| + if err != nil { |
| + return nil, err |
| + } |
| prefix := storeEffectivePrefix(rec) |
| wantEndpoint := rec.Provisioned != nil && rec.Provisioned.Endpoint != "" |
| bucket, region, endpoint, accessKey, secretKey := objstore.EnvNames(prefix) |
| @@ -129,6 +132,7 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec |
| } |
| return map[string]any{ |
| "app": app, |
| + "entitlement": entitlement, |
| "backend": s.cfg.StoreDeployment.Backend, |
| "declared": rec.Declared, |
| "grant": rec.Grant, |
| @@ -173,10 +177,9 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec |
| // request here — not a zero-length body, which decodes as malformed like |
| // anywhere else. |
| // |
| -// IDEMPOTENT by design: re-declaring the same prefix refreshes `at`/`by` and |
| -// answers 200. `carlos store create` is a thing people re-run — after a failed |
| -// grant, from a second terminal, from a deploy script — and turning the second |
| -// run into an error would make the safe verb the scary one. |
| +// Re-declaring the same prefix preserves the author, timestamp and etag. |
| +// A no-op must not make an in-flight provision lose its record write. Members |
| +// re-run create after a failed walk, from second terminals and deploy scripts. |
| func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { |
| addr, acct, app, ok := s.apiAccountApp(w, r) |
| if !ok { |
| @@ -254,24 +257,26 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { |
| "store already provisioned — the env prefix is fixed; destroy and re-create to change it") |
| return |
| } |
| - rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: addr} |
| - if _, err := objstore.Put(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil { |
| - if errors.Is(err, release.ErrCASConflict) { |
| - // Someone else wrote this record between our read and our write — |
| - // plausibly the operator's grant landing. Re-read and re-ask; this |
| - // door deliberately does not retry, because the thing it would be |
| - // overwriting is the half it doesn't own. |
| - jsonError(w, http.StatusConflict, "the store record changed underneath you — try again") |
| + if rec.Declared == nil || storeEffectivePrefix(rec) != prefix { |
| + rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: addr} |
| + if _, err := objstore.Put(ctx, s.cfg.Releases, acct.Sqid, app, rec, etag); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + // Someone else wrote this record between our read and our write — |
| + // plausibly the operator's grant landing. Re-read and re-ask; this |
| + // door deliberately does not retry, because the thing it would be |
| + // overwriting is the half it doesn't own. |
| + jsonError(w, http.StatusConflict, "the store record changed underneath you — try again") |
| + return |
| + } |
| + log.Printf("console: api store create %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not record the store — nothing was declared; try again") |
| return |
| } |
| - log.Printf("console: api store create %s/%s: %v", acct.Sqid, app, err) |
| - jsonError(w, http.StatusBadGateway, "could not record the store — nothing was declared; try again") |
| - return |
| + // Audited, unlike a read: this is the request an operator will act on by |
| + // spending money in the deployment's own cloud account, so who asked and |
| + // under what prefix has to be visible to anyone reading rows. |
| + s.audit(acct, app, addr, "store-create", "declared a store with env prefix "+prefix+" (cli)") |
| } |
| - // Audited, unlike a read: this is the request an operator will act on by |
| - // spending money in the deployment's own cloud account, so who asked and |
| - // under what prefix has to be visible to anyone reading rows. |
| - s.audit(acct, app, addr, "store-create", "declared a store with env prefix "+prefix+" (cli)") |
| // No bumpSerial: nothing a box reads has changed. See the file comment. |
| out, err := s.storeReply(ctx, acct, app, rec) |
| if err != nil { |
| @@ -323,7 +328,7 @@ func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) { |
| out, err := s.storeReply(ctx, acct, app, rec) |
| if err != nil { |
| log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, err) |
| - jsonError(w, http.StatusBadGateway, "could not read the store's config delivery — try again") |
| + jsonError(w, http.StatusBadGateway, "could not read the store's status — try again") |
| return |
| } |
| writeJSON(w, http.StatusOK, out) |
diff --git a/internal/console/apistore_provision.go b/internal/console/apistore_provision.go| new file mode 100644 |
| index 000000000..f444b2055 |
| --- /dev/null |
| +++ b/internal/console/apistore_provision.go |
| @@ -0,0 +1,76 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| +) |
| + |
| +// Provision spends the account approval on one app. Grant owns retry and |
| +// repair: a grant block without provisioning must still run the walk. |
| +func (s *Server) handleAPIStoreProvision(w http.ResponseWriter, r *http.Request) { |
| + actor, acct, app, ok := s.apiAccountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + storeNoBucket(w) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout) |
| + defer cancel() |
| + if _, ok := s.storeProvisionApproval(ctx, w, acct); !ok { |
| + return |
| + } |
| + p, _ := principalFrom(r.Context()) |
| + if !s.storeWalkGates(w, p) { |
| + return |
| + } |
| + pub, ok := s.storeSealKey(w) |
| + if !ok { |
| + return |
| + } |
| + var req struct{} |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + defer s.storeWalks.lock(acct.Sqid, app)() |
| + // A queued request may have waited while an operator revoked approval. |
| + // An already-running walk completes; decline does not tear down estate. |
| + e, ok := s.storeProvisionApproval(ctx, w, acct) |
| + if !ok { |
| + return |
| + } |
| + rec, err := objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, |
| + pub, acct.Sqid, app, e.Decision.By, objstore.GrantSourceAccountApproval, "", time.Now()) |
| + switch { |
| + case errors.Is(err, objstore.ErrNotDeclared): |
| + jsonError(w, http.StatusConflict, "no store is declared for this app — run `carlos store create` first") |
| + return |
| + case err != nil: |
| + log.Printf("console: api store provision %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not provision the store — re-run `carlos store create` to resume; after destroying a store, its bucket name may take time to become available again") |
| + return |
| + } |
| + s.audit(acct, app, actor, "store-provision", "provisioned a store in bucket "+storeBucketName(rec)+" (cli)") |
| + s.bumpSerial(ctx) |
| + s.storeWalkReply(ctx, w, acct, app, rec) |
| +} |
| + |
| +func (s *Server) storeProvisionApproval(ctx context.Context, w http.ResponseWriter, acct *Account) (*objstore.Entitlement, bool) { |
| + e, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) |
| + if err != nil { |
| + log.Printf("console: api store provision %s: read entitlement: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the storage application — try again") |
| + return nil, false |
| + } |
| + if !e.Approved() { |
| + jsonError(w, http.StatusForbidden, "this account is not approved for object storage — `carlos store apply`") |
| + return nil, false |
| + } |
| + return e, true |
| +} |
diff --git a/internal/console/apistore_provision_test.go b/internal/console/apistore_provision_test.go| new file mode 100644 |
| index 000000000..42b80ddce |
| --- /dev/null |
| +++ b/internal/console/apistore_provision_test.go |
| @@ -0,0 +1,454 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "net/http" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/svcrole" |
| +) |
| + |
| +func storeProvisionURL(fx *consoleFixture, app string) string { |
| + return storeURL(fx, app) + "/provision" |
| +} |
| + |
| +func approveStoreAccount(t *testing.T, fx *consoleFixture, by string) { |
| + t.Helper() |
| + putEntitlement(t, fx, fx.sqid, &objstore.Entitlement{ |
| + Applied: &objstore.Applied{By: "paul@keymail.dev", At: time.Now().Add(-time.Minute)}, |
| + Decision: &objstore.Decision{Approved: true, By: by, At: time.Now()}, |
| + }) |
| +} |
| + |
| +type pausedBucketProvisioner struct { |
| + objstore.Provisioner |
| + entered chan struct{} |
| + release chan struct{} |
| + once sync.Once |
| +} |
| + |
| +func (p *pausedBucketProvisioner) EnsureBucket(ctx context.Context, name string) (string, error) { |
| + p.once.Do(func() { |
| + close(p.entered) |
| + select { |
| + case <-p.release: |
| + case <-ctx.Done(): |
| + } |
| + }) |
| + return p.Provisioner.EnsureBucket(ctx, name) |
| +} |
| + |
| +type provisionResult struct { |
| + status int |
| + out map[string]any |
| +} |
| + |
| +func provisionAsync(t *testing.T, fx *consoleFixture, token string) <-chan provisionResult { |
| + t.Helper() |
| + ch := make(chan provisionResult, 1) |
| + go func() { |
| + defer close(ch) |
| + status, raw := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| + out := map[string]any{} |
| + if err := json.Unmarshal([]byte(raw), &out); err != nil { |
| + out["raw"] = raw |
| + } |
| + ch <- provisionResult{status: status, out: out} |
| + }() |
| + return ch |
| +} |
| + |
| +func waitPaused(t *testing.T, p *pausedBucketProvisioner) { |
| + t.Helper() |
| + select { |
| + case <-p.entered: |
| + case <-time.After(3 * time.Second): |
| + t.Fatal("provision did not reach the paused backend") |
| + } |
| +} |
| + |
| +func TestAPIStoreProvisionNeedsAnApprovedEntitlement(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-unapproved") |
| + declareStore(t, fx, token) |
| + |
| + status, _ := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| + if status < 400 { |
| + t.Fatalf("provision without approval = %d, want a refusal", status) |
| + } |
| + untouched(t, fake, "an unapproved provision") |
| + if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil { |
| + t.Fatalf("an unapproved provision wrote a grant: %+v", rec.Grant) |
| + } |
| +} |
| + |
| +func TestAPIStoreProvisionWithoutABackendDoesNotGrant(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-nobackend") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + fx.srv.cfg.StoreProvisioner = nil |
| + |
| + status, _ := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("provision without a backend = %d, want 503", status) |
| + } |
| + untouched(t, fake, "a backend-less provision") |
| + if rec := storedRecord(t, fx, "titogo"); rec.Grant != nil { |
| + t.Fatalf("a backend-less provision wrote a grant: %+v", rec.Grant) |
| + } |
| +} |
| + |
| +func TestAPIStoreProvisionAttributesAccountApproval(t *testing.T) { |
| + fx, fake, token, priv := storeArmed(t, "code-store-provision-happy") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + fx.srv.cfg.StoreOperators = nil |
| + |
| + status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("provision = %d %v, want 200", status, out) |
| + } |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec.Provisioned == nil || rec.Grant == nil { |
| + t.Fatalf("provisioned record = %+v", rec) |
| + } |
| + if rec.Grant.Source != objstore.GrantSourceAccountApproval || rec.Grant.By != "operator@keymail.dev" { |
| + t.Fatalf("grant = %+v, want the account approval source and deciding operator", rec.Grant) |
| + } |
| + u := fake.Users[rec.Provisioned.IAMUser] |
| + secret, ok := u.Keys[rec.Provisioned.KeyID] |
| + if !ok { |
| + t.Fatalf("recorded key %q is absent from backend keys %v", rec.Provisioned.KeyID, u.Keys) |
| + } |
| + if got := openSecret(t, priv, appVars(t, fx, "titogo")["CARLOS_STORE_SECRET_KEY"]); got != secret { |
| + t.Fatalf("delivered secret does not match the recorded backend key") |
| + } |
| + if entitlementBlock(t, out)["decision"] == nil { |
| + t.Fatalf("provision reply omitted the entitlement: %v", out) |
| + } |
| +} |
| + |
| +func TestAPIStoreProvisionCompletedStoreIsANoOp(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-noop") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("first provision = %d %v", status, out) |
| + } |
| + before := storedRecord(t, fx, "titogo").Provisioned.KeyID |
| + if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("second provision = %d %v", status, out) |
| + } |
| + after := storedRecord(t, fx, "titogo").Provisioned.KeyID |
| + if after != before || len(fake.Users["carlos-store-bab-titogo"].Keys) != 1 { |
| + t.Fatalf("no-op changed key %q to %q or minted extras: %v", before, after, fake.Users) |
| + } |
| +} |
| + |
| +func TestAPIStoreProvisionRetriesAFailedGrantWithoutChangingBucket(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-retry") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + fake.FailNext["EnsureBucket"] = errors.New("backend detail must stay private") |
| + |
| + status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("failed provision = %d %v, want 502", status, out) |
| + } |
| + if strings.Contains(str(t, out, "error"), "backend detail") { |
| + t.Fatalf("provision response leaked the backend failure: %v", out) |
| + } |
| + failed := storedRecord(t, fx, "titogo") |
| + if failed.Grant == nil || failed.Provisioned != nil { |
| + t.Fatalf("failed provision record = %+v, want grant-only", failed) |
| + } |
| + bucket := failed.Grant.Bucket |
| + if status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK { |
| + t.Fatalf("retry provision = %d %v, want 200", status, out) |
| + } |
| + got := storedRecord(t, fx, "titogo") |
| + if got.Provisioned == nil || got.Provisioned.Bucket != bucket || got.Grant.Bucket != bucket { |
| + t.Fatalf("retry changed or failed to provision bucket %q: %+v", bucket, got) |
| + } |
| +} |
| + |
| +func TestAPIStoreProvisionSerializesAndRereadsEntitlement(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-revoke") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})} |
| + fx.srv.cfg.StoreProvisioner = paused |
| + defer func() { |
| + select { |
| + case <-paused.release: |
| + default: |
| + close(paused.release) |
| + } |
| + }() |
| + |
| + first := provisionAsync(t, fx, token) |
| + waitPaused(t, paused) |
| + second := provisionAsync(t, fx, token) |
| + waitStoreProvisionQueued(t, fx) |
| + e, etag, err := objstore.GetEntitlement(context.Background(), fx.rel, fx.sqid) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.Decision = &objstore.Decision{Approved: false, By: "operator@keymail.dev", At: time.Now(), Reason: "capacity review"} |
| + if _, err := objstore.PutEntitlement(context.Background(), fx.rel, fx.sqid, e, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + close(paused.release) |
| + if got := <-first; got.status != http.StatusOK { |
| + t.Fatalf("first provision = %d %v, want 200", got.status, got.out) |
| + } |
| + if got := <-second; got.status < 400 { |
| + t.Fatalf("queued provision after revocation = %d %v, want a refusal", got.status, got.out) |
| + } |
| + status, out := getJSON(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK || block(t, entitlementBlock(t, out), "decision")["approved"] != false { |
| + t.Fatalf("declined status = %d %v", status, out) |
| + } |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec.Provisioned == nil || len(fake.Users[rec.Provisioned.IAMUser].Keys) != 1 { |
| + t.Fatalf("serialized provisions left the wrong estate: record=%+v users=%v", rec, fake.Users) |
| + } |
| +} |
| + |
| +func TestAPIStoreDeclareSamePrefixDuringProvisionIsNoOp(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-declare-race") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})} |
| + fx.srv.cfg.StoreProvisioner = paused |
| + defer func() { |
| + select { |
| + case <-paused.release: |
| + default: |
| + close(paused.release) |
| + } |
| + }() |
| + result := provisionAsync(t, fx, token) |
| + waitPaused(t, paused) |
| + |
| + before, beforeETag, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("same-prefix declare = %d %v, want 200", status, out) |
| + } |
| + after, afterETag, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, "titogo") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if afterETag != beforeETag || after.Declared.By != before.Declared.By || !after.Declared.At.Equal(before.Declared.At) { |
| + t.Fatalf("same-prefix declare changed record: etag %q -> %q, declared %+v -> %+v", beforeETag, afterETag, before.Declared, after.Declared) |
| + } |
| + close(paused.release) |
| + if got := <-result; got.status != http.StatusOK { |
| + t.Fatalf("provision after no-op declare = %d %v, want 200", got.status, got.out) |
| + } |
| +} |
| + |
| +func TestAPIStoreAdminServiceDeclarationKeepsServiceAttribution(t *testing.T) { |
| + fx, _, _, _ := storeArmed(t, "code-store-provision-service") |
| + admin := serviceToken(t, fx.st, OpsAccountID, "storebot", "", svcrole.Admin) |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, admin) |
| + if status != http.StatusOK { |
| + t.Fatalf("admin service declare = %d %v, want 200", status, out) |
| + } |
| + if got := storedRecord(t, fx, "titogo").Declared.By; got != "service:bab/storebot" { |
| + t.Fatalf("declared.by = %q, want service attribution", got) |
| + } |
| +} |
| + |
| +func waitStoreProvisionQueued(t *testing.T, fx *consoleFixture) { |
| + t.Helper() |
| + deadline := time.Now().Add(3 * time.Second) |
| + for { |
| + fx.srv.storeWalks.mu.Lock() |
| + e := fx.srv.storeWalks.m[fx.sqid+"/titogo"] |
| + refs := 0 |
| + if e != nil { |
| + refs = e.refs |
| + } |
| + fx.srv.storeWalks.mu.Unlock() |
| + if refs == 2 { |
| + return |
| + } |
| + if time.Now().After(deadline) { |
| + t.Fatalf("second provision never queued: refs=%d", refs) |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| +} |
| + |
| +func TestAPIStoreConcurrentProvisionsMintOneDeliveredKey(t *testing.T) { |
| + fx, fake, token, priv := storeArmed(t, "code-store-provision-two") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + paused := &pausedBucketProvisioner{Provisioner: fake, entered: make(chan struct{}), release: make(chan struct{})} |
| + fx.srv.cfg.StoreProvisioner = paused |
| + defer func() { |
| + select { |
| + case <-paused.release: |
| + default: |
| + close(paused.release) |
| + } |
| + }() |
| + first := provisionAsync(t, fx, token) |
| + waitPaused(t, paused) |
| + second := provisionAsync(t, fx, token) |
| + waitStoreProvisionQueued(t, fx) |
| + close(paused.release) |
| + for _, result := range []<-chan provisionResult{first, second} { |
| + select { |
| + case got := <-result: |
| + if got.status != http.StatusOK { |
| + t.Fatalf("provision = %d %v", got.status, got.out) |
| + } |
| + case <-time.After(3 * time.Second): |
| + t.Fatal("provision did not finish") |
| + } |
| + } |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec.Provisioned == nil { |
| + t.Fatal("store was not provisioned") |
| + } |
| + keys := fake.Users[rec.Provisioned.IAMUser].Keys |
| + if rec.Provisioned.KeyID != "MEMKEY1" || len(keys) != 1 { |
| + t.Fatalf("wanted exactly one mint: record=%+v keys=%v", rec.Provisioned, keys) |
| + } |
| + vars := appVars(t, fx, "titogo") |
| + if openSecret(t, priv, vars["CARLOS_STORE_ACCESS_KEY"]) != rec.Provisioned.KeyID { |
| + t.Fatal("delivered key differs from record") |
| + } |
| + if openSecret(t, priv, vars["CARLOS_STORE_SECRET_KEY"]) != keys[rec.Provisioned.KeyID] { |
| + t.Fatal("delivered secret differs from backend") |
| + } |
| +} |
| + |
| +func TestAPIStoreDeclareReportsEntitlementWithoutProvisioning(t *testing.T) { |
| + for _, approved := range []bool{false, true} { |
| + for _, backend := range []bool{false, true} { |
| + t.Run(fmt.Sprintf("approved=%t/backend=%t", approved, backend), func(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-declare-entitlement") |
| + if approved { |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + } |
| + if !backend { |
| + fx.srv.cfg.StoreProvisioner = nil |
| + fx.srv.cfg.StoreDeployment = objstore.Deployment{} |
| + } |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("declare = %d %v", status, out) |
| + } |
| + if _, present := out["entitlement"]; !present { |
| + t.Fatal("reply omitted entitlement") |
| + } |
| + if approved { |
| + if !block(t, entitlementBlock(t, out), "decision")["approved"].(bool) { |
| + t.Fatal("approval missing") |
| + } |
| + } else if out["entitlement"] != nil { |
| + t.Fatalf("unexpected entitlement: %v", out) |
| + } |
| + if (out["backend"] != "") != backend { |
| + t.Fatalf("backend mismatch: %v", out) |
| + } |
| + if out["grant"] != nil || out["provisioned"] != nil { |
| + t.Fatalf("declare minted estate: %v", out) |
| + } |
| + untouched(t, fake, "declare") |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestAPIStoreProvisionMemberAndServiceGates(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-gates") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + stranger := cliToken(t, fx, "stranger@keymail.dev", "code-store-provision-stranger") |
| + status, body := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, stranger) |
| + if status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("stranger = %d %q", status, body) |
| + } |
| + for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance, svcrole.Operate} { |
| + svc := serviceToken(t, fx.st, OpsAccountID, "denied-"+string(role), "", role) |
| + status, _ := postRaw(t, storeProvisionURL(fx, "titogo"), map[string]any{}, svc) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("service %s = %d", role, status) |
| + } |
| + } |
| + untouched(t, fake, "refused provision") |
| + admin := serviceToken(t, fx.st, OpsAccountID, "store-admin", "titogo", svcrole.Admin) |
| + status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, admin) |
| + if status != http.StatusOK { |
| + t.Fatalf("admin provision = %d %v", status, out) |
| + } |
| + if storedRecord(t, fx, "titogo").Grant.By != "operator@keymail.dev" { |
| + t.Fatal("service replaced approving actor") |
| + } |
| + auditedBy(t, fx, "titogo", "store-provision", "service:bab/store-admin", "provisioned") |
| +} |
| + |
| +func TestAPIStoreProvisionRequiresDeclarationAndSealKey(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-preconditions") |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("undeclared = %d %v", status, out) |
| + } |
| + declareStore(t, fx, token) |
| + fx.srv.cfg.SecretsPub = "" |
| + status, out = postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("unsealable = %d %v", status, out) |
| + } |
| + untouched(t, fake, "unsealable provision") |
| +} |
| + |
| +func TestAPIStoreProvisionCannotAdoptABucket(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-no-adoption") |
| + declareStore(t, fx, token) |
| + approveStoreAccount(t, fx, "operator@keymail.dev") |
| + status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{"bucket": "someone-elses-data"}, token) |
| + if status != http.StatusOK && status != http.StatusBadRequest { |
| + t.Fatalf("provision with adoption input = %d %v", status, out) |
| + } |
| + if _, exists := fake.Buckets["someone-elses-data"]; exists { |
| + t.Fatal("member provision adopted caller bucket") |
| + } |
| + rec := storedRecord(t, fx, "titogo") |
| + if rec.Grant != nil && rec.Grant.Bucket != objstore.MintedBucket(fx.srv.cfg.StoreDeployment, fx.sqid, "titogo") { |
| + t.Fatalf("grant chose caller bucket: %+v", rec.Grant) |
| + } |
| +} |
| + |
| +func TestAPIStoreProvisionFailsClosedOnUnreadableApproval(t *testing.T) { |
| + fx, fake, token, _ := storeArmed(t, "code-store-provision-unreadable") |
| + declareStore(t, fx, token) |
| + if _, err := fx.rel.Put(context.Background(), objstore.EntitlementKey(fx.sqid), []byte("private backend details"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, out := postJSON(t, storeProvisionURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusBadGateway { |
| + t.Fatalf("unreadable approval = %d %v", status, out) |
| + } |
| + if strings.Contains(str(t, out, "error"), "private") { |
| + t.Fatal("approval error leaked backend details") |
| + } |
| + untouched(t, fake, "unreadable approval") |
| +} |
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go| index bcca27b2f..02c128e6e 100644 |
| --- a/internal/console/apistore_test.go |
| +++ b/internal/console/apistore_test.go |
| @@ -157,9 +157,8 @@ func TestAPIStoreDeclareWritesTheRecord(t *testing.T) { |
| } |
| // TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent: a chosen prefix renames |
| -// every delivered variable, and re-declaring the SAME prefix is a refresh, not |
| -// a conflict — `carlos store create` has to be safe to run twice, including by |
| -// a second member of the same account. |
| +// every delivered variable, and re-declaring the same prefix preserves the |
| +// original declaration. A second member can safely run create again. |
| func TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent(t *testing.T) { |
| fx, token := storeFixture(t, "code-store-prefix") |
| @@ -184,9 +183,9 @@ func TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent(t *testing.T) { |
| if rec == nil || rec.Declared == nil || rec.Declared.EnvPrefix != "BLOBS" { |
| t.Fatalf("record after re-declare = %+v", rec) |
| } |
| - // Refreshed, not merely tolerated: the trail says who asked most recently. |
| - if rec.Declared.By != "bob@keymail.dev" { |
| - t.Fatalf("declared.by = %q, want the second caller", rec.Declared.By) |
| + // A retry preserves the original author and does not race a grant write. |
| + if rec.Declared.By != "alice@keymail.dev" { |
| + t.Fatalf("declared.by = %q, want the original caller", rec.Declared.By) |
| } |
| } |
| @@ -263,7 +262,7 @@ func TestAPIStorePrefixIsFixedOnceGranted(t *testing.T) { |
| if rec := storedRecord(t, fx, "titogo"); rec.Declared.EnvPrefix != "BLOBS" { |
| t.Fatalf("a refused re-declare changed the prefix to %q", rec.Declared.EnvPrefix) |
| } |
| - // The same prefix still refreshes: a re-run of `carlos store create` must |
| + // The same prefix remains a no-op: a re-run of `carlos store create` must |
| // not turn into an error the moment an operator picks the request up. |
| if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token); status != http.StatusOK { |
| t.Fatalf("same-prefix re-declare mid-grant = %d %v, want 200", status, out) |
| @@ -301,7 +300,7 @@ func TestAPIStorePrefixIsFixedOnceProvisioned(t *testing.T) { |
| if status != http.StatusOK { |
| t.Fatalf("same-prefix re-declare on a provisioned store = %d %v, want 200", status, out) |
| } |
| - // The provisioning survives the refresh — the reply echoes the whole |
| + // The provisioning survives the no-op — the reply echoes the whole |
| // record, not just the half this door writes. |
| if got := block(t, out, "provisioned")["bucket"]; got != "carlos-bab-titogo" { |
| t.Fatalf("provisioned.bucket = %v after a re-declare", got) |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 03af570e0..7f95c1338 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -699,6 +699,7 @@ func (s *Server) registerCLIRoutes() { |
| // .../store, never {wildcards}, so no app or host name can reach them. |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreCreate) |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/store", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGet) |
| + s.cliRoute("POST /api/cli/apps/{account}/{app}/store/provision", svcrole.Allow(svcrole.Admin), s.handleAPIStoreProvision) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/store/grant", svcrole.Allow(svcrole.Admin), s.handleAPIStoreGrant) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotate) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/store/rotate/finish", svcrole.Allow(svcrole.Admin), s.handleAPIStoreRotateFinish) |
diff --git a/store.go b/store.go| index 8a7dfe5a0..ef141f7f9 100644 |
| --- a/store.go |
| +++ b/store.go |
| @@ -147,11 +147,26 @@ func runStoreCreate(args []string) { |
| printUsing(stdout, sess) |
| acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| name = resolvedApp(sess, acct, name, usage) |
| - res, err := sess.storeCreate(context.Background(), acct, name, strings.TrimSpace(*envPrefix)) |
| - if err != nil { |
| + if err := storeCreateAndProvision(context.Background(), sess, acct, name, strings.TrimSpace(*envPrefix), stdout); err != nil { |
| log.Fatalf("store create: %v", err) |
| } |
| - printStoreCreate(stdout, res) |
| +} |
| + |
| +func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct, app, envPrefix string, out io.Writer) error { |
| + res, err := sess.storeCreate(ctx, acct, app, envPrefix) |
| + if err != nil { |
| + return err |
| + } |
| + printStoreCreate(out, res) |
| + if res.Backend == "" || !res.Entitlement.Approved() { |
| + return nil |
| + } |
| + res, err = sess.storeProvision(ctx, acct, app) |
| + if err != nil { |
| + return err |
| + } |
| + printStoreGrant(out, res) |
| + return nil |
| } |
| func runStoreStatus(args []string) { |
| @@ -258,7 +273,9 @@ func printStoreCreate(w io.Writer, res apiStoreReply) { |
| } |
| fmt.Fprintf(w, "%s %s under prefix %s — delivers %s\n", |
| c.ok("declared a store for"), res.App, prefix, strings.Join(res.EnvNames, ", ")) |
| - mutedf(w, "nothing is provisioned until a deployment operator grants it (carlos store grant --app %s)", res.App) |
| + if !res.Entitlement.Approved() { |
| + mutedf(w, "nothing is provisioned until a deployment operator grants it (carlos store grant --app %s)", res.App) |
| + } |
| if res.Backend == "" { |
| warnf(w, "this deployment has no object storage backend configured yet (the operator sets "+ |
| "CARLOS_STORE_BACKEND) — the declaration will simply wait until one is") |
diff --git a/store_test.go b/store_test.go| index b7b2decb0..05c21a354 100644 |
| --- a/store_test.go |
| +++ b/store_test.go |
| @@ -16,8 +16,21 @@ import ( |
| "os/exec" |
| "strings" |
| "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| ) |
| +func approvedEntitlement() *objstore.Entitlement { |
| + return &objstore.Entitlement{ |
| + Schema: objstore.EntitlementSchema, |
| + Account: "bac", |
| + Applied: &objstore.Applied{By: "owner@example.com", At: time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)}, |
| + Decision: &objstore.Decision{Approved: true, By: "operator@example.com", |
| + At: time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)}, |
| + } |
| +} |
| + |
| // ---- apiSession.store* wire shape ------------------------------------------- |
| // TestAPISessionStoreCreateWireShape pins handleAPIStoreCreate's contract: a |
| @@ -98,6 +111,90 @@ func TestAPISessionStoreCreateSendsChosenPrefix(t *testing.T) { |
| } |
| } |
| +func TestStoreCreateAndProvisionPrintsDeclarationBeforeProvisionFailure(t *testing.T) { |
| + var paths []string |
| + var out bytes.Buffer |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + paths = append(paths, r.URL.Path) |
| + switch r.URL.Path { |
| + case "/api/cli/apps/bac/keymail/store": |
| + json.NewEncoder(w).Encode(apiStoreReply{ |
| + App: "keymail", Backend: "aws", Entitlement: approvedEntitlement(), |
| + Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE", By: "owner@example.com"}, |
| + EnvNames: []string{"CARLOS_STORE_BUCKET"}, |
| + }) |
| + case "/api/cli/apps/bac/keymail/store/provision": |
| + if !strings.Contains(out.String(), "declared a store for keymail") { |
| + t.Errorf("provision began before declaration was printed: %q", out.String()) |
| + } |
| + w.WriteHeader(http.StatusBadGateway) |
| + json.NewEncoder(w).Encode(map[string]string{"error": "object storage provisioning failed — retry store create; a recently destroyed bucket name may still be unavailable"}) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| + })) |
| + defer ts.Close() |
| + |
| + err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", &out) |
| + if err == nil || !strings.Contains(err.Error(), "provisioning failed") { |
| + t.Fatalf("error = %v, want provision failure", err) |
| + } |
| + if got := strings.Join(paths, ","); got != "/api/cli/apps/bac/keymail/store,/api/cli/apps/bac/keymail/store/provision" { |
| + t.Fatalf("paths = %q, want declare then provision", got) |
| + } |
| +} |
| + |
| +func TestStoreCreateAndProvisionBranchesOnReply(t *testing.T) { |
| + tests := []struct { |
| + name string |
| + backend string |
| + entitlement *objstore.Entitlement |
| + wantCalls int |
| + }{ |
| + {name: "approved with backend", backend: "aws", entitlement: approvedEntitlement(), wantCalls: 2}, |
| + {name: "approved without backend", entitlement: approvedEntitlement(), wantCalls: 1}, |
| + {name: "not applied", backend: "aws", wantCalls: 1}, |
| + {name: "pending", backend: "aws", entitlement: &objstore.Entitlement{Applied: &objstore.Applied{}}, wantCalls: 1}, |
| + {name: "declined", backend: "aws", entitlement: &objstore.Entitlement{Applied: &objstore.Applied{}, Decision: &objstore.Decision{Approved: false}}, wantCalls: 1}, |
| + } |
| + for _, tt := range tests { |
| + t.Run(tt.name, func(t *testing.T) { |
| + calls := 0 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + calls++ |
| + if strings.HasSuffix(r.URL.Path, "/provision") { |
| + json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Provisioned: &apiStoreProvisioned{Bucket: "b", Region: "eu-west-1", IAMUser: "u"}}) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(apiStoreReply{App: "keymail", Backend: tt.backend, Entitlement: tt.entitlement, Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}}) |
| + })) |
| + defer ts.Close() |
| + if err := storeCreateAndProvision(context.Background(), newTestSession(ts.URL), apiAcct{Seg: "bac"}, "keymail", "", new(bytes.Buffer)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if calls != tt.wantCalls { |
| + t.Fatalf("calls = %d, want %d", calls, tt.wantCalls) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestStoreProvisionUsesLongWalkClient(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + time.Sleep(40 * time.Millisecond) |
| + json.NewEncoder(w).Encode(apiStoreReply{App: "keymail"}) |
| + })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + s.c.httpc.Timeout = time.Millisecond |
| + if _, err := s.storeProvision(context.Background(), apiAcct{Seg: "bac"}, "keymail"); err != nil { |
| + t.Fatalf("storeProvision inherited ordinary JSON timeout: %v", err) |
| + } |
| + if s.c.httpc.Timeout != time.Millisecond { |
| + t.Fatalf("ordinary client timeout changed to %s", s.c.httpc.Timeout) |
| + } |
| +} |
| + |
| // TestAPISessionStoreGetWireShape pins handleAPIStoreGet's contract: a GET |
| // to the same .../store path, decoding the full reply including drift and |
| // pending-finish. |