Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions app/middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
183 changes: 183 additions & 0 deletions tests/integration/auth-cascade-helpers.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 12 additions & 6 deletions tests/unit/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand All @@ -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);
});
});
Expand Down