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/integration/auth-cascade-helpers.test.js b/tests/integration/auth-cascade-helpers.test.js new file mode 100644 index 0000000..d886c17 --- /dev/null +++ b/tests/integration/auth-cascade-helpers.test.js @@ -0,0 +1,183 @@ +// 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`, + custFName: 'Cascade', + custLName: 'Test', + custCompId: companyId, + custArch: false, + }); + customerId = customer.custId; + + const job = await db.Job.create({ + jobCustId: customerId, + jobDesc: `${SENTINEL}-job`, + jobArch: false, + jobInvoiced: 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`, + custFName: 'Arch', + custLName: 'Test', + custCompId: isolatedCompany.compId, + custArch: true, // pre-archived + }); + const orphanJob = await db.Job.create({ + jobCustId: archivedCustomer.custId, + jobDesc: `${SENTINEL}-orphan-job`, + jobArch: false, + jobInvoiced: false, + }); + expect(await auth.getCompanyIdByJobId(orphanJob.jobId)).toBe(-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); }); });