From 0cd478b3f1baaca91d8f77e4217af34822b379dd Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 18 May 2026 01:52:38 -0500 Subject: [PATCH 1/3] test(integration): cascade auth helpers against real Postgres MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the integration tier started in #117 with coverage of the four cascade-scoped auth helpers: getCompanyIdByCustomerId(custId) Customer-scoped entities getCompanyIdByJobId(jobId) Job → Customer → company getCompanyIdByPovId(povId) Vendor-scoped entities getCompanyIdByPohId(pohId) Header → Vendor → company Real-PG coverage matters because: 1. The `required: true` INNER JOIN semantics mean an archived parent in the cascade silently drops the whole row to -1 — the correct security behavior, but only a live DB verifies the SQL emits the expected join. 2. P5-M moved every helper from raw `sequelize.query` strings to Sequelize model includes; unit-level fixtures exercise result- shape mapping but not the generated JOIN. Tests - Happy-path resolution for each of the four helpers. - All four return -1 for a nonexistent parent id. - Cascade INNER JOIN drops the row when an intermediate parent is archived (pinned: Job → archived Customer → -1). - Cleanup uses raw DELETEs so defaultScope doesn't hide the sentinel-archived rows we intentionally inserted. Full suite: 484 pass / 15 skip (was 484/9 — +6 cases that run only under the CI Postgres service from #91). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../integration/auth-cascade-helpers.test.js | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 tests/integration/auth-cascade-helpers.test.js diff --git a/tests/integration/auth-cascade-helpers.test.js b/tests/integration/auth-cascade-helpers.test.js new file mode 100644 index 0000000..c3c7af5 --- /dev/null +++ b/tests/integration/auth-cascade-helpers.test.js @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// Integration tests for the cascade-scoped auth helpers in +// `app/middleware/auth.js`: +// +// getCompanyIdByCustomerId(custId) — Customer-scoped entities +// getCompanyIdByJobId(jobId) — Job → Customer → company +// getCompanyIdByPovId(povId) — Vendor-scoped entities +// getCompanyIdByPohId(pohId) — Header → Vendor → company +// +// These helpers issue Sequelize queries with `include` joins. Real-PG +// coverage matters because: +// +// 1. The `required: true` INNER JOIN semantics mean an archived +// parent in the cascade silently drops the whole row to -1 — +// that's the correct security behavior (the parent's scope no +// longer applies), but only a live DB verifies the SQL emits it. +// +// 2. P5-M moved every helper from raw `sequelize.query` strings to +// Sequelize model includes; the unit-level fixtures don't +// exercise the actual JOIN generation, only the result-shape +// mapping. + +import { describe, test, expect, beforeAll, afterAll } from 'vitest'; + +const HAS_DB = Boolean(process.env.DB_PASSWORD); + +const SENTINEL = `_integ_cascade_${process.pid}_${Date.now()}`; + +let db; +let auth; +let connected = false; +let companyId; +let customerId; +let jobId; +let vendorId; +let headerId; + +beforeAll(async () => { + if (!HAS_DB) return; + db = require('../../app/config/db.config.js'); + auth = require('../../app/middleware/auth.js'); + try { + await db.sequelize.authenticate(); + connected = true; + } catch (err) { + console.warn('[auth-cascade] PG unreachable, skipping:', err.message); + return; + } + + // Build the cascade: Company → Customer → Job, and + // Company → Vendor → Header. Each step uses the SENTINEL prefix + // so cleanup is easy. + const company = await db.Company.create({ + compName: `${SENTINEL}-company`, + compArch: false, + }); + companyId = company.compId; + + const customer = await db.Customer.create({ + custCompanyName: `${SENTINEL}-customer`, + custCompId: companyId, + custArch: false, + }); + customerId = customer.custId; + + const job = await db.Job.create({ + jobCustId: customerId, + jobDesc: `${SENTINEL}-job`, + jobArch: false, + }); + jobId = job.jobId; + + const vendor = await db.PurchaseOrderVendor.create({ + povName: `${SENTINEL}-vendor`, + povMailingAddress1: '123 Test St', + povMailingCity: 'Lincoln', + povCompId: companyId, + povArch: false, + }); + vendorId = vendor.povId; + + const header = await db.PurchaseOrderHeader.create({ + pohDate: new Date(), + pohReference: `${SENTINEL}-poh`, + pohTerms: 'Net 30', + pohPovId: vendorId, + pohArch: false, + }); + headerId = header.pohId; +}, 30000); + +afterAll(async () => { + if (!connected || !db) return; + try { + // FK-aware cleanup: lines/headers first, then vendors; + // jobs/customers/company last. Use raw DELETE so default + // scope doesn't hide our archived sentinel rows. + await db.sequelize.query( + 'DELETE FROM "dbo"."PurchaseOrderHeaders" WHERE "pohReference" LIKE ?', + { replacements: [`${SENTINEL}%`] }, + ); + await db.sequelize.query( + 'DELETE FROM "dbo"."PurchaseOrderVendors" WHERE "povName" LIKE ?', + { replacements: [`${SENTINEL}%`] }, + ); + await db.sequelize.query( + 'DELETE FROM "dbo"."Job" WHERE "jobDesc" LIKE ?', + { replacements: [`${SENTINEL}%`] }, + ); + await db.sequelize.query( + 'DELETE FROM "dbo"."Customer" WHERE "custCompanyName" LIKE ?', + { replacements: [`${SENTINEL}%`] }, + ); + await db.sequelize.query( + 'DELETE FROM "dbo"."Company" WHERE "compName" LIKE ?', + { replacements: [`${SENTINEL}%`] }, + ); + } catch (e) { + console.warn('[auth-cascade] cleanup failed:', e.message); + } +}); + +describe.skipIf(!HAS_DB)('integration: cascade auth helpers against real PG', () => { + test('getCompanyIdByCustomerId resolves through the Customer.custCompId column', async () => { + if (!connected) return; + expect(await auth.getCompanyIdByCustomerId(customerId)).toBe(companyId); + }); + + test('getCompanyIdByJobId resolves through Job → Customer → custCompId', async () => { + if (!connected) return; + expect(await auth.getCompanyIdByJobId(jobId)).toBe(companyId); + }); + + test('getCompanyIdByPovId resolves through the Vendor.povCompId column', async () => { + if (!connected) return; + expect(await auth.getCompanyIdByPovId(vendorId)).toBe(companyId); + }); + + test('getCompanyIdByPohId resolves through Header → Vendor → povCompId', async () => { + if (!connected) return; + expect(await auth.getCompanyIdByPohId(headerId)).toBe(companyId); + }); + + test('helpers return -1 for nonexistent parent ids', async () => { + if (!connected) return; + const huge = 2_000_000_000; + expect(await auth.getCompanyIdByCustomerId(huge)).toBe(-1); + expect(await auth.getCompanyIdByJobId(huge)).toBe(-1); + expect(await auth.getCompanyIdByPovId(huge)).toBe(-1); + expect(await auth.getCompanyIdByPohId(huge)).toBe(-1); + }); + + test('cascade INNER JOIN drops the row when an intermediate parent is archived', async () => { + if (!connected) return; + // Build an isolated chain: company → customer → job, then archive + // the customer. getCompanyIdByJobId should now return -1 because + // the join's `required: true` against defaultScope-filtered + // Customer rules out the orphaned-feeling job. + const isolatedCompany = await db.Company.create({ + compName: `${SENTINEL}-isolated-company`, + compArch: false, + }); + const archivedCustomer = await db.Customer.create({ + custCompanyName: `${SENTINEL}-arch-customer`, + custCompId: isolatedCompany.compId, + custArch: true, // pre-archived + }); + const orphanJob = await db.Job.create({ + jobCustId: archivedCustomer.custId, + jobDesc: `${SENTINEL}-orphan-job`, + jobArch: false, + }); + expect(await auth.getCompanyIdByJobId(orphanJob.jobId)).toBe(-1); + }); +}); From cdec8e1a17afab925b2b1c70449f6ac79f3a14c6 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 18 May 2026 01:55:56 -0500 Subject: [PATCH 2/3] fix(integration): supply NOT NULL fields on cascade test inserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed with: SequelizeDatabaseError: null value in column "custFName" of relation "Customer" violates not-null constraint The Atbash baseline declares custFName, custLName as NOT NULL on Customer, and jobInvoiced as NOT NULL on Job. The cascade integration test (#121) didn't set them. Fixed by adding the required fields to all three inserts (one happy-path Customer, two cases in the archived-cascade scenario). Caught exactly the kind of regression the CI Postgres tier exists for — the unit fixtures would have happily passed nulls. --- tests/integration/auth-cascade-helpers.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/integration/auth-cascade-helpers.test.js b/tests/integration/auth-cascade-helpers.test.js index c3c7af5..d886c17 100644 --- a/tests/integration/auth-cascade-helpers.test.js +++ b/tests/integration/auth-cascade-helpers.test.js @@ -60,6 +60,8 @@ beforeAll(async () => { const customer = await db.Customer.create({ custCompanyName: `${SENTINEL}-customer`, + custFName: 'Cascade', + custLName: 'Test', custCompId: companyId, custArch: false, }); @@ -69,6 +71,7 @@ beforeAll(async () => { jobCustId: customerId, jobDesc: `${SENTINEL}-job`, jobArch: false, + jobInvoiced: false, }); jobId = job.jobId; @@ -164,6 +167,8 @@ describe.skipIf(!HAS_DB)('integration: cascade auth helpers against real PG', () }); const archivedCustomer = await db.Customer.create({ custCompanyName: `${SENTINEL}-arch-customer`, + custFName: 'Arch', + custLName: 'Test', custCompId: isolatedCompany.compId, custArch: true, // pre-archived }); @@ -171,6 +176,7 @@ describe.skipIf(!HAS_DB)('integration: cascade auth helpers against real PG', () jobCustId: archivedCustomer.custId, jobDesc: `${SENTINEL}-orphan-job`, jobArch: false, + jobInvoiced: false, }); expect(await auth.getCompanyIdByJobId(orphanJob.jobId)).toBe(-1); }); From 20726e5e2f4fcdea6cdd8bd8065911dca6989519 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Mon, 18 May 2026 02:00:03 -0500 Subject: [PATCH 3/3] fix(auth): cascade helpers need the Sequelize association alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration tests I'm about to land caught a latent bug in P5-M's auth.js refactor: getCompanyIdByJobId and getCompanyIdByPohId use Sequelize `include` without the `as:` alias, but db.config.js registers both associations with explicit aliases: db.Job.belongsTo(db.Customer, { foreignKey: 'jobCustId', as: 'customer' }); db.PurchaseOrderHeader.belongsTo(db.PurchaseOrderVendor, { foreignKey: 'pohPovId', as: 'vendor' }); Without `as: 'customer'` / `as: 'vendor'` on the include, Sequelize silently returns no rows (the unaliased association doesn't exist). The helpers then return -1 — meaning every non-master InvoiceJob / ProductEntry / PurchaseOrderLine request in production was 403'ing on every scoping check, despite the call being legitimate. Fix: pass the alias on the include, and read the loaded child from the alias property (`row.customer`, `row.vendor`) rather than the non-existent default name. Unit-test fixtures updated to use the aliased property names (`{ customer: { custCompId } }` instead of `{ Customer: ... }`). The integration suite added in this PR is what surfaced the bug to begin with — exactly the failure class it exists to catch. Tests: 484 pass / 15 skip locally. Lint clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/middleware/auth.js | 19 ++++++++++++++----- tests/unit/auth.test.js | 18 ++++++++++++------ 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/app/middleware/auth.js b/app/middleware/auth.js index 84b1713..0770deb 100644 --- a/app/middleware/auth.js +++ b/app/middleware/auth.js @@ -183,16 +183,20 @@ async function getCompanyIdByPohId(pohId) { const row = await getDb().PurchaseOrderHeader.findByPk(pohId, { attributes: ['pohId'], include: [{ + // db.config.js registers this association with + // `as: 'vendor'`. Without the alias the include + // silently matches nothing — was a latent bug in + // P5-M caught by the cascade integration suite. model: getDb().PurchaseOrderVendor, + as: 'vendor', attributes: ['povCompId'], required: true, }], }); if (!row) return -1; - // Association produces row.PurchaseOrderVendor (singular, - // belongsTo). defaultScope on PurchaseOrderVendor filters - // archived vendors automatically. - const vendor = row.PurchaseOrderVendor || row.purchaseOrderVendor; + // defaultScope on PurchaseOrderVendor filters archived + // vendors automatically. + const vendor = row.vendor; if (!vendor) return -1; const cid = vendor.povCompId; return typeof cid === 'number' && cid > 0 ? cid : -1; @@ -216,13 +220,18 @@ async function getCompanyIdByJobId(jobId) { const row = await getDb().Job.findByPk(jobId, { attributes: ['jobId'], include: [{ + // db.config.js registers this association with + // `as: 'customer'`. Without the alias the include + // silently matches nothing — was a latent bug in + // P5-M caught by the cascade integration suite. model: getDb().Customer, + as: 'customer', attributes: ['custCompId'], required: true, }], }); if (!row) return -1; - const customer = row.Customer || row.customer; + const customer = row.customer; if (!customer) return -1; const cid = customer.custCompId; return typeof cid === 'number' && cid > 0 ? cid : -1; diff --git a/tests/unit/auth.test.js b/tests/unit/auth.test.js index fe8d61b..a4e4c5e 100644 --- a/tests/unit/auth.test.js +++ b/tests/unit/auth.test.js @@ -140,10 +140,14 @@ describe('auth.getCompanyIdByPovId', () => { }); describe('auth.getCompanyIdByPohId', () => { - test('resolves through the eager-loaded vendor association', async () => { + test('resolves through the eager-loaded vendor association (alias: vendor)', async () => { + // db.config.js registers this association as `as: 'vendor'` + // so Sequelize attaches the loaded row at `row.vendor`, not + // `row.PurchaseOrderVendor`. Using the unaliased name was the + // P5-M latent bug fixed alongside this test. stub.PurchaseOrderHeader.findByPk.mockResolvedValueOnce({ pohId: 1, - PurchaseOrderVendor: { povCompId: 9 }, + vendor: { povCompId: 9 }, }); expect(await auth.getCompanyIdByPohId(1)).toBe(9); }); @@ -156,17 +160,19 @@ describe('auth.getCompanyIdByPohId', () => { test('returns -1 if header has no vendor (broken FK)', async () => { stub.PurchaseOrderHeader.findByPk.mockResolvedValueOnce({ pohId: 1, - PurchaseOrderVendor: null, + vendor: null, }); expect(await auth.getCompanyIdByPohId(1)).toBe(-1); }); }); describe('auth.getCompanyIdByJobId', () => { - test('resolves through the eager-loaded customer association', async () => { + test('resolves through the eager-loaded customer association (alias: customer)', async () => { + // Same alias gotcha as getCompanyIdByPohId above. db.config.js + // uses `as: 'customer'` for Job → Customer. stub.Job.findByPk.mockResolvedValueOnce({ jobId: 1, - Customer: { custCompId: 12 }, + customer: { custCompId: 12 }, }); expect(await auth.getCompanyIdByJobId(1)).toBe(12); }); @@ -177,7 +183,7 @@ describe('auth.getCompanyIdByJobId', () => { }); test('returns -1 if the job has no customer linkage', async () => { - stub.Job.findByPk.mockResolvedValueOnce({ jobId: 1, Customer: null }); + stub.Job.findByPk.mockResolvedValueOnce({ jobId: 1, customer: null }); expect(await auth.getCompanyIdByJobId(1)).toBe(-1); }); });