From a316d5201be81e7ab48d789bbea4f1c455ba3338 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 21 Oct 2026 23:51:58 -0700 Subject: [PATCH] Fix flaky TestRateLimits; drop stale /v1 paths from the planning docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestRateLimits sent 11 logins and expected the 11th to hit the 10-per-minute limit, but limits count per clock minute, so a run that crossed a minute boundary saw its counter reset and got 401. The API's clock in this test now starts at the top of a minute (testenv gains an Options.Now), so all 11 land in one window however slow CI is, and the test gets its own database so repeated runs don't share counters. - remaining_manual_steps.md: root paths (/health, /auth/login, /applications, /roles, /api-keys, /webhooks) and the Substratal-Version header on the sign-in curl and the app-registration calls. - plan-remaining.md: Phase 0 is checked at /health (200 on 2026-10-22; /v1/health is 404). - migrations/0008_scim_tokens.sql: the comment said it came after 0005–0007, which were renumbered 0009–0011 to follow it. Comment only; the runner tracks versions, not checksums. Co-Authored-By: Claude Opus 5.5 --- internal/core/webhooks_test.go | 8 +++++++- internal/testenv/testenv.go | 5 ++++- migrations/0008_scim_tokens.sql | 7 ++++--- plan-remaining.md | 2 +- remaining_manual_steps.md | 18 +++++++++--------- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/internal/core/webhooks_test.go b/internal/core/webhooks_test.go index 2929240..0581a6b 100644 --- a/internal/core/webhooks_test.go +++ b/internal/core/webhooks_test.go @@ -342,7 +342,13 @@ func TestPagination(t *testing.T) { } func TestRateLimits(t *testing.T) { - e := testenv.NewWith(t, testenv.Options{RateLimits: true}) + // Limits count per clock minute, so the API's clock starts at the top + // of one: all 11 logins land in the same window however slow the run. + // Its own database keeps the counters from leaking between runs. + start := time.Now() + minute := start.Truncate(time.Minute) + e := testenv.NewWith(t, testenv.Options{RateLimits: true, OwnDatabase: true, + Now: func() time.Time { return minute.Add(time.Since(start)) }}) var last *testenv.Resp for i := 0; i < 11; i++ { last = e.Post("/auth/login", "", map[string]any{"email": "rl+" + testenv.Unique() + "@example.com", "password": "whatever passphrase"}) diff --git a/internal/testenv/testenv.go b/internal/testenv/testenv.go index 91f108d..5b2bafe 100644 --- a/internal/testenv/testenv.go +++ b/internal/testenv/testenv.go @@ -189,6 +189,9 @@ type Options struct { // data would leak into others sharing the package database (a free // Application, say, is granted to every User created afterwards). OwnDatabase bool + // Now replaces the API's clock (time.Now). Rate-limit windows, token + // lifetimes, and every other time check read it. + Now func() time.Time } // New starts an API for one test, with a fresh superadmin. @@ -226,7 +229,7 @@ func NewWith(t testing.TB, opts Options) *Env { s := core.New(core.Deps{DB: d, Signer: signer(), Encrypter: enc, Email: mail, Push: recorder, Cursors: httpx.Cursors{Key: []byte("test-cursor-key")}, Log: log, Issuer: "https://api.substratalapps.com", PublicBaseURL: "http://localhost", AuthorizeURL: "https://auth.substratalapps.com/authorize", - DashboardURL: "https://dashboard.test", DisableRateLimits: !opts.RateLimits, Billing: opts.Billing}) + DashboardURL: "https://dashboard.test", DisableRateLimits: !opts.RateLimits, Billing: opts.Billing, Now: opts.Now}) srv := httptest.NewServer(s.Handler()) e := &Env{T: t, Server: srv, Core: s, DB: d, Mail: mail, Deliverer: deliverer, Push: recorder, Signer: signer(), Validate: true, validator: specValidator(t)} diff --git a/migrations/0008_scim_tokens.sql b/migrations/0008_scim_tokens.sql index 3d020d4..792a43f 100644 --- a/migrations/0008_scim_tokens.sql +++ b/migrations/0008_scim_tokens.sql @@ -3,9 +3,10 @@ -- One SCIM bearer token per Organization, for its identity provider. Only -- the SHA-256 of the secret is stored, like API Keys. -- --- Numbered after 0005_test_user_expiry, 0006_self_registered_apps, and --- 0007_audit_exports, which ship first; the action list below keeps their --- user.expired and audit.exported. +-- This reached production before the test-user-expiry, self-registered +-- apps, and audit-export migrations, which were renumbered 0009–0011 to +-- follow it. The action list below already includes their user.expired +-- and audit.exported. create table scim_tokens ( id text primary key, diff --git a/plan-remaining.md b/plan-remaining.md index dd7cf6f..03d4d43 100644 --- a/plan-remaining.md +++ b/plan-remaining.md @@ -25,7 +25,7 @@ As of 2026-10-20, every operation in `openapi.yaml` is implemented, including su The phase exit criteria have only been checked against the local devserver. Once production is up, check them there: -- **Phase 0:** `GET /health` returns `200` through the real Lambda and API Gateway path. (It returned `200` at the old `/v1/health` on 2026-10-20. Check `/health` again after the first deploy without the `/v1` prefix.) +- **Phase 0:** `GET /health` returns `200` through the real Lambda and API Gateway path. (Done: it returned `200` at `/health` on 2026-10-22, after the move to header versioning; the old `/v1/health` now returns `404`.) - **Phase 1:** the [Quickstart](https://compositecode.github.io/substratalapps.com/quickstart/) runs end to end against production: create a user, grant an app, check access, turn it off. Run `scripts/quickstart.sh` with `SUBSTRATAL_API=https://api.substratalapps.com` and a test-mode key. - **Phase 2:** a third-party-shaped app signs a user in through the embedded flow, receives `access.revoked` and kills the session, calls introspection, has its `settings_schema` validated and typed on write, and receives a push sent through its app key. A test-mode User and a test-mode Organization subscribe to Standard through Checkout (needs a Stripe test-mode key in a non-production environment, or temporarily in production), gain the standard Applications, and lapse back to Free. diff --git a/remaining_manual_steps.md b/remaining_manual_steps.md index 598aa1c..b9f0b49 100644 --- a/remaining_manual_steps.md +++ b/remaining_manual_steps.md @@ -2,7 +2,7 @@ One-time setup that has to be done by hand, in a console or from a laptop, to take the Platform API live. Code that still has to be built is in [plan-remaining.md](plan-remaining.md); how the deployment works is in [infra/README.md](infra/README.md). -Already done (2026-10-20): the AWS account (`substratal-prod`, `196936048600`) and its management account; CLI access with `aws login`; DNS moved from Vercel to Route 53; the Organization SCP exception for `substratal-prod`; the bootstrap Terraform (budgets, anomaly detection, state and artifact buckets, GitHub OIDC roles); GitHub variables, the `production` environment limited to `main`, and the `main` ruleset; and a first partial deploy (Aurora, all five Lambdas, API Gateway, the certificate, SES domain). `https://api.substratalapps.com/v1/health` returns `200`. +Already done (2026-10-20): the AWS account (`substratal-prod`, `196936048600`) and its management account; CLI access with `aws login`; DNS moved from Vercel to Route 53; the Organization SCP exception for `substratal-prod`; the bootstrap Terraform (budgets, anomaly detection, state and artifact buckets, GitHub OIDC roles); GitHub variables, the `production` environment limited to `main`, and the `main` ruleset; and a first partial deploy (Aurora, all five Lambdas, API Gateway, the certificate, SES domain). `https://api.substratalapps.com/health` returns `200` (the API moved from `/v1/…` paths to the `Substratal-Version` header on 2026-10-21; see [Conventions → Versioning](https://compositecode.github.io/substratalapps.com/api-reference/conventions/#versioning)). Work through these in order. The first four make the API usable; the rest make it ready for real customers. @@ -38,7 +38,7 @@ After the quota shows **1000** (`aws lambda get-account-settings --profile subst - **Easiest:** GitHub → **Actions → Deploy → Run workflow**, branch `main`, **Run workflow**. - Or from a terminal: `gh workflow run deploy.yml -R CompositeCode/substratalapps.com --ref main`. -The run builds the Lambdas, applies Terraform (it picks up where the first run stopped), runs the migrations on the new cluster, makes sure the protected superadmin exists (`SUPERADMIN_EMAIL`), and smoke-tests `/v1/health` and JWKS. It takes about 5–10 minutes. Every later merge into `main` deploys the same way, automatically. +The run builds the Lambdas, applies Terraform (it picks up where the first run stopped), runs the migrations on the new cluster, makes sure the protected superadmin exists (`SUPERADMIN_EMAIL`), and smoke-tests `/health` and JWKS. It takes about 5–10 minutes. Every later merge into `main` deploys the same way, automatically. ## 3. Set the superadmin's password @@ -53,7 +53,7 @@ export DATABASE_BACKEND=dataapi \ go run ./cmd/admin bootstrap -email adronhall@proton.me # prompts for the password (12+ characters) ``` -Then sign in: `curl -s https://api.substratalapps.com/v1/auth/login -H 'content-type: application/json' -d '{"email":"adronhall@proton.me","password":"…"}'`. Turn on MFA right away ([Auth → MFA](https://compositecode.github.io/substratalapps.com/api-reference/auth/#mfa-totp)). +Then sign in: `curl -s https://api.substratalapps.com/auth/login -H 'Substratal-Version: 1' -H 'content-type: application/json' -d '{"email":"adronhall@proton.me","password":"…"}'`. Turn on MFA right away ([Auth → MFA](https://compositecode.github.io/substratalapps.com/api-reference/auth/#mfa-totp)). ## 4. Confirm the alert emails @@ -86,16 +86,16 @@ The billing code (Free and Standard at $6 per user per month) is built and deplo Push notifications are merged (PR #12) and ship with the next successful deploy, which also applies migration `0003_push.sql`. Migrations `0003_push` and `0004_protected_superadmin` touch different tables, so it doesn't matter that `0004` reached `main` first. -For each Application that sends pushes, its owner uploads credentials with `POST /v1/applications/{id}/push-credentials` (and rotates them with `PATCH …/push-credentials/{provider}`): an APNs key (`.p8`, key id, team id, bundle id), an FCM service account, or Web Push VAPID keys ([Push Notifications → Push credentials](https://compositecode.github.io/substratalapps.com/api-reference/notifications/#push-credentials)). +For each Application that sends pushes, its owner uploads credentials with `POST /applications/{id}/push-credentials` (and rotates them with `PATCH …/push-credentials/{provider}`): an APNs key (`.p8`, key id, team id, bundle id), an FCM service account, or Web Push VAPID keys ([Push Notifications → Push credentials](https://compositecode.github.io/substratalapps.com/api-reference/notifications/#push-credentials)). ## 8. Register Substratal's Applications -For each of Substratal's apps, with the superadmin's token ([Applications → Owner onboarding](https://compositecode.github.io/substratalapps.com/api-reference/applications/#owner-onboarding-end-to-end)): +For each of Substratal's apps, with the superadmin's token and `Substratal-Version: 1` on every request ([Applications → Owner onboarding](https://compositecode.github.io/substratalapps.com/api-reference/applications/#owner-onboarding-end-to-end)): -1. `POST /v1/applications` with its slug, name, `launch_url`, `redirect_uris`, `permissions`, `available_app_roles`, and **`access_tier`** (`free` or `standard`, which decides who gets it under [Pricing](https://compositecode.github.io/substratalapps.com/pricing/)). -2. Give each seeded Role its permissions (`PATCH /v1/roles/role__`). -3. Create the app backend's key (`POST /v1/api-keys`, scope the app, `entitlements.manage` and `users.list`), plus a test-mode key. -4. Subscribe the backend to `access.revoked` and `role.removed` (`POST /v1/webhooks`): the required revocation integration. +1. `POST /applications` with its slug, name, `launch_url`, `redirect_uris`, `permissions`, `available_app_roles`, and **`access_tier`** (`free` or `standard`, which decides who gets it under [Pricing](https://compositecode.github.io/substratalapps.com/pricing/)). +2. Give each seeded Role its permissions (`PATCH /roles/role__`). +3. Create the app backend's key (`POST /api-keys`, scope the app, `entitlements.manage` and `users.list`), plus a test-mode key. +4. Subscribe the backend to `access.revoked` and `role.removed` (`POST /webhooks`): the required revocation integration. ## 9. Fix the pull-request plan job