feat(customers): customer portal (#354) on top of feature-flags reorg
Implements the recurring-customer login surface from the-luap/picpeak#354 plugged into the maintainer's new feature-flag infrastructure (PR #443) instead of a parallel toggle. * New `customerPortal` feature flag (foundation flag for the not-yet-built calendar/quotes/bills/messaging customer surfaces). Defaults FALSE on fresh installs, TRUE on existing installs (events > 0) via migration 095 so live customer accounts don't disappear mid-deployment. * Foundation schema: customer_accounts, customer_invitations, event_customer_assignments, customer_password_resets, plus RBAC permissions customers.view / .create / .delete granted to super_admin + admin system roles. * Backend: /api/admin/customers (invite, list, search, assign, deactivate, reset password) + /api/customer/auth/* + /api/customer/* (login, dashboard, accept-invite, reset). Customer JWT bypass minted via /api/customer/events/:slug/access-token so existing gallery middleware stays untouched. * Frontend: /customer/* route tree gated by RequireFeature flag customerPortal, with login / dashboard / accept-invite / reset pages and a customer-side sidebar layout. /admin/customers and /admin/customers/:id gated identically. * Settings → Features grows a "Customers" section with a Customer portal card. The maintainer's Features tab stays the single source of truth — no parallel Advanced features tab. * CustomerAccountPicker on event create/edit forms hides itself when the flag is off; backend ignores customer_account_ids in that case instead of erroring the whole event save. Translations: en + de hand-translated. nl/pt/ru fall through to en — flagged here as needing native review. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Unit tests for customerAccountsService (#354).
|
||||
*
|
||||
* The service touches the DB in most call sites, so we mock the knex
|
||||
* builder. The point of these tests is to catch the assignment-diff
|
||||
* logic and the invitation guards — not to integration-test knex.
|
||||
*/
|
||||
|
||||
// --- mocks --------------------------------------------------------------
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
mockDb.transaction = jest.fn(async (fn) => fn(mockDb));
|
||||
return { db: mockDb, logActivity: jest.fn() };
|
||||
});
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
jest.mock('../services/emailProcessor', () => ({
|
||||
queueEmail: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
jest.mock('../utils/passwordValidation', () => ({
|
||||
getBcryptRounds: () => 4, // fast for tests
|
||||
}));
|
||||
// frontendUrl is resolved against app_settings; mock the helper directly
|
||||
// so the test doesn't need to also stub the settings query.
|
||||
jest.mock('../utils/frontendUrl', () => ({
|
||||
getFrontendBaseUrl: jest.fn().mockResolvedValue('https://example.test'),
|
||||
}));
|
||||
jest.mock('../utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
|
||||
// Helper to make a chainable query builder mock that resolves to `result`.
|
||||
const chain = (result) => {
|
||||
const q = {};
|
||||
['where', 'whereNull', 'whereNot', 'whereRaw', 'whereIn', 'andWhere',
|
||||
'select', 'leftJoin', 'join', 'orderBy', 'limit', 'groupBy', 'first']
|
||||
.forEach((m) => { q[m] = jest.fn().mockReturnValue(q); });
|
||||
q.first = jest.fn().mockResolvedValue(result?.first);
|
||||
q.del = jest.fn().mockResolvedValue(result?.del ?? 0);
|
||||
// returning() must itself return a thenable that resolves to the
|
||||
// configured insert result, since the service awaits it directly.
|
||||
q.insert = jest.fn().mockImplementation(() => {
|
||||
const promise = Promise.resolve(result?.insert ?? []);
|
||||
promise.returning = () => Promise.resolve(result?.insert ?? []);
|
||||
return promise;
|
||||
});
|
||||
q.pluck = jest.fn().mockResolvedValue(result?.pluck ?? []);
|
||||
q.update = jest.fn().mockResolvedValue(result?.update ?? 0);
|
||||
q.then = (resolve) => Promise.resolve(result?.rows ?? []).then(resolve);
|
||||
q.catch = () => q;
|
||||
return q;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
db.mockReset();
|
||||
queueEmail.mockClear();
|
||||
db.transaction.mockImplementation(async (fn) => fn(db));
|
||||
});
|
||||
|
||||
// ---- createInvitation --------------------------------------------------
|
||||
|
||||
describe('createInvitation', () => {
|
||||
it('rejects when a customer with the email already exists', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
db.mockImplementationOnce(() => chain({ first: { id: 1, email: '[email protected]' } }));
|
||||
await expect(
|
||||
svc.createInvitation({ email: '[email protected]', invitedById: 9 })
|
||||
).rejects.toThrow(/already exists/i);
|
||||
expect(queueEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when a non-expired pending invitation exists', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
db.mockImplementationOnce(() => chain({ first: null })); // no customer
|
||||
db.mockImplementationOnce(() => chain({ first: { id: 5, email: '[email protected]' } }));
|
||||
await expect(
|
||||
svc.createInvitation({ email: '[email protected]', invitedById: 9 })
|
||||
).rejects.toThrow(/pending invitation/i);
|
||||
});
|
||||
|
||||
it('queues an invitation email on success', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
db.mockImplementationOnce(() => chain({ first: null })); // no customer
|
||||
db.mockImplementationOnce(() => chain({ first: null })); // no pending
|
||||
db.mockImplementationOnce(() => chain({ insert: [{ id: 42 }] })); // insert invitation
|
||||
|
||||
const result = await svc.createInvitation({
|
||||
email: '[email protected]',
|
||||
invitedById: 9,
|
||||
});
|
||||
|
||||
expect(result.email).toBe('[email protected]');
|
||||
expect(result.token).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(queueEmail).toHaveBeenCalledTimes(1);
|
||||
const call = queueEmail.mock.calls[0];
|
||||
expect(call[2]).toBe('customer_invitation');
|
||||
expect(call[3].invite_link).toMatch(/\/customer\/invite\//);
|
||||
// Link must honour the configured frontend URL (Site Settings →
|
||||
// general_site_url, surfaced via getFrontendBaseUrl). Mocked above
|
||||
// to https://example.test — the dev-day bug was the link always
|
||||
// emitting localhost regardless of config.
|
||||
expect(call[3].invite_link.startsWith('https://example.test/')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- setAssignmentsForEvent --------------------------------------------
|
||||
|
||||
describe('setAssignmentsForEvent', () => {
|
||||
it('inserts only customers that are missing and removes those not in the wanted list', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
// existing assignments: customers 1 and 2
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 100, customer_account_id: 1 },
|
||||
{ id: 101, customer_account_id: 2 },
|
||||
] });
|
||||
// delete chain
|
||||
const deleteChain = chain({ del: 1 });
|
||||
// validity check chain — returns valid ids 3 only (99 is filtered out)
|
||||
const validityChain = chain({ pluck: [3] });
|
||||
// insert chain
|
||||
const insertChain = chain({ insert: [] });
|
||||
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
db.mockImplementationOnce(() => deleteChain);
|
||||
db.mockImplementationOnce(() => validityChain);
|
||||
db.mockImplementationOnce(() => insertChain);
|
||||
|
||||
const summary = await svc.setAssignmentsForEvent(42, [2, 3, 99], 7);
|
||||
|
||||
// Should remove customer 1 (not in wanted) and only insert valid ones.
|
||||
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [100]);
|
||||
expect(insertChain.insert).toHaveBeenCalledWith([{
|
||||
event_id: 42,
|
||||
customer_account_id: 3,
|
||||
assigned_by_admin_id: 7,
|
||||
assigned_at: expect.any(Date),
|
||||
}]);
|
||||
// `added` counts attempted-additions before the validity filter — so
|
||||
// 3 and 99 were both attempted (added: 2). The validity filter drops
|
||||
// 99 silently (logged as a warning) before the insert. This matches
|
||||
// the service contract; the test is asserting on it explicitly so a
|
||||
// future refactor can't quietly change it.
|
||||
expect(summary).toEqual({ added: 2, removed: 1 });
|
||||
});
|
||||
|
||||
it('clears all assignments when wanted list is empty', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 100, customer_account_id: 1 },
|
||||
{ id: 101, customer_account_id: 2 },
|
||||
] });
|
||||
const deleteChain = chain({ del: 2 });
|
||||
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
db.mockImplementationOnce(() => deleteChain);
|
||||
|
||||
const summary = await svc.setAssignmentsForEvent(42, [], 7);
|
||||
expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [100, 101]);
|
||||
expect(summary).toEqual({ added: 0, removed: 2 });
|
||||
});
|
||||
|
||||
it('is a no-op when wanted equals existing', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const existingChain = chain({ rows: [
|
||||
{ id: 100, customer_account_id: 1 },
|
||||
] });
|
||||
db.mockImplementationOnce(() => existingChain);
|
||||
|
||||
const summary = await svc.setAssignmentsForEvent(42, [1], 7);
|
||||
// Only the existing-rows query was called; no del or insert chain
|
||||
// was needed because both diffs are empty.
|
||||
expect(db).toHaveBeenCalledTimes(1);
|
||||
expect(summary).toEqual({ added: 0, removed: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---- customerHasAccessToEvent ------------------------------------------
|
||||
|
||||
describe('customerHasAccessToEvent', () => {
|
||||
it('returns true when an assignment row exists', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
const c = chain({ first: { id: 99 } });
|
||||
db.mockImplementationOnce(() => c);
|
||||
|
||||
const result = await svc.customerHasAccessToEvent(1, 2);
|
||||
expect(result).toBe(true);
|
||||
expect(c.where).toHaveBeenCalledWith('customer_account_id', 1);
|
||||
expect(c.where).toHaveBeenCalledWith('event_id', 2);
|
||||
});
|
||||
|
||||
it('returns false when no assignment row exists', async () => {
|
||||
const svc = require('../services/customerAccountsService');
|
||||
db.mockImplementationOnce(() => chain({ first: undefined }));
|
||||
const result = await svc.customerHasAccessToEvent(1, 999);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* Unit tests for customerAuth middleware (#354 follow-up).
|
||||
*
|
||||
* Mirrors the parity-with-adminAuth invariants the maintainer flagged
|
||||
* during the PR #403 review:
|
||||
* - Issuer-claim verify
|
||||
* - Token revocation lookup
|
||||
* - Wrong-token-type rejection
|
||||
* - Missing-customer / inactive-customer rejection
|
||||
* - password_changed_at invalidation
|
||||
* - IP drift logged but not rejected
|
||||
*
|
||||
* The middleware reaches into the DB, the JWT verifier, the revocation
|
||||
* cache and the cookie helper — all four are mocked so this stays a
|
||||
* fast unit test (no postgres, no real JWTs).
|
||||
*/
|
||||
|
||||
// --- mocks --------------------------------------------------------------
|
||||
jest.mock('../database/db', () => {
|
||||
const mockDb = jest.fn();
|
||||
return { db: mockDb, logActivity: jest.fn() };
|
||||
});
|
||||
|
||||
jest.mock('../utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({
|
||||
verify: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/tokenUtils', () => ({
|
||||
getCustomerTokenFromRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
const { customerAuth } = require('../middleware/customerAuth');
|
||||
|
||||
// Helper: build a minimal Express-shaped req/res/next trio. The
|
||||
// middleware reads req.headers, req.cookies, req.ip etc.; res.status()
|
||||
// returns res so the .json() chain works; next is a jest fn so we can
|
||||
// assert it was/wasn't called.
|
||||
function makeRes() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
function makeReq({ token = 'tkn', cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) {
|
||||
return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } };
|
||||
}
|
||||
|
||||
// Convenience: mock db('customer_accounts').where(...).select(...).first()
|
||||
// to return the given row. The middleware uses `.where().select().first()`.
|
||||
function mockCustomerLookup(row) {
|
||||
const q = {};
|
||||
q.where = jest.fn().mockReturnValue(q);
|
||||
q.select = jest.fn().mockReturnValue(q);
|
||||
q.first = jest.fn().mockResolvedValue(row);
|
||||
db.mockImplementationOnce(() => q);
|
||||
return q;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.mockReset();
|
||||
jwt.verify.mockReset();
|
||||
isTokenRevoked.mockReset();
|
||||
getCustomerTokenFromRequest.mockReset();
|
||||
logger.info.mockClear();
|
||||
logger.warn.mockClear();
|
||||
logger.debug.mockClear();
|
||||
logger.error.mockClear();
|
||||
});
|
||||
|
||||
// ---- no token ----------------------------------------------------------
|
||||
|
||||
describe('customerAuth — no token', () => {
|
||||
it('returns 401 with NO_TOKEN code when the helper returns null', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue(null);
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await customerAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'NO_TOKEN' }),
|
||||
);
|
||||
// Maintainer-flagged: must be debug-level for unauthenticated probes.
|
||||
// (info-level was the prod-noise bug.)
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- JWT verification --------------------------------------------------
|
||||
|
||||
describe('customerAuth — JWT verification', () => {
|
||||
it('returns 401 TOKEN_EXPIRED when the JWT is expired', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
const err = new Error('jwt expired');
|
||||
err.name = 'TokenExpiredError';
|
||||
jwt.verify.mockImplementation(() => { throw err; });
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'TOKEN_EXPIRED' }));
|
||||
});
|
||||
|
||||
it('returns 401 JWT_INVALID on any other JWT error', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
const err = new Error('invalid signature');
|
||||
err.name = 'JsonWebTokenError';
|
||||
jwt.verify.mockImplementation(() => { throw err; });
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'JWT_INVALID' }));
|
||||
});
|
||||
|
||||
it('passes the issuer claim to jwt.verify', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
// Make jwt.verify succeed with a customer payload so the test gets
|
||||
// past the verify step; we only care that the call shape is right.
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: '[email protected]', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: null, preferred_language: 'en',
|
||||
});
|
||||
|
||||
await customerAuth(makeReq(), makeRes(), jest.fn());
|
||||
|
||||
expect(jwt.verify).toHaveBeenCalledWith(
|
||||
'tkn',
|
||||
expect.anything(),
|
||||
expect.objectContaining({ issuer: 'picpeak-auth', complete: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- revocation --------------------------------------------------------
|
||||
|
||||
describe('customerAuth — revocation', () => {
|
||||
it('rejects revoked tokens with TOKEN_REVOKED', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(true);
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'TOKEN_REVOKED' }));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- wrong token type --------------------------------------------------
|
||||
|
||||
describe('customerAuth — token type', () => {
|
||||
it('rejects an admin token with WRONG_TOKEN_TYPE', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'admin', id: 99, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'WRONG_TOKEN_TYPE' }));
|
||||
});
|
||||
|
||||
it('rejects a gallery token with WRONG_TOKEN_TYPE', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'gallery', eventId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'WRONG_TOKEN_TYPE' }));
|
||||
});
|
||||
});
|
||||
|
||||
// ---- customer existence + active check ---------------------------------
|
||||
|
||||
describe('customerAuth — customer lookup', () => {
|
||||
it('rejects when the customer row is missing', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup(null); // not found
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'CUSTOMER_NOT_FOUND' }));
|
||||
});
|
||||
|
||||
it('rejects when the customer is inactive (the where-clause filters them out)', async () => {
|
||||
// Active filter is part of the query (.where({ ..., is_active: true })),
|
||||
// so an inactive customer surfaces as a missing row — same code path
|
||||
// as CUSTOMER_NOT_FOUND. This test guards the active filter itself
|
||||
// by asserting the where call shape.
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
const q = mockCustomerLookup(null);
|
||||
|
||||
await customerAuth(makeReq(), makeRes(), jest.fn());
|
||||
|
||||
expect(q.where).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 1, is_active: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- password_changed_at invalidation ----------------------------------
|
||||
|
||||
describe('customerAuth — password_changed_at', () => {
|
||||
it('rejects tokens issued before password_changed_at with PASSWORD_CHANGED', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
// Token issued at unix 1000; password changed at 2000.
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: '[email protected]', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: new Date(2000 * 1000), // unix 2000
|
||||
preferred_language: 'en',
|
||||
});
|
||||
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'PASSWORD_CHANGED' }));
|
||||
});
|
||||
|
||||
it('accepts tokens issued at exactly password_changed_at', async () => {
|
||||
// Boundary: iat === passwordChangedSeconds → the strict-less-than
|
||||
// check should NOT reject. Token is still valid in this edge case.
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 2000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: '[email protected]', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: new Date(2000 * 1000),
|
||||
preferred_language: 'en',
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), makeRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts tokens when password_changed_at is null', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: '[email protected]', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: null,
|
||||
preferred_language: 'en',
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq(), makeRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- IP drift ----------------------------------------------------------
|
||||
|
||||
describe('customerAuth — IP drift', () => {
|
||||
it('logs but does not reject when token IP differs from request IP', async () => {
|
||||
// Mirrors adminAuth: customers may roam between mobile networks
|
||||
// mid-session, so IP drift is a log-and-continue, not a denial.
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 1, iat: 1000, ip: '8.8.8.8' },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 1, email: '[email protected]', display_name: null,
|
||||
first_name: null, last_name: null,
|
||||
password_changed_at: null,
|
||||
preferred_language: 'en',
|
||||
});
|
||||
|
||||
const next = jest.fn();
|
||||
await customerAuth(makeReq({ ip: '4.4.4.4' }), makeRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
// The drift line uses logger.info on adminAuth and customerAuth;
|
||||
// we don't assert level here, just that something was logged.
|
||||
expect(logger.info).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- happy path --------------------------------------------------------
|
||||
|
||||
describe('customerAuth — happy path', () => {
|
||||
it('attaches req.customer and calls next() on a valid token', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 7, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 7,
|
||||
email: '[email protected]',
|
||||
display_name: 'Charlie',
|
||||
first_name: 'Charlie',
|
||||
last_name: 'Customer',
|
||||
password_changed_at: null,
|
||||
preferred_language: 'de',
|
||||
});
|
||||
|
||||
const req = makeReq();
|
||||
const next = jest.fn();
|
||||
await customerAuth(req, makeRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.customer).toEqual({
|
||||
id: 7,
|
||||
email: '[email protected]',
|
||||
displayName: 'Charlie',
|
||||
firstName: 'Charlie',
|
||||
lastName: 'Customer',
|
||||
preferredLanguage: 'de',
|
||||
});
|
||||
expect(req.token).toBe('tkn');
|
||||
});
|
||||
|
||||
it('defaults preferredLanguage to en when the column is null', async () => {
|
||||
getCustomerTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
payload: { type: 'customer', customerId: 7, iat: 1000 },
|
||||
});
|
||||
isTokenRevoked.mockResolvedValue(false);
|
||||
mockCustomerLookup({
|
||||
id: 7, email: '[email protected]',
|
||||
display_name: null, first_name: null, last_name: null,
|
||||
password_changed_at: null,
|
||||
preferred_language: null,
|
||||
});
|
||||
|
||||
const req = makeReq();
|
||||
await customerAuth(req, makeRes(), jest.fn());
|
||||
|
||||
expect(req.customer.preferredLanguage).toBe('en');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user