Billing, usage and spending-limit routes behind plan entitlements
Plan caps, spending limits, and a gate that never fired
What makes it hard
Entitlements are the deny direction of a product, and the deny direction is where the money is.
One route issued presigned upload URLs. A global validation refused anything over 100 MB, and that worked. The plan-tier attachment cap did not run at all: an account on the free plan was issued a working, immediately usable signed URL for a 100 MB object, valid for an hour. The refusal that was supposed to name the entitlement never fired.
Note where the spend happens. Storage is written at the upload. A cap enforced later, in the route that consumes the file, refuses the use and does not reclaim the object. A user interface that hides the upload control on the free plan looks entirely correct while this route stays open.
A second finding in the same surface: a seat denial returned 400 with a seat object while every plan denial returned 403 with a PLAN_REQUIRED code. Two shapes for one concept means the upgrade prompt cannot be driven from a seat refusal, and it means a test suite has to pin both shapes and can never assert one.
And a third: the entitlement check did not re-evaluate at the effective tier, so a member of an organisation on the top plan was refused features that organisation pays for.
How to cover it
Three assertions per gate, always: the status, the code in the body, and the absence of the thing that was being gated.
The third one is where the shared library stops. There is the response JSON path {string} should exist; there is no negative form. A refusal that still hands out the URL is a total bypass for any client that ignores the status code, so this assertion genuinely matters and you need a way to make it.
The clean way, without writing a step, is a JSON schema — the response should match the JSON schema {string} runs ajv with formats, so the schema can forbid the key:
{
"type": "object",
"required": ["error", "code"],
"not": { "required": ["presignedUrl"] }
}That is a real assertion with a real failure message, and it lives in schemas/ next to the project rather than in a step nobody else reuses.
For the boundaries themselves, drive the sizes from a dataset rather than from copy-pasted scenarios — I load dataset "attachment-caps" row 1, or an outline over the table. A cap test is only interesting at the edges: one under, one at, one over, and the global limit above all of them, so the run can distinguish "the plan gate refused" from "the size validator refused".
Two more things this surface wants:
- entitlements that change per tier belong in a dataset keyed by plan, so a new tier is a row and not a rewrite
- a refusal shape is a contract. Pin it with a schema, and when the product unifies 400-with-an-object and 403-with-a-code, one schema file changes.
Scenario sketch
@api @regression @data-driven
Scenario Outline: The attachment cap is enforced before a URL is issued
Given I use a leased user with role "free" for API calls
When I send a POST request to "/files/presigned" with body:
"""json
{ "uploadType": "assistant", "contentType": "text/plain", "fileSize": <bytes> }
"""
Then the response status should be <status>
And the response should match the JSON schema "schemas/<schema>.json"
Examples:
| bytes | status | schema |
| 1048576 | 200 | presigned-issued |
| 6291456 | 403 | plan-refusal |
| 104857600 | 403 | plan-refusal |
@api @sanity
Scenario: The refusal names the entitlement it enforced
Given I use a leased user with role "free" for API calls
When I send a POST request to "/files/presigned" with body:
"""json
{ "uploadType": "assistant", "contentType": "text/plain", "fileSize": 104857600 }
"""
Then the response status should be 403
And the response JSON path "code" should equal "PLAN_REQUIRED"
And the response JSON path "feature" should equal "chatAttachmentMbPerMessage"
And the response header "cache-control" should contain "no-store"schemas/plan-refusal.json is where "not": { "required": ["presignedUrl"] } lives — the assertion the step library cannot make on its own.
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
- A 403 that a shared cache is free to replayAuthorisation refusals on an API served through a CDN
- 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