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
42 changes: 42 additions & 0 deletions app/config/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,48 @@ const spec = {
},
},
},
'/v1/customer/search': {
get: {
summary: 'Search customers by substring (company-scoped)',
description:
'Case-insensitive ILIKE match on custCompanyName / custFName / custLName. ' +
'Non-master keys are auto-scoped to their authKey company; master keys ' +
'must pass companyId explicitly (no global cross-tenant search).',
security: [{ authKey: [] }],
parameters: [
{ name: 'q', in: 'query', required: true, schema: { type: 'string', minLength: 2, maxLength: 255 } },
{ name: 'companyId', in: 'query', schema: { type: 'integer' }, description: 'Required for master keys. Forbidden when it does not match a non-master authKey\'s own company.' },
{ name: 'limit', in: 'query', schema: { type: 'integer', default: 100, maximum: 500 } },
{ name: 'offset', in: 'query', schema: { type: 'integer', default: 0 } },
],
responses: {
200: {
description: 'OK — search results',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: { type: 'string' },
q: { type: 'string' },
companyId: { type: 'integer' },
count: { type: 'integer' },
limit: { type: 'integer' },
offset: { type: 'integer' },
customers: {
type: 'array',
items: { $ref: '#/components/schemas/Customer' },
},
},
},
},
},
},
400: { description: 'Bad request — q missing/too short, or master without companyId' },
403: { description: 'Missing authKey, or cross-tenant search attempt' },
},
},
},
'/v1/customer/{id}': {
get: {
summary: 'Get one customer by id',
Expand Down
110 changes: 110 additions & 0 deletions app/controllers/customercontroller.js
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,116 @@ exports.getAllByCompanyId = async (req, res) => {
}
};

/**
* GET /v1/customer/search
*
* Substring search over custCompanyName / custFName / custLName.
*
* Auth contract:
* - missing authKey -> 403
* - non-master + companyId in query NOT matching auth scope -> 403
* - non-master without companyId -> auto-scope to own
* - master without companyId -> 400 (companyId required)
*
* The companyId requirement for master keys is deliberate: a
* global substring search across every tenant's customer table
* is a big footgun (latency + accidental data exposure if the
* caller wasn't authorized to see all companies' data). Master
* keys must be explicit about the scope.
*/
exports.search = async (req, res) => {
const authKey = req.get('authKey');
if (!authKey) {
return res.status(403).json({ message: "Authorization key not sent." });
}

let isAuthKeyMasterKey;
try {
isAuthKeyMasterKey = await IsMaster(authKey);
} catch (error) {
log.error({ err: error }, 'IsMaster failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}

// Resolve effective companyId from auth + query
let effectiveCompanyId;
if (isAuthKeyMasterKey) {
const qCompanyId = Number(req.query.companyId);
if (!Number.isInteger(qCompanyId) || qCompanyId <= 0) {
return res.status(400).json({
message: "Master keys must specify companyId on search.",
});
}
effectiveCompanyId = qCompanyId;
} else {
let authKeyCompanyId;
try {
authKeyCompanyId = await GetCompanyId(authKey);
} catch (error) {
log.error({ err: error }, 'GetCompanyId failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
if (authKeyCompanyId === -1) {
return res.status(403).json({ message: "Invalid Authorization Key." });
}
const qCompanyId = req.query.companyId !== undefined ? Number(req.query.companyId) : null;
if (qCompanyId !== null && qCompanyId !== authKeyCompanyId) {
return res.status(403).json({
message: "Cannot search customers in a company you do not belong to.",
});
}
effectiveCompanyId = authKeyCompanyId;
}

const q = String(req.query.q || '');
const requestedLimit = parseInt(req.query.limit, 10);
const limit = Number.isInteger(requestedLimit) && requestedLimit > 0
? Math.min(requestedLimit, 500)
: 100;
const requestedOffset = parseInt(req.query.offset, 10);
const offset = Number.isInteger(requestedOffset) && requestedOffset >= 0
? requestedOffset
: 0;

const Op = db.Sequelize && db.Sequelize.Op;
if (!Op) {
return res.status(500).json({ message: "Error!", error: "Sequelize Op not available" });
}

// ILIKE on Postgres; the `%` wildcards come from us, not the user.
const pattern = `%${q}%`;
const where = {
custCompId: effectiveCompanyId,
custArch: false,
[Op.or]: [
{ custCompanyName: { [Op.iLike]: pattern } },
{ custFName: { [Op.iLike]: pattern } },
{ custLName: { [Op.iLike]: pattern } },
],
};

try {
const { count, rows } = await Customer.findAndCountAll({
where,
limit,
offset,
order: [['custId', 'ASC']],
});
return res.status(200).json({
message: `Found ${count} customer(s) matching ${JSON.stringify(q)} in company ${effectiveCompanyId}`,
q,
companyId: effectiveCompanyId,
count,
limit,
offset,
customers: rows,
});
} catch (error) {
log.error({ err: error }, 'Customer.search findAndCountAll failed');
return res.status(500).json({ message: "Error!", error: String(error) });
}
};

// ---- helpers ----

async function findAndRespond(customerId, res) {
Expand Down
7 changes: 7 additions & 0 deletions app/routers/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ router.use('/docs', swaggerUi.serve, swaggerUi.setup(openapiSpec, {
}));

// v1 customer routes.
// /search MUST be declared before /:id so express doesn't treat
// "search" as an :id param (which intIdParam would 400 on).
router.get(
'/v1/customer/search',
v.query(customerSchemas.searchQuery),
customer.search,
);
router.get(
'/v1/customer/:id',
v.params(customerSchemas.intIdParam),
Expand Down
21 changes: 21 additions & 0 deletions app/schemas/customer.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,29 @@ const listByCompanyQuery = z.object({
message: 'Unexpected query parameter. Allowed: limit, offset.',
});

/**
* GET /v1/customer/search query schema.
*
* `q` is the substring to match against custCompanyName, custFName,
* and custLName (case-insensitive ILIKE). 2-char minimum so we don't
* page-scan the table on every dropdown keystroke.
*
* `companyId` is master-only — non-master keys are auto-scoped to
* their authKey's owning company and a `companyId` param that
* doesn't match returns 403.
*/
const searchQuery = z.object({
q: z.string().min(2).max(255),
companyId: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().positive().max(500).optional(),
offset: z.coerce.number().int().nonnegative().optional(),
}).strict({
message: 'Unexpected query parameter. Allowed: q (>= 2 chars), companyId, limit, offset.',
});

module.exports = {
intIdParam,
createCustomerBody,
listByCompanyQuery,
searchQuery,
};
107 changes: 107 additions & 0 deletions tests/api/customer-search.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Aaron K. Clark
//
// HTTP tests for GET /v1/customer/search. Same constraint as the
// other API tests: vi.mock on db.config.js doesn't intercept the
// nested CJS require chain, so behavioral testing (real ILIKE
// matching) lives in tests/integration. What this file asserts:
//
// - auth contract (403 / 400 paths the test env can drive
// without a working DB mock)
// - query-param validation via zod middleware
// - route is mounted (not 404, no double-response)

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

vi.mock('../../app/config/db.config.js', () => ({
sequelize: {
query: vi.fn().mockResolvedValue([]),
QueryTypes: { SELECT: 'SELECT' },
},
Sequelize: { Op: { or: Symbol('or'), iLike: Symbol('iLike') } },
Customer: {
findAndCountAll: vi.fn().mockResolvedValue({ count: 0, rows: [] }),
},
TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {},
Company: {}, Job: {}, Invoice: {}, CustomerPayment: {},
InvoiceJob: {}, ProductEntry: {}, VersionInfo: {},
PurchaseOrderVendor: {}, PurchaseOrderHeader: {}, PurchaseOrderLine: {},
InventoryTransaction: {},
ApiKey: {}, ApiMaster: {},
}));

let app;

beforeAll(async () => {
const router = (await import('../../app/routers/router.js')).default
|| require('../../app/routers/router.js');
app = express();
app.use(express.json());
app.use('/', router);
});

describe('GET /v1/customer/search auth contract', () => {
test('returns 403 when authKey header is missing', async () => {
const res = await request(app).get('/v1/customer/search?q=acme');
expect(res.status).toBe(403);
expect(res.body.message).toMatch(/Authorization key not sent/i);
});
});

describe('GET /v1/customer/search query validation', () => {
test('q is required (400 when missing)', async () => {
const res = await request(app)
.get('/v1/customer/search')
.set('authKey', 'any');
expect(res.status).toBe(400);
});

test('q is too short — < 2 chars rejected (400)', async () => {
const res = await request(app)
.get('/v1/customer/search?q=a')
.set('authKey', 'any');
expect(res.status).toBe(400);
});

test('q at the 2-char minimum is accepted (passes validation)', async () => {
const res = await request(app)
.get('/v1/customer/search?q=ab')
.set('authKey', 'any');
// 400 would mean schema rejected; anything else means schema passed.
expect(res.status).not.toBe(400);
});

test('unknown query param is rejected via .strict()', async () => {
const res = await request(app)
.get('/v1/customer/search?q=acme&bogus=1')
.set('authKey', 'any');
expect(res.status).toBe(400);
});

test('limit cap enforced — > 500 rejected', async () => {
const res = await request(app)
.get('/v1/customer/search?q=acme&limit=10000')
.set('authKey', 'any');
expect(res.status).toBe(400);
});
});

describe('GET /v1/customer/search route mounting', () => {
test('route is mounted; not treated as /v1/customer/:id', async () => {
// If the route ordering were wrong, "search" would be parsed as
// an :id param and intIdParam would 400 with a different message.
const res = await request(app)
.get('/v1/customer/search?q=acme')
.set('authKey', 'any');
expect(res.body).toBeTypeOf('object');
expect(res.body.message).toBeDefined();
// 400 from intIdParam would say "expected positive integer";
// search's own body errors are different (and we pass validation
// with q=acme anyway).
if (res.status === 400) {
expect(res.body.message).not.toMatch(/positive/i);
}
});
});
Loading