Test tenant isolation before accepting a cache change
For example, tenants A and B each have record 42, but an ID-only cache warmed by A returns A’s payload to B. A payload-only test can also pass after caching has been disabled.
| Tenant | Record ID | Stored payload |
|---|---|---|
| A | 42 |
{"label": "A-only"} |
| B | 42 |
{"label": "B-only"} |
A correct database filter cannot protect a read that bypasses it.
Before accepting this change, require an A–B–A check: read as A, then B, then A again. Assert each tenant’s payload and verify that a cache hit supplies the final response. The test needs to catch both cross-tenant reuse and the loss of caching.
Write the acceptance note before reviewing the key
“Make record reads faster with caching” leaves the important constraint unstated. Put a Cache acceptance note in the change request:
| Part | What to write |
|---|---|
| Requirement | Cached reads preserve the tenant scope of the existing authorized read path. |
| Counterexample | A and B share record ID 42, but have different payloads; an ID-only cache aliases their records. |
| Acceptance check | Read A–B–A through the application entry point; assert each payload and observe a cache hit serving the final A read. |
| Boundary | Evidence covers the exercised path, fixture, adapter, configuration, and sequential execution, not a complete security proof. Other authorization routes, concurrent execution, permission changes, invalidation, and alternate endpoints need separate checks where affected. |
Run A–B–A through the affected application path
Use isolated test tenants, synthetic records, and a dedicated cache instance or namespace. Start with an empty cache, enough capacity, and a lifetime long enough that these entries will not expire during the sequence.
Create authenticated test contexts for A and B using the application’s normal tenant-resolution mechanism. Do not substitute a client-supplied tenant field if the application normally derives tenant identity from authentication.
Then call the service method or endpoint that the caching change affects:
| Step | Request context | Required response | Cache observation |
|---|---|---|---|
| 1 | A reads 42 |
Exactly A’s expected payload | Cold miss, followed by population |
| 2 | B reads 42 |
Exactly B’s expected payload | No reuse of A’s cached record; a separate miss and population for this fixture |
| 3 | A reads 42 again |
Exactly A’s expected payload | A hit that supplies this response |
Compare the tenant-specific payload, not just the status code or record ID. Both correct responses have ID 42, so asserting only that field would miss the failure.
Observe cache behavior using request-correlated tracing or a narrow spy around the cache adapter. Distinguish exercising the application path with a test adapter from exercising the deployed adapter. For the latter, use that adapter against a dedicated cache instance or namespace.
A global hit counter can include unrelated activity. A cache lookup reporting a hit is also insufficient if the application discards it and serves the response from storage.
When the cache-hit path is expected to bypass storage, pair the final payload assertion with a request-scoped storage spy. The spy should observe storage calls made by that request without replacing the application read path. In pseudocode, using the fixture above:
assert application.read_record(context=A, record_id=42) == {"label": "A-only"}
assert application.read_record(context=B, record_id=42) == {"label": "B-only"}
with request_scoped_storage_spy(context=A) as storage_spy:
final_response = application.read_record(context=A, record_id=42)
assert final_response == {"label": "A-only"}
assert storage_spy.call_count == 0
Together with the cache observation, this checks that the final request returns A’s payload without invoking storage.
Do not mock away the application read path. A unit test of a key-building helper cannot show that the changed code calls that helper.
A passing payload assertion does not show that caching still works.
Check whether different responses can share an entry
For this fixture, the unsafe identity is:
cache_key = record_id
A tenant-scoped identity needs to distinguish the two records:
cache_key = encode(resource_kind, tenant_id, record_id)
These are conceptual expressions, not library APIs. encode must preserve the boundaries between components, and tenant_id must come from the trusted request context. A separate cache namespace per tenant can provide equivalent separation; a combined key is not the only design.
The important question is whether two requests that require different cached values can resolve to the same cache entry. Here, omitting the tenant makes that collision explicit.
I would require the A–B–A check before accepting this change, even if a key-helper unit test already passes. It checks the wiring: tenant resolution, cache lookup, storage fallback, and response construction on the affected path.
Adding tenant_id is not a complete authorization design. If two users within one tenant can see different representations of the same record, tenant scoping alone does not capture that distinction.
Write the acceptance note for one pending cache change, define the two colliding records, and run A–B–A when the harness supports it. Until the test runs and passes, you have a specification, not a verified implementation.