Skip to content

← All use cases

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:

gherkin
Then the response header "cache-control" should contain "no-store"

Assert three things on every negative scenario, not one:

  1. the status
  2. 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
  3. 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.

There is no negative header step. 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

gherkin
@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