Authorisation refusals on an API served through a CDN
A 403 that a shared cache is free to replay
What makes it hard
A refusal carried no Cache-Control header. The CDN in front of the application applies a default positive max-age to any response that declares nothing, and its cache key does not vary on the session cookie. So a 403 built for one caller becomes a 403 stored under a URL and handed to the next one — including, on one occasion, the legitimate owner of the resource.
An audit found the same shape on somewhere north of sixty routes: logs, webhooks, deployments, knowledge bases, invitations, permission groups, organisation members, and the platform admin surface. Exactly one shared HTTP helper stamped private, no-store on a 4xx, and most routes were hand-rolling their refusals instead of going through it.
The part that matters for testing: every one of those routes returned the correct status code. A suite that asserts the response status should be 403 was green on the day the bug shipped and stayed green for months. The status is the easy half of a denial; the caching semantics are the half that decides whether the denial is a security control or a suggestion.
How to cover it
The shared library has the step this needs. It is in @sdods/core/steps and it is not in the docs table, so people miss it:
Then the response header "cache-control" should contain "no-store"Assert three things on every negative scenario, not one:
- the status
- the reason, from the body —
the response JSON path "error" should equal "...", so a red run names the cause instead of making somebody re-probe by hand - the header
Do the deny direction as a wrong actor rather than as no actor wherever you can. I use a leased user with role "viewer" exercises the authorisation code; I use no authentication exercises the session guard in front of it. Both are real steps and they prove different things — the unauthenticated one sets the API context's auth to null explicitly, so it does not silently fall back to the environment's credential.
should contain exists; should not contain does not, for a header or for a body. If your contract is "the enforcing response must not carry public", that is a project step in your own api.steps.ts. Do not write an assertion the library will not parse and assume lint caught it — an unknown step is a missing-step error at generate time, which is a fine failure, but it is not the same as an assertion.This is also the argument for scenario outlines over copy-paste. One outline over a table of routes and roles is the difference between sixty scenarios you maintain and sixty scenarios you inherit.
Scenario sketch
@api @regression @data-driven
Scenario Outline: A refusal is never storable by a shared cache
Given I use a leased user with role "<role>" for API calls
When I send a <method> request to "<path>"
Then the response status should be <status>
And the response header "cache-control" should contain "no-store"
And the response JSON path "error" should exist
Examples:
| role | method | path | status |
| viewer | POST | /workflows/{{id}}/variables | 403 |
| viewer | DELETE | /workspaces/{{id}}/members | 403 |
| member | GET | /admin/role-policies | 403 |
| member | POST | /custom-domains | 403 |
@api @sanity
Scenario: An unauthenticated caller is refused and the refusal is not stored
Given I use no authentication
When I send a GET request to "/workspaces/{{workspaceId}}"
Then the response status should be 401
And the response header "cache-control" should contain "no-store"Exactly one layer tag and one suite tag per scenario, as the taxonomy requires; @data-driven is optional and documents the outline.
Other use cases
- Testing an eight-screen auth funnel that has no test idsLogin, signup, email verification, 2FA, the device flow, OAuth consent, invitation acceptance and password reset
- Plan caps, spending limits, and a gate that never firedBilling, usage and spending-limit routes behind plan entitlements
- A workflow canvas SDODS cannot dragA node-graph editor: 193 block types, edges, handles, sub-block inputs and subflow containers
- Ten scenarios said "Forbidden" and meant "you are out of workflows"A shared fixture workspace on a free plan with a three-workflow cap, holding twenty-three