Skip to content

Commit 9eebbac

Browse files
CryptoJonesAaron K. Clarkclaude
authored
test(integration): cascade auth helpers against real Postgres (#121)
* test(integration): cascade auth helpers against real Postgres 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) <noreply@anthropic.com> * fix(integration): supply NOT NULL fields on cascade test inserts 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. * fix(auth): cascade helpers need the Sequelize association alias 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) <noreply@anthropic.com> --------- Co-authored-by: Aaron K. Clark <akclark@thenetwerk.net> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 53f9a35 commit 9eebbac

3 files changed

Lines changed: 209 additions & 11 deletions

File tree

app/middleware/auth.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,20 @@ async function getCompanyIdByPohId(pohId) {
183183
const row = await getDb().PurchaseOrderHeader.findByPk(pohId, {
184184
attributes: ['pohId'],
185185
include: [{
186+
// db.config.js registers this association with
187+
// `as: 'vendor'`. Without the alias the include
188+
// silently matches nothing — was a latent bug in
189+
// P5-M caught by the cascade integration suite.
186190
model: getDb().PurchaseOrderVendor,
191+
as: 'vendor',
187192
attributes: ['povCompId'],
188193
required: true,
189194
}],
190195
});
191196
if (!row) return -1;
192-
// Association produces row.PurchaseOrderVendor (singular,
193-
// belongsTo). defaultScope on PurchaseOrderVendor filters
194-
// archived vendors automatically.
195-
const vendor = row.PurchaseOrderVendor || row.purchaseOrderVendor;
197+
// defaultScope on PurchaseOrderVendor filters archived
198+
// vendors automatically.
199+
const vendor = row.vendor;
196200
if (!vendor) return -1;
197201
const cid = vendor.povCompId;
198202
return typeof cid === 'number' && cid > 0 ? cid : -1;
@@ -216,13 +220,18 @@ async function getCompanyIdByJobId(jobId) {
216220
const row = await getDb().Job.findByPk(jobId, {
217221
attributes: ['jobId'],
218222
include: [{
223+
// db.config.js registers this association with
224+
// `as: 'customer'`. Without the alias the include
225+
// silently matches nothing — was a latent bug in
226+
// P5-M caught by the cascade integration suite.
219227
model: getDb().Customer,
228+
as: 'customer',
220229
attributes: ['custCompId'],
221230
required: true,
222231
}],
223232
});
224233
if (!row) return -1;
225-
const customer = row.Customer || row.customer;
234+
const customer = row.customer;
226235
if (!customer) return -1;
227236
const cid = customer.custCompId;
228237
return typeof cid === 'number' && cid > 0 ? cid : -1;
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// Copyright 2026 Aaron K. Clark
3+
//
4+
// Integration tests for the cascade-scoped auth helpers in
5+
// `app/middleware/auth.js`:
6+
//
7+
// getCompanyIdByCustomerId(custId) — Customer-scoped entities
8+
// getCompanyIdByJobId(jobId) — Job → Customer → company
9+
// getCompanyIdByPovId(povId) — Vendor-scoped entities
10+
// getCompanyIdByPohId(pohId) — Header → Vendor → company
11+
//
12+
// These helpers issue Sequelize queries with `include` joins. Real-PG
13+
// coverage matters because:
14+
//
15+
// 1. The `required: true` INNER JOIN semantics mean an archived
16+
// parent in the cascade silently drops the whole row to -1 —
17+
// that's the correct security behavior (the parent's scope no
18+
// longer applies), but only a live DB verifies the SQL emits it.
19+
//
20+
// 2. P5-M moved every helper from raw `sequelize.query` strings to
21+
// Sequelize model includes; the unit-level fixtures don't
22+
// exercise the actual JOIN generation, only the result-shape
23+
// mapping.
24+
25+
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
26+
27+
const HAS_DB = Boolean(process.env.DB_PASSWORD);
28+
29+
const SENTINEL = `_integ_cascade_${process.pid}_${Date.now()}`;
30+
31+
let db;
32+
let auth;
33+
let connected = false;
34+
let companyId;
35+
let customerId;
36+
let jobId;
37+
let vendorId;
38+
let headerId;
39+
40+
beforeAll(async () => {
41+
if (!HAS_DB) return;
42+
db = require('../../app/config/db.config.js');
43+
auth = require('../../app/middleware/auth.js');
44+
try {
45+
await db.sequelize.authenticate();
46+
connected = true;
47+
} catch (err) {
48+
console.warn('[auth-cascade] PG unreachable, skipping:', err.message);
49+
return;
50+
}
51+
52+
// Build the cascade: Company → Customer → Job, and
53+
// Company → Vendor → Header. Each step uses the SENTINEL prefix
54+
// so cleanup is easy.
55+
const company = await db.Company.create({
56+
compName: `${SENTINEL}-company`,
57+
compArch: false,
58+
});
59+
companyId = company.compId;
60+
61+
const customer = await db.Customer.create({
62+
custCompanyName: `${SENTINEL}-customer`,
63+
custFName: 'Cascade',
64+
custLName: 'Test',
65+
custCompId: companyId,
66+
custArch: false,
67+
});
68+
customerId = customer.custId;
69+
70+
const job = await db.Job.create({
71+
jobCustId: customerId,
72+
jobDesc: `${SENTINEL}-job`,
73+
jobArch: false,
74+
jobInvoiced: false,
75+
});
76+
jobId = job.jobId;
77+
78+
const vendor = await db.PurchaseOrderVendor.create({
79+
povName: `${SENTINEL}-vendor`,
80+
povMailingAddress1: '123 Test St',
81+
povMailingCity: 'Lincoln',
82+
povCompId: companyId,
83+
povArch: false,
84+
});
85+
vendorId = vendor.povId;
86+
87+
const header = await db.PurchaseOrderHeader.create({
88+
pohDate: new Date(),
89+
pohReference: `${SENTINEL}-poh`,
90+
pohTerms: 'Net 30',
91+
pohPovId: vendorId,
92+
pohArch: false,
93+
});
94+
headerId = header.pohId;
95+
}, 30000);
96+
97+
afterAll(async () => {
98+
if (!connected || !db) return;
99+
try {
100+
// FK-aware cleanup: lines/headers first, then vendors;
101+
// jobs/customers/company last. Use raw DELETE so default
102+
// scope doesn't hide our archived sentinel rows.
103+
await db.sequelize.query(
104+
'DELETE FROM "dbo"."PurchaseOrderHeaders" WHERE "pohReference" LIKE ?',
105+
{ replacements: [`${SENTINEL}%`] },
106+
);
107+
await db.sequelize.query(
108+
'DELETE FROM "dbo"."PurchaseOrderVendors" WHERE "povName" LIKE ?',
109+
{ replacements: [`${SENTINEL}%`] },
110+
);
111+
await db.sequelize.query(
112+
'DELETE FROM "dbo"."Job" WHERE "jobDesc" LIKE ?',
113+
{ replacements: [`${SENTINEL}%`] },
114+
);
115+
await db.sequelize.query(
116+
'DELETE FROM "dbo"."Customer" WHERE "custCompanyName" LIKE ?',
117+
{ replacements: [`${SENTINEL}%`] },
118+
);
119+
await db.sequelize.query(
120+
'DELETE FROM "dbo"."Company" WHERE "compName" LIKE ?',
121+
{ replacements: [`${SENTINEL}%`] },
122+
);
123+
} catch (e) {
124+
console.warn('[auth-cascade] cleanup failed:', e.message);
125+
}
126+
});
127+
128+
describe.skipIf(!HAS_DB)('integration: cascade auth helpers against real PG', () => {
129+
test('getCompanyIdByCustomerId resolves through the Customer.custCompId column', async () => {
130+
if (!connected) return;
131+
expect(await auth.getCompanyIdByCustomerId(customerId)).toBe(companyId);
132+
});
133+
134+
test('getCompanyIdByJobId resolves through Job → Customer → custCompId', async () => {
135+
if (!connected) return;
136+
expect(await auth.getCompanyIdByJobId(jobId)).toBe(companyId);
137+
});
138+
139+
test('getCompanyIdByPovId resolves through the Vendor.povCompId column', async () => {
140+
if (!connected) return;
141+
expect(await auth.getCompanyIdByPovId(vendorId)).toBe(companyId);
142+
});
143+
144+
test('getCompanyIdByPohId resolves through Header → Vendor → povCompId', async () => {
145+
if (!connected) return;
146+
expect(await auth.getCompanyIdByPohId(headerId)).toBe(companyId);
147+
});
148+
149+
test('helpers return -1 for nonexistent parent ids', async () => {
150+
if (!connected) return;
151+
const huge = 2_000_000_000;
152+
expect(await auth.getCompanyIdByCustomerId(huge)).toBe(-1);
153+
expect(await auth.getCompanyIdByJobId(huge)).toBe(-1);
154+
expect(await auth.getCompanyIdByPovId(huge)).toBe(-1);
155+
expect(await auth.getCompanyIdByPohId(huge)).toBe(-1);
156+
});
157+
158+
test('cascade INNER JOIN drops the row when an intermediate parent is archived', async () => {
159+
if (!connected) return;
160+
// Build an isolated chain: company → customer → job, then archive
161+
// the customer. getCompanyIdByJobId should now return -1 because
162+
// the join's `required: true` against defaultScope-filtered
163+
// Customer rules out the orphaned-feeling job.
164+
const isolatedCompany = await db.Company.create({
165+
compName: `${SENTINEL}-isolated-company`,
166+
compArch: false,
167+
});
168+
const archivedCustomer = await db.Customer.create({
169+
custCompanyName: `${SENTINEL}-arch-customer`,
170+
custFName: 'Arch',
171+
custLName: 'Test',
172+
custCompId: isolatedCompany.compId,
173+
custArch: true, // pre-archived
174+
});
175+
const orphanJob = await db.Job.create({
176+
jobCustId: archivedCustomer.custId,
177+
jobDesc: `${SENTINEL}-orphan-job`,
178+
jobArch: false,
179+
jobInvoiced: false,
180+
});
181+
expect(await auth.getCompanyIdByJobId(orphanJob.jobId)).toBe(-1);
182+
});
183+
});

tests/unit/auth.test.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,14 @@ describe('auth.getCompanyIdByPovId', () => {
140140
});
141141

142142
describe('auth.getCompanyIdByPohId', () => {
143-
test('resolves through the eager-loaded vendor association', async () => {
143+
test('resolves through the eager-loaded vendor association (alias: vendor)', async () => {
144+
// db.config.js registers this association as `as: 'vendor'`
145+
// so Sequelize attaches the loaded row at `row.vendor`, not
146+
// `row.PurchaseOrderVendor`. Using the unaliased name was the
147+
// P5-M latent bug fixed alongside this test.
144148
stub.PurchaseOrderHeader.findByPk.mockResolvedValueOnce({
145149
pohId: 1,
146-
PurchaseOrderVendor: { povCompId: 9 },
150+
vendor: { povCompId: 9 },
147151
});
148152
expect(await auth.getCompanyIdByPohId(1)).toBe(9);
149153
});
@@ -156,17 +160,19 @@ describe('auth.getCompanyIdByPohId', () => {
156160
test('returns -1 if header has no vendor (broken FK)', async () => {
157161
stub.PurchaseOrderHeader.findByPk.mockResolvedValueOnce({
158162
pohId: 1,
159-
PurchaseOrderVendor: null,
163+
vendor: null,
160164
});
161165
expect(await auth.getCompanyIdByPohId(1)).toBe(-1);
162166
});
163167
});
164168

165169
describe('auth.getCompanyIdByJobId', () => {
166-
test('resolves through the eager-loaded customer association', async () => {
170+
test('resolves through the eager-loaded customer association (alias: customer)', async () => {
171+
// Same alias gotcha as getCompanyIdByPohId above. db.config.js
172+
// uses `as: 'customer'` for Job → Customer.
167173
stub.Job.findByPk.mockResolvedValueOnce({
168174
jobId: 1,
169-
Customer: { custCompId: 12 },
175+
customer: { custCompId: 12 },
170176
});
171177
expect(await auth.getCompanyIdByJobId(1)).toBe(12);
172178
});
@@ -177,7 +183,7 @@ describe('auth.getCompanyIdByJobId', () => {
177183
});
178184

179185
test('returns -1 if the job has no customer linkage', async () => {
180-
stub.Job.findByPk.mockResolvedValueOnce({ jobId: 1, Customer: null });
186+
stub.Job.findByPk.mockResolvedValueOnce({ jobId: 1, customer: null });
181187
expect(await auth.getCompanyIdByJobId(1)).toBe(-1);
182188
});
183189
});

0 commit comments

Comments
 (0)