Multi Tenancy Test Harness - edmozley/freeitsm GitHub Wiki
๐งช 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
- Developer Guide โ the recipe
- CMDB case study โ what these tests actually caught
- Database Verification โ schema drift, and why a fresh-install test matters