console: email pause door — I1-I4 review fixes
I1: paused decodes as *bool, acted on only when present, so a
cap-raise-only body ({"daily_cap": N}) cannot decode a missing paused as
false and silently resume a tenant an earlier request paused — that would
have contradicted "nothing auto-resumes... resume is a decision"
(mailcounts.go), the rule that makes this door operator-only in the first
place.
I2: MailOperators stays fail-closed on empty, confirmed by test and by a
mutation that flips it to fail-open (caught).
I3: the pause door no longer uses apiAccountApp's member-then-allowlist
shape. It follows economicspass.go's handleAPIEconomicsBill instead:
cliUser -> store.IsOperator -> byte-identical http.NotFound, with the
account resolved afterward via AccountBySqid (viewer-independent, sqid
only, mirroring handleEconomicsTierSave) rather than membership.
Member-then-allowlist deadlocked the abuse brake in the exact scenario it
exists for: only an account's own owner can add a member, and an abusive
tenant has no reason to add the operator investigating them.
CARLOS_MAIL_OPERATORS (mailOperator) narrows the fleet-operator bit
further; the other eight doors are untouched by this — member-level is
still correct for them.
I4: cmd/console/main.go now wires CARLOS_MAIL_OPERATORS and
CARLOS_MAIL_REGION / CARLOS_MAIL_PLATFORM_SUFFIX / CARLOS_MAIL_DMARC_RUA /
CARLOS_MAIL_SNS_TOPIC_ARN into Config.MailOperators/MailDeployment,
following storeOperators()'s pattern. MailProvisioner and DNSWriter remain
unwired (separate infra work), so every email door still 404s in the
shipped binary until that lands — this at least means the operator list
and deployment coordinates don't become a second thing to remember the day
it does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+345
−52
cmd/console/main.go+78 −0internal/console/apimail.go+151 −41internal/console/apimail_test.go+116 −11
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 75131c327..169e3f604 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -48,6 +48,16 @@ |
| // member's own verb and destroy is the account owner's browser ceremony; |
| // it is read whether or not a backend is configured. |
| // |
| +// Tenant email (task 17) reads CARLOS_MAIL_OPERATORS (comma-separated |
| +// addresses; narrows the fleet-operator bit further for the pause/cap |
| +// door only — see mailOperators' doc comment), CARLOS_MAIL_REGION, |
| +// CARLOS_MAIL_PLATFORM_SUFFIX, CARLOS_MAIL_DMARC_RUA and |
| +// CARLOS_MAIL_SNS_TOPIC_ARN. All five are read regardless of whether a |
| +// mail Provisioner is wired (none is, yet — see mailDeployment's doc |
| +// comment): every email door 404s until that infra work lands, same |
| +// "config costs nothing to hold ahead of the backend" reasoning as |
| +// CARLOS_STORE_OPERATORS above. |
| +// |
| // grant/revoke/owners need only CONSOLE_DB. |
| package main |
| @@ -65,6 +75,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -258,6 +269,18 @@ func serve() { |
| // Deliberately independent of PLATFORM_APPS_DOMAIN (#167's blind |
| // spot), so it is read raw here rather than folded into appsDomain(). |
| DelegationZone: os.Getenv("CARLOS_ACME_DELEGATION_ZONE"), |
| + // MailDeployment/MailOperators (tenant-email task 17, review round |
| + // 1 I4): MailProvisioner and DNSWriter are NOT wired here yet — a |
| + // real AWS SES/IAM Provisioner and DNS writer are separate infra |
| + // work this command does not yet do, so every email door still |
| + // 404s in this binary (mailProvisionerGate) regardless of the two |
| + // fields below. Wiring them now anyway is what lets an operator |
| + // set CARLOS_MAIL_OPERATORS ahead of that work landing, same as |
| + // storeOperators' own reasoning, and means the pause door's |
| + // authority list and the mail deployment coordinates do not become |
| + // a SECOND thing to remember to wire the day the Provisioner does. |
| + MailDeployment: mailDeployment(), |
| + MailOperators: mailOperators(), |
| }, st) |
| if err != nil { |
| log.Fatal(err) |
| @@ -450,6 +473,61 @@ func storeOperators() []string { |
| return out |
| } |
| +// mailOperators parses CARLOS_MAIL_OPERATORS (comma-separated addresses) — |
| +// who may drive the email pause/cap door (apimail.go's handleAPIEmailPause |
| +// and mailOperator's own doc comment explain why this is a SEPARATE list |
| +// from the fleet-operator bit console grant -operator sets, not a |
| +// replacement for it: pausing a tenant's shared sending reputation is a |
| +// narrower authority than "can see the fleet bill," and a deployment may |
| +// want to grant the two to different people). |
| +// |
| +// Same shape as storeOperators, for the same reason: unset is fail-closed, |
| +// not fail-open — no addresses means NO fleet operator may drive this |
| +// door, not "any fleet operator may." That is the right direction for a |
| +// list whose purpose is a per-tenant abuse brake, not merely a spending |
| +// gate. |
| +func mailOperators() []string { |
| + var out []string |
| + for _, a := range strings.Split(os.Getenv("CARLOS_MAIL_OPERATORS"), ",") { |
| + if a = strings.TrimSpace(a); a != "" { |
| + out = append(out, a) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// mailDeployment builds the tenant-email half of the console's config from |
| +// CARLOS_MAIL_* (tenant-email spec §7, §9): the region and platform suffix |
| +// every mail walk needs, plus the optional DMARC aggregate-report address |
| +// and SNS topic ARN that feed the bounce/complaint counts mailcounts.go |
| +// enforces caps against. |
| +// |
| +// DELIBERATELY INDEPENDENT OF PLATFORM_APPS_DOMAIN, the same caution |
| +// DelegationZone's own comment above already applies to a different zone |
| +// (#167's blind spot): PLATFORM_APPS_DOMAIN is the edge's routing zone, |
| +// and conflating it with the zone SES sending identities are verified |
| +// under would repeat the exact class of bug #167 already cost this |
| +// codebase once, just for a new pair of variables. |
| +// |
| +// NOTHING HERE IS FATAL, unlike storeBackend's coordinates. A store |
| +// deployment booted with the wrong region stamps that region into bucket |
| +// names and IAM policies it then actually creates — a real, misplaced |
| +// estate. A mail Deployment with a blank region or suffix cannot do that: |
| +// MailProvisioner is not wired by this command yet (see this function's |
| +// own doc comment history / the task-17 review), so |
| +// mailProvisionerGate's 404 already refuses every email door regardless |
| +// of what these env vars hold. Once a real Provisioner is wired, the |
| +// coordinates below become load-bearing the same way storeBackend's are — |
| +// that wiring should promote the same fields to fatal-if-missing then. |
| +func mailDeployment() mail.Deployment { |
| + return mail.Deployment{ |
| + Region: strings.TrimSpace(os.Getenv("CARLOS_MAIL_REGION")), |
| + PlatformSuffix: strings.ToLower(strings.TrimSpace(os.Getenv("CARLOS_MAIL_PLATFORM_SUFFIX"))), |
| + DMARCRua: strings.TrimSpace(os.Getenv("CARLOS_MAIL_DMARC_RUA")), |
| + SNSTopicARN: strings.TrimSpace(os.Getenv("CARLOS_MAIL_SNS_TOPIC_ARN")), |
| + } |
| +} |
| + |
| // edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the |
| // agent's domains reconcile loop reads, so the console's point-your-DNS-here |
| // guidance and the agent's DNS health checks can never name different |
diff --git a/internal/console/apimail.go b/internal/console/apimail.go| index 4f3e61d70..d6a915d1e 100644 |
| --- a/internal/console/apimail.go |
| +++ b/internal/console/apimail.go |
| @@ -20,10 +20,22 @@ |
| // verb: declaring, bringing a domain into existence, verifying it, enabling, |
| // rotating and minting/revoking a standalone credential all self-serve at |
| // the account's tier cap (spec §10 — "free tier self-serves the platform |
| -// domain... paid tiers self-serve custom domains"). Only pausing/resuming and |
| -// raising a cap above what the tier grants need a deployment operator |
| -// (CARLOS_MAIL_OPERATORS) — the one door in this file with an allowlist gate, |
| -// run AFTER the member 404 exactly as store's grant door runs its own. |
| +// domain... paid tiers self-serve custom domains"). Only pausing/resuming |
| +// and raising a cap above what the tier grants need a deployment operator. |
| +// |
| +// THE PAUSE DOOR IS NOT apiAccountApp's SHAPE, and that is deliberate |
| +// (review round 1, I3). A pause is applied AGAINST a tenant, not asked for |
| +// by one — the abusive tenant has no reason to add the operator |
| +// investigating them as a member, and only that tenant's own owner can add |
| +// one, so member-then-allowlist (store's grant shape) would deadlock the |
| +// abuse brake in the exact scenario it exists for. handleAPIEmailPause |
| +// instead follows economicspass.go's handleAPIEconomicsBill shape: cliUser |
| +// -> store.IsOperator -> a bare, byte-identical http.NotFound, with the |
| +// account resolved afterward WITHOUT requiring membership |
| +// (mailPauseTarget, via AccountBySqid — viewer-independent, like |
| +// handleEconomicsTierSave's). CARLOS_MAIL_OPERATORS narrows the |
| +// fleet-operator bit further (mailOperator's doc comment says why); the |
| +// other eight doors never consult it. |
| // |
| // NOTHING SECRET CROSSES THESE DOORS except once. Every reply here echoes |
| // the record's IAM user, its key id (the SMTP USERNAME) and the domains' |
| @@ -149,12 +161,29 @@ func (s *Server) mailSealKey(w http.ResponseWriter) (*ecdh.PublicKey, bool) { |
| return pub, true |
| } |
| -// mailOperator reports whether addr may drive the pause/cap door. Matches |
| -// storeOperator's exact shape, including the reason it must NOT be |
| -// strings.EqualFold (see that function's doc comment): the address on a CLI |
| -// token is already ASCII-lowercased at every entry point, so the allowlist |
| -// entry is folded the same way and never Unicode-folded, which would let a |
| -// long-s lookalike claim someone else's entry. |
| +// mailOperator reports whether addr may drive the pause/cap door — a |
| +// NARROWING of store.IsOperator, not a replacement for it (review round 1, |
| +// I3). IsOperator is broad, fleet-wide authority: billing, every account's |
| +// economics, the alert watcher's recipient list. This door pauses a |
| +// tenant's SHARED SENDING REPUTATION specifically, and a deployment may |
| +// reasonably want to trust a smaller, different set of people with that |
| +// than with "can see the fleet bill" — so handleAPIEmailPause checks BOTH: |
| +// IsOperator first (cheap, and it is what makes the account resolution |
| +// membership-free — see the file comment), then this list. |
| +// |
| +// FAIL-CLOSED, like StoreOperators (that field's own doc comment): an |
| +// unset CARLOS_MAIL_OPERATORS means no fleet operator may drive this door, |
| +// not "any fleet operator may." That direction matters here more than for |
| +// store's grant — this list gates a brake against ABUSE, not merely |
| +// spending, and "everyone with the fleet-operator bit" is not the same |
| +// population as "everyone this deployment trusts with a tenant's sending |
| +// reputation." |
| +// |
| +// Matches storeOperator's exact shape otherwise, including the reason it |
| +// must NOT be strings.EqualFold (see that function's doc comment): the |
| +// address on a CLI token is already ASCII-lowercased at every entry point, |
| +// so the allowlist entry is folded the same way and never Unicode-folded, |
| +// which would let a long-s lookalike claim someone else's entry. |
| func (s *Server) mailOperator(addr string) bool { |
| for _, op := range s.cfg.MailOperators { |
| if strings.ToLower(strings.TrimSpace(op)) == addr { |
| @@ -164,6 +193,44 @@ func (s *Server) mailOperator(addr string) bool { |
| return false |
| } |
| +// mailPauseTarget resolves the pause door's {account}/{app} WITHOUT |
| +// requiring membership (review round 1, I3) — see the file comment for |
| +// why member-then-allowlist is backwards for a door applied AGAINST a |
| +// tenant. AccountBySqid is viewer-independent (it filters deleted_at, not |
| +// membership — store.go's own doc comment on it, and the same function |
| +// handleEconomicsTierSave uses to let an operator act on ANY account) and |
| +// sqid-ONLY, no name resolution — the same reason apiAccountApp's |
| +// `?exact=sqid` exists: the reference an operator reaches for here can |
| +// never itself be ambiguous. |
| +// |
| +// Every failure is a plain, byte-identical http.NotFound: an unknown |
| +// account, an app that account never claimed, and (via the caller, before |
| +// this is even reached) a non-operator's probe against a real one must all |
| +// read the same — the same non-probeability the member doors keep, just |
| +// earned a different way. |
| +func (s *Server) mailPauseTarget(w http.ResponseWriter, r *http.Request) (*Account, string, bool) { |
| + acct, err := s.store.AccountBySqid(r.PathValue("account")) |
| + if err != nil { |
| + log.Printf("console: api email pause: account lookup: %v", err) |
| + http.NotFound(w, r) |
| + return nil, "", false |
| + } |
| + if acct == nil { |
| + http.NotFound(w, r) |
| + return nil, "", false |
| + } |
| + app := r.PathValue("app") |
| + found, err := s.store.HasAppIn(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: api email pause: has-app %s/%s: %v", acct.Sqid, app, err) |
| + } |
| + if err != nil || !found { |
| + http.NotFound(w, r) |
| + return nil, "", false |
| + } |
| + return acct, app, true |
| +} |
| + |
| // mailBillingTier is the account's billing tier, as TierCap wants it. |
| // |
| // THERE IS NO BILLING-TIER FIELD ON Account YET (issue #226). The design |
| @@ -827,41 +894,63 @@ func (s *Server) handleAPIEmailCredentialsRevoke(w http.ResponseWriter, r *http. |
| // handleAPIEmailPause answers `carlos email pause`/`resume` AND an |
| // operator's cap raise — ONE door for both, per the F2 controller ruling. |
| // |
| -// OPERATOR-ONLY, and that is deliberate for pause/resume too, not just the |
| -// cap half: mailcounts.go's automatic abuse pause exists precisely so one |
| -// tenant's bounce rate cannot put the whole shared SES account under |
| -// review, and "NOTHING auto-resumes... resume is a decision" (that file's |
| -// own doc comment) would be hollow if the paused tenant could simply |
| -// resume itself through this door. The gate order is store's grant door's |
| -// exactly: member 404 FIRST (apiAccountApp), THEN the allowlist — an |
| -// operator is not automatically a member, and a stranger holding the |
| -// allowlist address must still get the byte-identical 404 for an account |
| -// they are not in. |
| +// FLEET-OPERATOR GATED via store.IsOperator, NOT apiAccountApp's |
| +// member-then-allowlist shape the other eight doors use (review round 1, |
| +// I3) — see the file comment for why. The order below matches |
| +// economicspass.go's handleAPIEconomicsBill exactly: cliUser -> IsOperator |
| +// -> a bare, byte-identical http.NotFound, before ANY account is even |
| +// looked up. CARLOS_MAIL_OPERATORS (mailOperator) narrows that further — |
| +// see its own doc comment for why a second, smaller list exists at all. |
| +// Both checks refuse with the SAME silent 404, never a speaking 403: a 403 |
| +// here would be an oracle telling a non-operator prober "this route exists |
| +// and you lack authority for it," which is exactly the class of leak the |
| +// byte-identical 404 exists to prevent everywhere else in this file. |
| +// |
| +// I1 (review round 1): Paused is *bool, not bool, and SetPaused is called |
| +// ONLY when it is present. A body carrying just {"daily_cap": N} decoding |
| +// a MISSING paused as false would silently RESUME a tenant an earlier |
| +// request paused — directly contradicting "nothing auto-resumes... resume |
| +// is a decision" (mailcounts.go), the very rule that makes this whole door |
| +// operator-only in the first place. |
| // |
| // F2: a daily_cap in the body raises the cap with mail.ApplyGrant, source |
| // "operator" — mail.ApplyGrant itself already refuses to let a later |
| // tier-sourced grant (the enable door) overwrite an operator's raise; this |
| // door only has to pass the right source. |
| func (s *Server) handleAPIEmailPause(w http.ResponseWriter, r *http.Request) { |
| - addr, acct, app, ok := s.apiAccountApp(w, r) |
| + addr, ok := s.cliUser(w, r) |
| if !ok { |
| return |
| } |
| - if !s.mailOperator(addr) { |
| - jsonError(w, http.StatusForbidden, |
| - "pausing, resuming or raising an email cap needs a deployment operator (CARLOS_MAIL_OPERATORS)") |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: api email pause: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp || !s.mailOperator(addr) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + acct, app, ok := s.mailPauseTarget(w, r) |
| + if !ok { |
| return |
| } |
| if !s.mailWriteGates(w, addr) { |
| return |
| } |
| var req struct { |
| - Paused bool `json:"paused"` |
| - DailyCap int `json:"daily_cap"` |
| + Paused *bool `json:"paused"` |
| + DailyCap int `json:"daily_cap"` |
| } |
| if !readJSON(w, r, &req) { |
| return |
| } |
| + if req.Paused == nil && req.DailyCap <= 0 { |
| + jsonError(w, http.StatusBadRequest, |
| + "send paused (true/false) or a daily_cap to raise — this request would change nothing") |
| + return |
| + } |
| ctx, cancel := context.WithTimeout(r.Context(), mailPauseTimeout) |
| defer cancel() |
| @@ -888,25 +977,46 @@ func (s *Server) handleAPIEmailPause(w http.ResponseWriter, r *http.Request) { |
| } |
| } |
| - rec, err := mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct.Sqid, app, req.Paused, time.Now()) |
| - if errors.Is(err, mail.ErrNotProvisioned) { |
| - jsonError(w, http.StatusConflict, "no provisioned email credential to pause — enable email first") |
| - return |
| - } |
| - if err != nil { |
| - log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err) |
| - jsonError(w, http.StatusBadGateway, "could not change the pause state — try again") |
| - return |
| + // I1: SetPaused runs ONLY when the caller actually asked for a pause or |
| + // a resume. A cap-only raise reads the current record instead of |
| + // writing one — it must not touch Active at all. |
| + var rec *mail.Record |
| + if req.Paused != nil { |
| + rec, err = mail.SetPaused(ctx, s.cfg.Releases, s.cfg.MailProvisioner, acct.Sqid, app, *req.Paused, time.Now()) |
| + if errors.Is(err, mail.ErrNotProvisioned) { |
| + jsonError(w, http.StatusConflict, "no provisioned email credential to pause — enable email first") |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not change the pause state — try again") |
| + return |
| + } |
| + } else { |
| + rec, _, err = mail.Get(ctx, s.cfg.Releases, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: api email pause %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the email record — try again") |
| + return |
| + } |
| + if rec == nil { |
| + jsonError(w, http.StatusNotFound, "no email is declared for this app — declare it first") |
| + return |
| + } |
| } |
| - action := "resumed" |
| - if req.Paused { |
| - action = "paused" |
| + |
| + var detail []string |
| + if req.Paused != nil { |
| + action := "resumed" |
| + if *req.Paused { |
| + action = "paused" |
| + } |
| + detail = append(detail, "email "+action) |
| } |
| - detail := "email " + action + " (cli)" |
| if req.DailyCap > 0 { |
| - detail += fmt.Sprintf("; daily cap raised to %d", req.DailyCap) |
| + detail = append(detail, fmt.Sprintf("daily cap raised to %d", req.DailyCap)) |
| } |
| - s.audit(acct, app, addr, "email-pause", detail) |
| + s.audit(acct, app, addr, "email-pause", strings.Join(detail, "; ")+" (cli)") |
| // No bumpSerial: SetPaused and ApplyGrant both write only email.json, |
| // never the app's own delivered config. |
| s.mailWalkReply(ctx, w, acct, app, rec) |
diff --git a/internal/console/apimail_test.go b/internal/console/apimail_test.go| index b2dfc7969..7b2295c1c 100644 |
| --- a/internal/console/apimail_test.go |
| +++ b/internal/console/apimail_test.go |
| @@ -62,12 +62,16 @@ func emailArmed(t *testing.T, code string) (*consoleFixture, *mail.MemProvisione |
| return fx, fake, writer, token |
| } |
| -// emailOperatorToken mints a token for an address on MailOperators, adding |
| -// it as a member first — an operator is not automatically a member (the |
| -// pause door's own doc comment), so every operator test needs both. |
| +// emailOperatorToken mints a token for a genuine fleet operator (the |
| +// store.IsOperator bit set) on MailOperators — and DELIBERATELY does NOT |
| +// add them as a member of fx's account. I3: the pause door must be |
| +// drivable by an operator who is not a member of the account they are |
| +// pausing, which is the exact scenario the abuse brake exists for, so the |
| +// standard operator fixture proves that by construction rather than by a |
| +// separate test remembering to skip AddMember. |
| func emailOperatorToken(t *testing.T, fx *consoleFixture, code string) string { |
| t.Helper() |
| - if err := fx.st.AddMember(OpsAccountID, "ops@keymail.dev", RoleMember, time.Now()); err != nil { |
| + if err := fx.st.SetOperator("ops@keymail.dev", true); err != nil { |
| t.Fatal(err) |
| } |
| return cliToken(t, fx, "ops@keymail.dev", code) |
| @@ -678,19 +682,46 @@ func TestAPIEmailCredentialsRevokeRemovesIt(t *testing.T) { |
| // ---- pause (operator) ------------------------------------------------------- |
| // TestAPIEmailPauseNeedsAnOperator: a member of the account — even the one |
| -// who declared and enabled — cannot pause or raise a cap. The refusal |
| -// names CARLOS_MAIL_OPERATORS, and nothing is changed. |
| +// who declared and enabled — cannot pause or raise a cap. The refusal is |
| +// the family's byte-identical 404 (I3: this door never speaks a 403 — |
| +// that would be an oracle telling a non-operator prober the route |
| +// exists), and nothing is changed. |
| func TestAPIEmailPauseNeedsAnOperator(t *testing.T) { |
| fx, fake, _, token := emailArmed(t, "code-email-pause-operator") |
| app, domain := "titogo", "titogo.bab.oncarlos.com" |
| provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| - status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, token) |
| - if status != http.StatusForbidden { |
| - t.Fatalf("pause by a non-operator member = %d %v, want 403", status, out) |
| + status, body := postRaw(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, token) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("pause by a non-operator member = %d %q, want 404", status, body) |
| + } |
| + if body != "404 page not found\n" { |
| + t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", body) |
| + } |
| + rec := emailRecord(t, fx, app) |
| + if !rec.Provisioned.Active { |
| + t.Fatal("a refused pause changed the record's active state") |
| + } |
| +} |
| + |
| +// TestAPIEmailPauseOperatorNotOnMailOperatorsListIsRefused (I2): a genuine |
| +// fleet operator (store.IsOperator true) is STILL refused when |
| +// CARLOS_MAIL_OPERATORS does not name them — the list is fail-closed, not |
| +// "any fleet operator may." This is the invariant protecting the SHIPPED |
| +// deployment shape, where CARLOS_MAIL_OPERATORS is typically unset. |
| +func TestAPIEmailPauseOperatorNotOnMailOperatorsListIsRefused(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-pause-notlisted") |
| + fx.srv.cfg.MailOperators = nil // the shipped-binary default |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + if err := fx.st.SetOperator("random-operator@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| } |
| - if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_MAIL_OPERATORS") { |
| - t.Fatalf("error = %q, want the operator list named", msg) |
| + randomOp := cliToken(t, fx, "random-operator@keymail.dev", "code-email-pause-notlisted-op") |
| + |
| + status, body := postRaw(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, randomOp) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("pause by a fleet operator not on MailOperators = %d %q, want 404", status, body) |
| } |
| rec := emailRecord(t, fx, app) |
| if !rec.Provisioned.Active { |
| @@ -698,6 +729,80 @@ func TestAPIEmailPauseNeedsAnOperator(t *testing.T) { |
| } |
| } |
| +// TestAPIEmailPauseOperatorNeedNotBeAMember (I3): the abuse brake's whole |
| +// reason for existing — an operator with NO membership in the account can |
| +// still pause it. Confirms non-membership explicitly, rather than relying |
| +// on emailOperatorToken never having called AddMember. |
| +func TestAPIEmailPauseOperatorNeedNotBeAMember(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-pause-nonmember") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + ops := emailOperatorToken(t, fx, "code-email-pause-nonmember-ops") |
| + |
| + role, err := fx.st.Role(OpsAccountID, "ops@keymail.dev") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if role != "" { |
| + t.Fatalf("role = %q, want the operator to hold NO role in this account", role) |
| + } |
| + |
| + status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops) |
| + if status != http.StatusOK { |
| + t.Fatalf("pause by a non-member operator = %d %v, want 200", status, out) |
| + } |
| + if out["active"] != false { |
| + t.Fatalf("active = %v, want false after a non-member operator's pause", out["active"]) |
| + } |
| +} |
| + |
| +// TestAPIEmailPauseCapRaiseDoesNotResume (I1): a cap-raise-only request |
| +// ({"daily_cap": N}, no "paused" field) must not decode a missing Paused |
| +// as false and silently resume a tenant an earlier request paused. |
| +func TestAPIEmailPauseCapRaiseDoesNotResume(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-pause-capnoresume") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + ops := emailOperatorToken(t, fx, "code-email-pause-capnoresume-ops") |
| + |
| + if status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"paused": true}, ops); status != http.StatusOK { |
| + t.Fatalf("pause = %d %v", status, out) |
| + } |
| + if emailRecord(t, fx, app).Provisioned.Active { |
| + t.Fatal("sanity check failed: record is not paused after the pause call") |
| + } |
| + |
| + status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{"daily_cap": 5000}, ops) |
| + if status != http.StatusOK { |
| + t.Fatalf("cap-raise-only = %d %v", status, out) |
| + } |
| + if out["active"] != false { |
| + t.Fatalf("active = %v, want false — a cap-raise-only request must not resume", out["active"]) |
| + } |
| + rec := emailRecord(t, fx, app) |
| + if rec.Provisioned.Active { |
| + t.Fatal("a cap-raise-only request resumed a paused tenant") |
| + } |
| + if rec.Grant.DailyCap != 5000 || rec.Grant.Source != "operator" { |
| + t.Fatalf("grant = %+v, want the cap raise to still have applied", rec.Grant) |
| + } |
| +} |
| + |
| +// TestAPIEmailPauseRefusesAnEmptyBody: neither paused nor a positive |
| +// daily_cap is a request that would change nothing — refused rather than |
| +// silently answering 200 having done nothing. |
| +func TestAPIEmailPauseRefusesAnEmptyBody(t *testing.T) { |
| + fx, fake, _, token := emailArmed(t, "code-email-pause-empty") |
| + app, domain := "titogo", "titogo.bab.oncarlos.com" |
| + provisionEmail(t, fx, fake, token, app, domain, "hello@"+domain) |
| + ops := emailOperatorToken(t, fx, "code-email-pause-empty-ops") |
| + |
| + status, out := postJSON(t, emailURL(fx, app)+"/pause", map[string]any{}, ops) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("empty pause body = %d %v, want 400", status, out) |
| + } |
| +} |
| + |
| // TestAPIEmailPauseTogglesTheKeyAndRaisesTheCap: an operator can pause, |
| // resume and raise the cap through the same door. Raising the cap applies |
| // mail.ApplyGrant with source "operator" (F2), and that grant SURVIVES a |