Skip to content

Multi Tenancy Test Harness

Ed Mozley edited this page Jul 18, 2026 · 1 revision

πŸ§ͺ Multi-tenancy: the test harness

Part of Multi-tenancy. How to prove a module is actually isolated, without a browser and without touching your dev database.

Isolation bugs are invisible in normal use: at N=1 everything looks perfect, and at N>1 you only notice if you happen to be looking at the right screen as the right user. That's why this exists.


The two things that must be true

Test Why it matters
1️⃣ N=1 is a no-op Almost every install is single-company. A regression here breaks everyone.
2️⃣ Nβ‰₯2 isolates Company A can't read, open-by-id, write or link to company B's rows.

Both, every slice. Neither is optional.


πŸ”΄ Rule one: always include a positive control

This is the rule that matters most, because breaking it produces a green suite that proves nothing.

A "was it blocked?" assertion passes just as happily when the request was broken as when it was refused. Building the CMDB write suite, php://input turned out to be empty under the CLI SAPI β€” so every write died at 'name' is required before reaching a single tenancy check, and all six isolation assertions went green.

[PASS] co4 CANNOT rename co6 CI          ← refused? no. it never ran.
[PASS] co4 CANNOT delete co6 CI          ← same
[FAIL] co4 CAN rename its own CI         ← ⚠️ THE TELL

Nine green checks proving nothing. The only reason it was caught is that the positive controls failed alongside the negatives.

πŸ”‘ For every "X cannot do Y", assert the matching "X can do Y in its own company". If both move together, your harness is broken, not your code.


πŸ”΄ Rule two: drive the real endpoint, not a copy of its SQL

Re-implementing a query in your test proves your reimplementation is correct.

The bug class this catches is PDO parameter order. A scope fragment lands in three different places, and they bind in this order:

SELECT (subquery … AND o.tenant_id = ?)      ← 1st
  FROM x
  LEFT JOIN y ON y.id = x.y_id AND y.tenant_id = ?   ← 2nd
 WHERE x.id = ? AND x.tenant_id = ?          ← 3rd

Get that wrong and PDO doesn't error β€” it silently binds a tenant id as an object id and vice versa. Only the real query, with real parameters, catches it.

$params = array_merge($aChild, $aParent, $tArgs);   // SELECT, then JOIN, then WHERE

πŸ”§ The three tricks that make it work

1. Point the app at a scratch database

DB_NAME is define()d in db_config.php, and a second define() on an existing constant is ignored β€” so define it first and yours wins, while the real credentials still load normally.

define('DB_NAME', $argv[1]);   // ← MUST be before config.php loads db_config.php
require 'config.php';

2. Establish the session before the endpoint does

Endpoints call session_start() themselves, which overwrites a plain $_SESSION array. Open the session first, write to it, close it β€” the endpoint's own session_start() then reopens the same id.

session_id('cmdbtest' . $analyst);
@session_start();
$_SESSION['analyst_id'] = $analyst;
session_write_close();

3. Make php://input work under CLI

It's empty in the CLI SAPI. Override the wrapper so a piped body reaches the endpoint:

$body = stream_get_contents(STDIN);
class TestInputStream {
    public static string $data = '';
    private int $pos = 0;
    public function stream_open($path, $m, $o, &$p): bool { return $path === 'php://input'; }
    public function stream_read(int $n): string {
        $c = substr(self::$data, $this->pos, $n); $this->pos += strlen($c); return $c;
    }
    public function stream_eof(): bool { return $this->pos >= strlen(self::$data); }
    public function stream_stat() { return ['size' => strlen(self::$data)]; }
}
TestInputStream::$data = $body;
stream_wrapper_unregister('php');
stream_wrapper_register('php', 'TestInputStream');

⚠️ Run N=1 and Nβ‰₯2 in separate processes

tenantCount() memoises in a static, so isMultiTenant() is evaluated once per process and everything below inherits that answer. A script that checks N=1, inserts a second company, then checks isolation will report the second half as completely unscoped β€” the count is still cached at 1.

That looks exactly like "my guards don't work" and will send you debugging correct code. Separate database, separate process, per fixture.


πŸ—οΈ The fixture

Import database/freeitsm.sql into a throwaway schema β€” never mutate the dev database. This doubles as a fresh-install test: if the baseline has drifted from db_verify, the import or the first scoped query fails right there (#879).

putenv('MYSQL_PWD=' . DB_PASSWORD);   // keeps the password out of the command line and logs
shell_exec(sprintf('"%s" -u %s -h %s %s < "%s/database/freeitsm.sql"',
    $mysql, escapeshellarg(DB_USERNAME), escapeshellarg(DB_SERVER),
    escapeshellarg($TEST), $root));

A fixture that exercises the interesting cases:

Seed Why
Analyst all-access, analyst co4 only, analyst co6 only the three reaches that behave differently
A row in co4, a row in co6, a row with NULL NULL must resolve as Default's, not "everyone's"
A child in each company parent/child hydration
A deliberately cross-company link proves reads don't expose it and that the invariant blocks new ones
A deleted row (ticket in the recycle bin) catches missing deleted_datetime filters
A team granting company access getAccessibleTenantIds() unions analyst + team grants β€” a guard reading only analyst_tenant_access passes a naive test and is still wrong

Gotchas: freeitsm.sql already seeds the Default company as id 1 and default reference rows (relationship types, classes) β€” use INSERT IGNORE and look ids up rather than assuming.


πŸ“‹ What to assert

LIST      each analyst sees only their company's rows; all-access in Default
          context sees Default's (activeTenantFilter = the ACTIVE company,
          not "everything you could reach")
BY-ID     A cannot open B's; A CAN open its own (positive control);
          the refusal is framed as NOT-FOUND, never "forbidden"
NEIGHBOUR a linked row in another company is not NAMED in the payload,
          while a same-company one still is (positive control)
WRITE     create is stamped with the right company; A cannot create INTO B;
          A cannot update/delete B's; A CAN update its own (positive control)
INVARIANT cross-company link refused β€” INCLUDING for an all-access actor
COUNTS    sidebar/badge totals scope too (a COUNT of data is data)
REST      same set again via the API key's company_scope
N=1       every one of the above is a no-op

πŸ—‚οΈ The CMDB kit

Written for CMDB, but the runner and fixture are ~20 lines from being module-agnostic β€” copy them for Calendar, Forms or Network Mapper.

File Purpose
cmdb_build_fixture.php builds a scratch DB at N=1 or N=3 and seeds it
cmdb_endpoint_runner.php runs one real api/cmdb/ endpoint as a chosen analyst
tickets_endpoint_runner.php / nm_endpoint_runner.php same, other module folders (a one-line sed of the above)
cmdb_isolation_suite.php reads: list, by-id, impact, search, linked tickets, neighbours, counts
cmdb_write_suite.php writes: create/update/delete + the same-company invariant
cmdb_ticketlink_suite.php the ticket ↔ CI link, both directions
cmdb_rest_suite.php + cmdb_rest_call.php the REST resource with synthetic API keys
cmdb_slice1_test.php schema: fresh import, FK rule, column parity, drift self-check

Result: 83 checks, N=1 and N=3, in a few seconds.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally