navree
Documentation

Isolation tests

Generate cross-workspace tests from a module's route table: reading, changing or deleting another workspace's record is a 404, lists never show it, and creates never land in it.

On this page

A multi-tenant app is only as safe as its least careful query. One Entity::find_by_id without the workspace filter, and Globex reads Acme's deals. Isolation tests prove each route keeps workspaces apart, and TestApp::isolation_suite writes them for you from the module's route table: every route, sent from one workspace against another workspace's records.

A first suite

Give the suite the module to test and a model factory for its records, then run it:

tests/deals_isolation.rs

The factory is an ordinary model factory whose entity implements WorkspaceScoped:

src/deals/factory.rs

run passes when every route keeps workspaces apart. Otherwise it fails the test with a report of every route:

Text

try_run returns the same report as an IsolationReport instead of failing, for tests that look at single routes.

What the suite sends

The suite creates two workspaces, A and B, with one user who owns both, so role checks pass in either. Every request acts as that user in workspace A, through the X-Workspace header. Then, route by route:

RouteThe suitePasses when
With path parameters: GET, PATCH, PUT, POST or DELETE /deals/{id}Creates a fresh record in B for each parameter, then sends the route from A with B's idsThe answer is 404. A GET first answers 2xx in B (the control), so the 404 comes from isolation and not from a broken route. After a write, when the module has a GET route on the same path, it returns B's record unchanged
GET without parameters, a listCreates a record in B, then lists from AThe answer is 2xx and does not show B's record
POST, PUT, PATCH or DELETE without parameters, a createSends the body you gave with .body(..), with workspace_id set to B's idThe record is not created in B: the answer is a 4xx, or the created record's workspace_id is not B's and B's list does not show it

Any other answer to a cross-workspace request fails the route, with a reason:

  • 2xx: the record was read, changed or deleted from the wrong workspace.
  • 403: the route refused, but it told the caller that the record exists. A record of another workspace must look like one that doesn't exist.
  • 400 before the record was looked up: the route rejected the body, so the suite couldn't prove anything. Give it a valid body with .body(..).
  • 401: the suite's requests aren't authenticated. See Requirements.

Each route gets its own records, so a leaking DELETE can't spoil the next route's test.

Records for path parameters

.factory(f) binds the {id} parameter. Bind other parameters by name with .factory_for:

Rust

Each record goes into the workspace the suite asks for: through WorkspaceRepo::insert under row isolation, and into the workspace's own schema or database under schema and database isolation, with its workspace column set when the table keeps one. On PostgreSQL the insert runs inside the workspace, so a table with row-level security accepts it.

The suite tests what the routes do, not how the code is written. A handler that reads by id without the workspace filter still passes when row-level security or a per-workspace schema keeps the record out of reach; the workspace.repo check is the one that asks for WorkspaceRepo in the code.

A nested route such as /deals/{deal}/notes/{note} needs a note that belongs to the deal. Bind it with .record, whose closure gets the parameters bound before it:

Rust

A route whose parameter has no binding fails with path parameter {note} has no record, so a new route can't slip past the suite unnoticed.

Bodies and skipped routes

Writes on a record (PATCH /deals/{id}) are sent with {} unless you give a body. Creates without parameters (POST /deals) need a body to be tested and are skipped without one. Give bodies by method and mounted path, the API prefix and version included:

Rust

Skipped routes are listed in the report with their reason and don't fail the suite.

Requirements

  • The testing and workspace features. navree::workspace::testing is there when the navree dependency has both, as the default full features do. An app that picks its features adds testing to its dev-dependency on navree.
  • The header resolver. The suite names the workspace in the workspace.header header (X-Workspace by default). It is in the default workspace.resolvers; if your app resolves workspaces only by path or subdomain, add header in the test app: .config("workspace.resolvers", "header,path").
  • A test authenticator. The suite acts as its user with acting_as. Apps that include AuthModule build the test app with .fake_auth() (see Apps with navree-auth).
  • Roles. The user owns both workspaces, so the owner role's permissions (all.*) pass every route check. A route whose checks need something else, such as a global role, fails, and the report says which request was refused.
  • A clean module. The suite tests the routes of the module you name, not those of the modules it imports. Run one suite per feature module.

Other tenancies

The suite itself lives in navree-testing and knows nothing about workspaces. A Tenancy creates its tenants and says how a request names one, and navree-workspace's WorkspaceTenancy is the one .factory installs. Bring your own for another tenant model:

Rust

Reference

IsolationSuiteDoes
app.isolation_suite::<M>()A suite for every route of module M
.factory(f), .factory_for(param, f)Binds {id} or {param} to records of a model factory, and uses workspaces (navree-workspace, IsolationSuiteExt)
.record(param, f)Binds {param} to records an async closure creates: f(RecordContext) -> Result<String, BoxError>
.body(method, path, json)The JSON body for one route
.skip(method, path, reason)Leaves one route out
.tenancy(t), .workspaces()The Tenancy that creates the two tenants
.run().awaitRuns the suite; fails the test with the report when a route fails
.try_run().awaitRuns the suite and returns the IsolationReport

IsolationReport has routes (each a RouteResult with method, path and an IsolationOutcome of Passed, Failed or Skipped with its reason), passed(), failed(), skipped() and is_ok(), and prints as the table above.