← back to Norma
test(cycle6 Cody-gate): CRUD tests real org id-space; non-hollow admin E2E
2a8a6c76a7969f43bc07ec7713cc7513b593024e · 2026-08-06 12:49:52 -0700 · Steve
Cody VERIFIED the CRUD suite only tested the null/global path (which no real tenant takes)
and the admin E2E was hollow (silent skip + fixed sleep + body-visible-always-true).
- CRUD: scope each entity with the id-space its org_id FK actually requires — donations/
grants now POST with a real nonprofit_accounts.id (201, genuine org-scoped write path),
clients/library with a real organizations.id, journalists org-less. Proves the routes
work under real tenant scoping (not org=null). 35 assertions green.
- admin E2E: removed the silent-skip; each navigated tab must settle #main-content to
non-empty content, and >=3 tabs must be navigated (can't pass by skipping all). Waits on
networkidle, not a fixed timeout.
- Expanded the gated memo: the split FK makes a coherent staff org_id impossible (all seeded
users have org_id=null; null-org staff reads cross-tenant) — a second-order symptom of the
same data-model bug, gated to Steve (not app-fixable without the schema decision).
Full battery: api 195 / write 216 / crud 35 / regression 20 / e2e 9.
Files touched
M tests/api-crud-lifecycle.mjsM tests/e2e/admin.spec.ts
Diff
commit 2a8a6c76a7969f43bc07ec7713cc7513b593024e
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 12:49:52 2026 -0700
test(cycle6 Cody-gate): CRUD tests real org id-space; non-hollow admin E2E
Cody VERIFIED the CRUD suite only tested the null/global path (which no real tenant takes)
and the admin E2E was hollow (silent skip + fixed sleep + body-visible-always-true).
- CRUD: scope each entity with the id-space its org_id FK actually requires — donations/
grants now POST with a real nonprofit_accounts.id (201, genuine org-scoped write path),
clients/library with a real organizations.id, journalists org-less. Proves the routes
work under real tenant scoping (not org=null). 35 assertions green.
- admin E2E: removed the silent-skip; each navigated tab must settle #main-content to
non-empty content, and >=3 tabs must be navigated (can't pass by skipping all). Waits on
networkidle, not a fixed timeout.
- Expanded the gated memo: the split FK makes a coherent staff org_id impossible (all seeded
users have org_id=null; null-org staff reads cross-tenant) — a second-order symptom of the
same data-model bug, gated to Steve (not app-fixable without the schema decision).
Full battery: api 195 / write 216 / crud 35 / regression 20 / e2e 9.
---
tests/api-crud-lifecycle.mjs | 46 ++++++++++++++++++++++++++------------------
tests/e2e/admin.spec.ts | 23 +++++++++++++++-------
2 files changed, 43 insertions(+), 26 deletions(-)
diff --git a/tests/api-crud-lifecycle.mjs b/tests/api-crud-lifecycle.mjs
index f5adf0a..ec15c44 100644
--- a/tests/api-crud-lifecycle.mjs
+++ b/tests/api-crud-lifecycle.mjs
@@ -22,16 +22,21 @@ const TEST_DB = process.env.NORMA_TEST_DB || 'postgresql://127.0.0.1:5432/sdcc_t
// Each entity: create path + minimal valid payload, a field to prove the create
// persisted (read-back), and a PATCH field to prove update works.
-// `org: true` sends x-org-id (a real organizations id) — required for tables whose
-// org_id is NOT NULL and keyed to organizations (clients, library_items). The
-// nonprofit_accounts-FK tables (donations, grants) are created org-less (null) because
-// their org-scoped path is blocked by the gated FK inconsistency — see the memo.
+// `org` selects the id-space each entity's org_id FK actually requires, so the suite
+// exercises the REAL org-scoped write path (org filter + FK), not the null/global path:
+// 'org' → a real organizations.id (clients: NOT NULL no-FK; library_items: FK→organizations)
+// 'npa' → a real nonprofit_accounts.id (donations, grants: FK→nonprofit_accounts)
+// false → org-less (journalists: no org_id FK)
+// The APP supplies an organizations.id for ALL of these via getOrgId(), so the real
+// tenant UI flow for the 'npa' tables is broken — that app-level id-space conflation is
+// the gated data-model finding (pending-approval/norma-sessions-org-fk-datamodel.md). The
+// routes themselves work when given the FK-correct id, which is what this suite proves.
const ENTITIES = [
- { name: 'clients', path: '/api/clients', org: true, create: { first_name: 'Smoke', last_name: 'Test' }, verify: ['first_name', 'Smoke'], update: { city: 'SmokeCity' }, updated: ['city', 'SmokeCity'] },
- { name: 'donations', path: '/api/donations', org: false, create: { amount: 12.34 }, verify: ['amount', 12.34], update: { notes: 'smoke-updated' }, updated: ['notes', 'smoke-updated'] },
- { name: 'grants', path: '/api/grants', org: false, create: { title: 'Smoke Grant', funder: 'SmokeCo' }, verify: ['title', 'Smoke Grant'], update: { notes: 'smoke-updated' }, updated: ['notes', 'smoke-updated'] },
- { name: 'library', path: '/api/library', org: true, create: { item_type: 'template', title: 'Smoke Item' }, verify: ['title', 'Smoke Item'], update: { title: 'Smoke Updated' }, updated: ['title', 'Smoke Updated'] },
- { name: 'journalists', path: '/api/journalists', org: false, create: { name: 'Smoke J', outlet: 'SmokeOutlet' }, verify: ['name', 'Smoke J'], update: { beat: 'smoke-updated' }, updated: ['beat', 'smoke-updated'] },
+ { name: 'clients', path: '/api/clients', org: 'org', create: { first_name: 'Smoke', last_name: 'Test' }, verify: ['first_name', 'Smoke'], update: { city: 'SmokeCity' }, updated: ['city', 'SmokeCity'] },
+ { name: 'donations', path: '/api/donations', org: 'npa', create: { amount: 12.34 }, verify: ['amount', 12.34], update: { notes: 'smoke-updated' }, updated: ['notes', 'smoke-updated'] },
+ { name: 'grants', path: '/api/grants', org: 'npa', create: { title: 'Smoke Grant', funder: 'SmokeCo' }, verify: ['title', 'Smoke Grant'], update: { notes: 'smoke-updated' }, updated: ['notes', 'smoke-updated'] },
+ { name: 'library', path: '/api/library', org: 'org', create: { item_type: 'template', title: 'Smoke Item' }, verify: ['title', 'Smoke Item'], update: { title: 'Smoke Updated' }, updated: ['title', 'Smoke Updated'] },
+ { name: 'journalists', path: '/api/journalists', org: false, create: { name: 'Smoke J', outlet: 'SmokeOutlet' }, verify: ['name', 'Smoke J'], update: { beat: 'smoke-updated' }, updated: ['beat', 'smoke-updated'] },
];
let pass = 0, fail = 0;
@@ -48,12 +53,14 @@ async function login() {
});
return (res.headers.get('set-cookie') || '').match(/norma-auth=([^;]+)/)?.[1];
}
-async function resolveOrgId() {
+async function resolveOrgIds() {
try {
const c = new pg.Client({ connectionString: TEST_DB }); await c.connect();
- const r = await c.query('SELECT id FROM organizations LIMIT 1'); await c.end();
- return r.rows[0]?.id || null;
- } catch { return null; }
+ const org = (await c.query('SELECT id FROM organizations LIMIT 1')).rows[0]?.id || null;
+ const npa = (await c.query('SELECT id FROM nonprofit_accounts LIMIT 1')).rows[0]?.id || null;
+ await c.end();
+ return { org, npa };
+ } catch { return { org: null, npa: null }; }
}
// Find a uuid-shaped id at the top level or nested one level into the response.
function extractId(body) {
@@ -77,16 +84,17 @@ function fieldOf(body, field) {
async function main() {
console.log(`\n=== Norma CRUD lifecycle — ${BASE} ===\n`);
const cookie = await login();
- const orgId = await resolveOrgId();
+ const { org: orgId, npa: npaId } = await resolveOrgIds();
if (!cookie) { console.error('\x1b[31mFATAL\x1b[0m admin login failed — is :7411 up?'); process.exit(2); }
- // Org scope is applied PER ENTITY (see ENTITIES). The org_id FK is inconsistent across
- // tables (some -> organizations NOT NULL, some -> nonprofit_accounts nullable), a
- // data-model issue gated to Steve (pending-approval/norma-sessions-org-fk-datamodel.md).
- console.log(`org id (for org:true entities): ${orgId || '(none found)'}\n`);
+ // Org scope is applied PER ENTITY (see ENTITIES) using the id-space each FK requires.
+ // The split org_id FK (organizations vs nonprofit_accounts) is a data-model issue gated
+ // to Steve (pending-approval/norma-sessions-org-fk-datamodel.md).
+ console.log(`organizations id: ${orgId || '(none)'} · nonprofit_accounts id: ${npaId || '(none)'}\n`);
const base = { 'Content-Type': 'application/json', Cookie: `norma-auth=${cookie}` };
+ const orgHeader = (org) => (org === 'org' && orgId ? { 'x-org-id': orgId } : org === 'npa' && npaId ? { 'x-org-id': npaId } : {});
const req = (method, path, body, org) => fetch(`${BASE}${path}`, {
method,
- headers: org && orgId ? { ...base, 'x-org-id': orgId } : base,
+ headers: { ...base, ...orgHeader(org) },
body: body ? JSON.stringify(body) : undefined,
redirect: 'manual',
});
diff --git a/tests/e2e/admin.spec.ts b/tests/e2e/admin.spec.ts
index 6b327f0..2a33beb 100644
--- a/tests/e2e/admin.spec.ts
+++ b/tests/e2e/admin.spec.ts
@@ -26,16 +26,25 @@ test('admin loads the shell and navigates core tabs with no console errors', asy
// App shell nav present.
await expect(page.getByText('Dashboard', { exact: true }).first()).toBeVisible();
+ // The main content region the skip-link targets — used to assert each tab renders
+ // real content, not a blank/error-boundary shell.
+ const main = page.locator('#main-content');
+ await expect(main).toBeVisible();
- // Navigate a handful of core tabs; each should render without throwing.
- for (const tab of ['Grants', 'Donations', 'Petitions', 'News']) {
+ // Navigate core tabs. For each one present, clicking it MUST settle #main-content to
+ // non-empty content (a blank/error-boundary tab fails here). We require at least 3 real
+ // navigations so the test can't pass by silently skipping every tab (a hollow check).
+ const candidates = ['Grants', 'Petitions', 'News', 'Drafts', 'Statements', 'Pipeline', 'Donations', 'Clients / Borrowers'];
+ let navigated = 0;
+ for (const tab of candidates) {
const item = page.getByText(tab, { exact: true }).first();
- if (await item.count()) {
- await item.click();
- await page.waitForTimeout(700);
- await expect(page.locator('body')).toBeVisible();
- }
+ if (!(await item.count())) continue;
+ await item.click();
+ await page.waitForLoadState('networkidle');
+ await expect(main, `"${tab}" tab should render non-empty content`).not.toBeEmpty();
+ navigated++;
}
+ expect(navigated, 'should have navigated at least 3 admin tabs with real content').toBeGreaterThanOrEqual(3);
const real = errors.filter((e) => !BENIGN.test(e));
expect(real, `console errors during admin navigation: ${real.join(' | ')}`).toHaveLength(0);
← e958e07 test(cycle6): CRUD lifecycle suite (create->read->update->de
·
back to Norma
·
test(cycle7): authorization-matrix + tenant-isolation suite f7e95d8 →