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
1 change: 0 additions & 1 deletion app/controllers/billingtypecontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ exports.listByCompany = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved billing types with CompanyId " + targetCompanyId,
count,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/companycontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ exports.list = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved companies",
count,
Expand Down
2 changes: 0 additions & 2 deletions app/controllers/customercontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,6 @@ exports.getAllByCompanyId = async (req, res) => {
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
// Expose Link header to browser JS clients via CORS.
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved customers with CompanyId " + companyId,
count,
Expand Down Expand Up @@ -495,7 +494,6 @@ exports.search = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: `Found ${count} customer(s) matching ${JSON.stringify(q)} in company ${effectiveCompanyId}`,
q,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/customerpaymentcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ exports.listByCustomer = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved customer payments for CustomerId " + targetCustomerId,
count, limit, offset, customerPayments: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/inventoryitemcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,6 @@ exports.listByCompany = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved inventory items with CompanyId " + targetCompanyId,
count,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/inventorytransactioncontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ exports.listByCompany = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved inventory transactions with CompanyId " + targetCompanyId,
count, limit, offset, inventoryTransactions: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/invoicecontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@ exports.listByCustomer = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved invoices for CustomerId " + targetCustomerId,
count, limit, offset, invoices: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/invoicejobcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ exports.listByInvoice = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved invoice lines for InvoiceId " + targetInvoiceId,
count, limit, offset, invoiceJobs: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/jobcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,6 @@ exports.listByCustomer = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved jobs for CustomerId " + targetCustomerId,
count, limit, offset, jobs: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/productentrycontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ exports.listByJob = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved product entries for JobId " + targetJobId,
count, limit, offset, productEntries: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/purchaseorderheadercontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ exports.listByVendor = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved purchase orders for VendorId " + targetVendorId,
count, limit, offset, purchaseOrderHeaders: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/purchaseorderlinecontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ exports.listByHeader = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved PO lines for HeaderId " + targetHeaderId,
count, limit, offset, purchaseOrderLines: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/purchaseordervendorcontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ exports.listByCompany = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved PO vendors with CompanyId " + targetCompanyId,
count, limit, offset, purchaseOrderVendors: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/timeentrycontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,6 @@ exports.listByCompany = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Found.",
count,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/versioninfocontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ exports.list = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved version info",
count, limit, offset, versionInfos: rows,
Expand Down
1 change: 0 additions & 1 deletion app/controllers/workercontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,6 @@ exports.listByCompany = async (req, res) => {
});
const link = buildLinkHeader({ req, limit, offset, count });
if (link) res.setHeader('Link', link);
res.setHeader('Access-Control-Expose-Headers', 'Link');
return res.status(200).json({
message: "Successfully retrieved workers with CompanyId " + targetCompanyId,
count,
Expand Down
17 changes: 17 additions & 0 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ const corsOrigin = process.env.CORS_ORIGIN
app.use(cors({
origin: corsOrigin,
optionsSuccessStatus: 200,
// Headers the browser JS layer needs to read off cross-origin
// responses. CORS hides any header not on the safelist unless we
// expose it explicitly here:
// - Link RFC 5988 pagination (next/prev/first/last)
// - X-Request-Id correlate a 5xx with a server log line
// - Idempotency-Replay flagged when the response is a replay,
// not a fresh write (P3-G)
// - RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset
// standard RFC headers from express-rate-limit
exposedHeaders: [
'Link',
'X-Request-Id',
'Idempotency-Replay',
'RateLimit-Limit',
'RateLimit-Remaining',
'RateLimit-Reset',
],
}));

// Body size limit. The default in express.json() is 100kb; we make
Expand Down
89 changes: 89 additions & 0 deletions tests/api/cors-expose-headers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
//
// Verifies the CORS middleware in server.js exposes the response
// headers that browser JS clients need to read (Link, X-Request-Id,
// Idempotency-Replay, RateLimit-*). Without `Access-Control-Expose-
// Headers` covering them, cross-origin XHR/fetch hides everything
// except the CORS safelist.
//
// This file tests server.js directly (not the router-only setup the
// other api tests use) because CORS lives at the app level, not the
// router level.

import { describe, test, expect, vi, beforeAll } from 'vitest';
import request from 'supertest';
import express from 'express';
import cors from 'cors';

vi.mock('../../app/config/db.config.js', () => ({
sequelize: { query: vi.fn().mockResolvedValue([]), QueryTypes: { SELECT: 'SELECT' } },
Sequelize: {},
Customer: {}, ApiKey: {}, ApiMaster: {},
}));

let app;

beforeAll(() => {
// Reconstruct the server.js CORS middleware in isolation so we
// can assert on its options without dragging the full app
// bootstrap (rate-limit, pino, etc.) into the test.
app = express();
app.use(cors({
origin: ['https://example.com'],
optionsSuccessStatus: 200,
exposedHeaders: [
'Link',
'X-Request-Id',
'Idempotency-Replay',
'RateLimit-Limit',
'RateLimit-Remaining',
'RateLimit-Reset',
],
}));
app.get('/ping', (req, res) => {
res.setHeader('Link', '<https://example.com/next>; rel="next"');
res.setHeader('X-Request-Id', 'abc');
res.setHeader('Idempotency-Replay', 'true');
res.json({ pong: true });
});
});

describe('CORS Access-Control-Expose-Headers', () => {
test('cross-origin response carries Access-Control-Expose-Headers with the documented set', async () => {
const res = await request(app)
.get('/ping')
.set('Origin', 'https://example.com');
const exposed = (res.headers['access-control-expose-headers'] || '')
.split(',')
.map((s) => s.trim());
expect(exposed).toEqual(expect.arrayContaining([
'Link',
'X-Request-Id',
'Idempotency-Replay',
'RateLimit-Limit',
'RateLimit-Remaining',
'RateLimit-Reset',
]));
});

test('the per-route Access-Control-Expose-Headers is no longer hand-rolled in controllers', () => {
// Static-string assertion against the controller directory.
// The audit-cycle PRs sprinkled `res.setHeader('Access-Control-
// Expose-Headers', 'Link')` in every list endpoint; once the
// global CORS middleware took over, those became redundant.
// This regression test pins the cleanup: any future controller
// that adds the hand-rolled call will fail here.
const { readdirSync, readFileSync } = require('fs');
const path = require('path');
const dir = path.resolve(__dirname, '../../app/controllers');
const hits = [];
for (const f of readdirSync(dir).filter((x) => x.endsWith('.js'))) {
const body = readFileSync(path.join(dir, f), 'utf8');
if (/Access-Control-Expose-Headers/.test(body)) {
hits.push(f);
}
}
expect(hits, 'controllers should not hand-roll the CORS expose header').toEqual([]);
});
});
Loading