mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,258 @@
|
||||
'use strict';
|
||||
|
||||
// Set env BEFORE requiring the middleware (ALLOWED array is built at load time)
|
||||
process.env.ORIGIN_GUARD_DISABLED = 'false';
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.ALLOWED_ORIGINS = 'http://localhost:5173,http://localhost:3024';
|
||||
|
||||
const originGuard = require('../../middleware/originGuard.middleware');
|
||||
|
||||
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makeReq({ method = 'GET', headers = {} } = {}) {
|
||||
return { method, headers };
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
_status: null,
|
||||
_body: null,
|
||||
status(code) { this._status = code; return this; },
|
||||
json(body) { this._body = body; return this; },
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
// Minimal headers that represent a real browser fetch() call (SPA → API).
|
||||
// localhost:5173 → localhost:3024 is same-site (same eTLD+1, different port).
|
||||
const BROWSER_HEADERS = {
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('originGuard middleware', () => {
|
||||
|
||||
// ── Layer 1a: Sec-Fetch-Site presence + value ─────────────────────────────
|
||||
|
||||
test('1. GET with no Sec-Fetch-Site → 403 (Layer 1a: header absent)', () => {
|
||||
const req = makeReq({ method: 'GET', headers: {} });
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
|
||||
});
|
||||
|
||||
test('2. GET with Sec-Fetch-Site: cross-site → 403 (Layer 1a: cross-site rejected)', () => {
|
||||
const req = makeReq({ method: 'GET', headers: { 'sec-fetch-site': 'cross-site' } });
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
|
||||
});
|
||||
|
||||
// ── Layer 1b: Fetch Metadata family completeness + valid combo ────────────
|
||||
|
||||
test('3. GET with Sec-Fetch-Site but missing Mode and Dest → 403 (Layer 1b: incomplete family)', () => {
|
||||
const req = makeReq({ method: 'GET', headers: { 'sec-fetch-site': 'same-site' } });
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
|
||||
});
|
||||
|
||||
test('4. GET with Sec-Fetch-Site + Mode but missing Dest → 403 (Layer 1b: partial family)', () => {
|
||||
const req = makeReq({
|
||||
method: 'GET',
|
||||
headers: { 'sec-fetch-site': 'same-site', 'sec-fetch-mode': 'cors' },
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
test('5. GET with impossible combo (cors + document) → 403 (Layer 1b: invalid combination)', () => {
|
||||
const req = makeReq({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-dest': 'document',
|
||||
'accept-language': 'en-US',
|
||||
},
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
test('6. GET with no-cors mode → 403 (Layer 1b: no-cors not expected on API server)', () => {
|
||||
const req = makeReq({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-fetch-mode': 'no-cors',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'accept-language': 'en-US',
|
||||
},
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
// ── Layer 2: Browser presence signals ────────────────────────────────────
|
||||
|
||||
test('7. GET with valid Fetch Metadata but no Sec-CH-UA and no Accept-Language → 403 (Layer 2: no browser fingerprint)', () => {
|
||||
const req = makeReq({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-dest': 'empty',
|
||||
},
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
|
||||
});
|
||||
|
||||
// ── Layer 3: Origin allowlist ─────────────────────────────────────────────
|
||||
|
||||
test('8. POST with full browser headers but foreign Origin → 403 (Layer 3: unlisted origin)', () => {
|
||||
const req = makeReq({
|
||||
method: 'POST',
|
||||
headers: { ...BROWSER_HEADERS, 'origin': 'http://attacker.com' },
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
expect(res._body).toMatchObject({ status: 'error', message: 'Forbidden.' });
|
||||
});
|
||||
|
||||
test('9. POST with full browser headers but missing Origin → 403 (Layer 3: no origin header)', () => {
|
||||
const req = makeReq({ method: 'POST', headers: { ...BROWSER_HEADERS } });
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
// ── Happy paths ───────────────────────────────────────────────────────────
|
||||
|
||||
test('10. GET with full browser headers (Accept-Language path) → passes', () => {
|
||||
const req = makeReq({ method: 'GET', headers: { ...BROWSER_HEADERS } });
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res._status).toBeNull();
|
||||
});
|
||||
|
||||
test('11. GET with Sec-CH-UA instead of Accept-Language (Chromium path) → passes', () => {
|
||||
const req = makeReq({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'sec-fetch-site': 'same-site',
|
||||
'sec-fetch-mode': 'cors',
|
||||
'sec-fetch-dest': 'empty',
|
||||
'sec-ch-ua': '"Chromium";v="137", "Not/A)Brand";v="24"',
|
||||
},
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res._status).toBeNull();
|
||||
});
|
||||
|
||||
test('12. POST with full browser headers and allowed Origin → passes', () => {
|
||||
const req = makeReq({
|
||||
method: 'POST',
|
||||
headers: { ...BROWSER_HEADERS, 'origin': 'http://localhost:5173' },
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res._status).toBeNull();
|
||||
});
|
||||
|
||||
test('13. Direct browser navigation (navigate + document, site: none) → passes', () => {
|
||||
const req = makeReq({
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'sec-fetch-site': 'none',
|
||||
'sec-fetch-mode': 'navigate',
|
||||
'sec-fetch-dest': 'document',
|
||||
'sec-fetch-user': '?1',
|
||||
'accept-language': 'en-US,en;q=0.9',
|
||||
'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
});
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res._status).toBeNull();
|
||||
});
|
||||
|
||||
test('14. OPTIONS preflight → passes immediately (handled by cors())', () => {
|
||||
const req = makeReq({ method: 'OPTIONS', headers: {} });
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
|
||||
originGuard(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(res._status).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
requireClient,
|
||||
requireStaff,
|
||||
requireAdmin,
|
||||
requireOwnerOrStaff,
|
||||
requireOwnerOrAdmin,
|
||||
} = require('../../middleware/rbac.middleware');
|
||||
|
||||
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
_status: null,
|
||||
_body: null,
|
||||
status(code) { this._status = code; return this; },
|
||||
json(body) { this._body = body; return this; },
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
function makeReq(accType = null, userId = null, paramId = null) {
|
||||
return {
|
||||
user: accType ? { user_id: userId, acc_type: accType } : null,
|
||||
params: { id: paramId },
|
||||
};
|
||||
}
|
||||
|
||||
// ── requireClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireClient()', () => {
|
||||
test('user role → passes', () => {
|
||||
const next = jest.fn();
|
||||
requireClient()(makeReq('user'), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('staff role → passes', () => {
|
||||
const next = jest.fn();
|
||||
requireClient()(makeReq('staff'), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('admin role → passes', () => {
|
||||
const next = jest.fn();
|
||||
requireClient()(makeReq('admin'), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('no req.user → 401', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
requireClient()(makeReq(null), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireStaff ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireStaff()', () => {
|
||||
test('staff role → passes', () => {
|
||||
const next = jest.fn();
|
||||
requireStaff()(makeReq('staff'), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('admin role → passes', () => {
|
||||
const next = jest.fn();
|
||||
requireStaff()(makeReq('admin'), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('user role → 403', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
requireStaff()(makeReq('user'), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
test('no req.user → 401', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
requireStaff()(makeReq(null), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireAdmin ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('requireAdmin()', () => {
|
||||
test('admin role → passes', () => {
|
||||
const next = jest.fn();
|
||||
requireAdmin()(makeReq('admin'), makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('staff role → 403', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
requireAdmin()(makeReq('staff'), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
test('user role → 403', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
requireAdmin()(makeReq('user'), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
test('no req.user → 401', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
requireAdmin()(makeReq(null), res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireOwnerOrStaff ───────────────────────────────────────────────────────
|
||||
|
||||
describe('requireOwnerOrStaff()', () => {
|
||||
test('owner (user) accessing own resource → passes', () => {
|
||||
const next = jest.fn();
|
||||
const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '10' } };
|
||||
requireOwnerOrStaff()(req, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('staff accessing another user\'s resource → passes', () => {
|
||||
const next = jest.fn();
|
||||
const req = { user: { user_id: 2, acc_type: 'staff' }, params: { id: '99' } };
|
||||
requireOwnerOrStaff()(req, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('admin accessing another user\'s resource → passes', () => {
|
||||
const next = jest.fn();
|
||||
const req = { user: { user_id: 1, acc_type: 'admin' }, params: { id: '99' } };
|
||||
requireOwnerOrStaff()(req, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('user accessing another user\'s resource → 403', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '99' } };
|
||||
requireOwnerOrStaff()(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
test('no req.user → 401', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
const req = { user: null, params: { id: '10' } };
|
||||
requireOwnerOrStaff()(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── requireOwnerOrAdmin ───────────────────────────────────────────────────────
|
||||
|
||||
describe('requireOwnerOrAdmin()', () => {
|
||||
test('owner (user) accessing own resource → passes', () => {
|
||||
const next = jest.fn();
|
||||
const req = { user: { user_id: 10, acc_type: 'user' }, params: { id: '10' } };
|
||||
requireOwnerOrAdmin()(req, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('admin accessing another user\'s resource → passes', () => {
|
||||
const next = jest.fn();
|
||||
const req = { user: { user_id: 1, acc_type: 'admin' }, params: { id: '99' } };
|
||||
requireOwnerOrAdmin()(req, makeRes(), next);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('staff (non-owner) accessing another user\'s resource → 403', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
const req = { user: { user_id: 2, acc_type: 'staff' }, params: { id: '99' } };
|
||||
requireOwnerOrAdmin()(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(403);
|
||||
});
|
||||
|
||||
test('no req.user → 401', () => {
|
||||
const res = makeRes();
|
||||
const next = jest.fn();
|
||||
const req = { user: null, params: { id: '10' } };
|
||||
requireOwnerOrAdmin()(req, res, next);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res._status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
'use strict';
|
||||
|
||||
// ── Env vars must be set BEFORE the service loads (enabled flags read process.env at load time)
|
||||
process.env.DB_HOST = 'test-db-host';
|
||||
process.env.DB_PORT = '5432';
|
||||
process.env.S3_ENDPOINT = 'http://test-s3:3900';
|
||||
process.env.S3_PUBLIC_URL = 'https://cdn.example.com';
|
||||
process.env.S3_BUCKET = 'test-bucket';
|
||||
process.env.SMTP_HOST = 'smtp.test.com';
|
||||
process.env.SMTP_PORT = '587';
|
||||
process.env.REDIS_URL = 'redis://127.0.0.1:6379';
|
||||
|
||||
// ── Mock all infrastructure before the service module loads ───────────────────
|
||||
jest.mock('../../config/db.config', () => ({ authenticate: jest.fn() }));
|
||||
jest.mock('../../config/redis.config', () => ({ ping: jest.fn() }));
|
||||
jest.mock('../../services/s3.service', () => ({ ping: jest.fn() }));
|
||||
jest.mock('../../services/email.service', () => ({ ping: jest.fn() }));
|
||||
|
||||
const db = require('../../config/db.config');
|
||||
const cache = require('../../config/redis.config');
|
||||
const { ping: s3 } = require('../../services/s3.service');
|
||||
const { ping: smtp } = require('../../services/email.service');
|
||||
const { runDashboard, runReadiness } = require('../../services/health.service');
|
||||
|
||||
// ── Reset mocks between tests ─────────────────────────────────────────────────
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ── Preset helpers ────────────────────────────────────────────────────────────
|
||||
const resolve = (fn) => fn.mockResolvedValue();
|
||||
|
||||
function allHealthy() {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
}
|
||||
|
||||
// ── runDashboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('runDashboard()', () => {
|
||||
|
||||
test('all healthy → status=healthy, HTTP 200', async () => {
|
||||
allHealthy();
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('healthy');
|
||||
});
|
||||
|
||||
test('database down (critical) → status=unhealthy, HTTP 503', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('Connection refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(503);
|
||||
expect(body.status).toBe('unhealthy');
|
||||
});
|
||||
|
||||
test('S3 down (non-critical) → status=degraded, HTTP 200', async () => {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
s3.mockRejectedValue(new Error('S3 storage unavailable'));
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
test('SMTP down (non-critical) → degraded, HTTP 200', async () => {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
smtp.mockRejectedValue(new Error('SMTP unreachable'));
|
||||
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
test('both DB and S3 down → unhealthy (critical takes precedence), HTTP 503', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
s3.mockRejectedValue(new Error('s3 down'));
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus, body } = await runDashboard();
|
||||
expect(httpStatus).toBe(503);
|
||||
expect(body.status).toBe('unhealthy');
|
||||
});
|
||||
|
||||
test('body has app, system, uptime, timestamp, services', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(body).toHaveProperty('app');
|
||||
expect(body).toHaveProperty('system');
|
||||
expect(body).toHaveProperty('uptime');
|
||||
expect(body).toHaveProperty('timestamp');
|
||||
expect(body).toHaveProperty('services');
|
||||
});
|
||||
|
||||
test('app info has correct shape', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(body.app).toMatchObject({
|
||||
name: expect.any(String),
|
||||
version: expect.any(String),
|
||||
environment: expect.any(String),
|
||||
node_version: expect.stringMatching(/^v\d+/),
|
||||
pid: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
test('database connection label is "established" when healthy', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(body.services.database.connection).toBe('established');
|
||||
});
|
||||
|
||||
test('database connection label is "unavailable" when down', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
|
||||
const { body } = await runDashboard();
|
||||
expect(body.services.database.connection).toBe('unavailable');
|
||||
});
|
||||
|
||||
test('latency_ms is a non-negative number for enabled checks', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(typeof body.services.database.latency_ms).toBe('number');
|
||||
expect(body.services.database.latency_ms).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test('timestamp is a valid ISO 8601 string', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runDashboard();
|
||||
expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp);
|
||||
});
|
||||
});
|
||||
|
||||
// ── runReadiness ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('runReadiness()', () => {
|
||||
|
||||
test('all healthy → status=healthy, HTTP 200', async () => {
|
||||
allHealthy();
|
||||
const { httpStatus, body } = await runReadiness();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('healthy');
|
||||
});
|
||||
|
||||
test('database down → HTTP 503', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus } = await runReadiness();
|
||||
expect(httpStatus).toBe(503);
|
||||
});
|
||||
|
||||
test('S3 down → HTTP 200, degraded — non-critical stays routable', async () => {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
s3.mockRejectedValue(new Error('S3 storage unavailable'));
|
||||
resolve(smtp);
|
||||
|
||||
const { httpStatus, body } = await runReadiness();
|
||||
expect(httpStatus).toBe(200);
|
||||
expect(body.status).toBe('degraded');
|
||||
});
|
||||
|
||||
test('body has version, uptime, timestamp, checks, memory', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runReadiness();
|
||||
expect(body).toHaveProperty('version');
|
||||
expect(body).toHaveProperty('uptime');
|
||||
expect(body).toHaveProperty('timestamp');
|
||||
expect(body).toHaveProperty('checks');
|
||||
expect(body).toHaveProperty('memory');
|
||||
});
|
||||
|
||||
test('checks object has database, cache, storage, smtp keys', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runReadiness();
|
||||
['database', 'cache', 'storage', 'smtp'].forEach((key) => {
|
||||
expect(body.checks).toHaveProperty(key);
|
||||
});
|
||||
});
|
||||
|
||||
test('memory snapshot has heap_used_mb, heap_total_mb, rss_mb, external_mb', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runReadiness();
|
||||
expect(body.memory).toMatchObject({
|
||||
heap_used_mb: expect.any(Number),
|
||||
heap_total_mb: expect.any(Number),
|
||||
rss_mb: expect.any(Number),
|
||||
external_mb: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
test('database check entry has status=unhealthy and critical=true when DB fails', async () => {
|
||||
db.authenticate.mockRejectedValue(new Error('refused'));
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
resolve(s3);
|
||||
resolve(smtp);
|
||||
|
||||
const { body } = await runReadiness();
|
||||
expect(body.checks.database.status).toBe('unhealthy');
|
||||
expect(body.checks.database.critical).toBe(true);
|
||||
});
|
||||
|
||||
test('storage check entry has status=unhealthy and critical=false when S3 fails', async () => {
|
||||
resolve(db.authenticate);
|
||||
cache.ping.mockResolvedValue('PONG');
|
||||
s3.mockRejectedValue(new Error('S3 storage unavailable'));
|
||||
resolve(smtp);
|
||||
|
||||
const { body } = await runReadiness();
|
||||
expect(body.checks.storage.status).toBe('unhealthy');
|
||||
expect(body.checks.storage.critical).toBe(false);
|
||||
});
|
||||
|
||||
test('cache check entry has status=skipped when redis is null (CACHE_DRIVER=memory)', async () => {
|
||||
allHealthy();
|
||||
const { body } = await runReadiness();
|
||||
// cache mock returns { ping: fn } so it is enabled; but the skipped test
|
||||
// is covered when redis module exports null — we verify the shape here instead
|
||||
expect(['healthy', 'skipped']).toContain(body.checks.cache.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
|
||||
const { evaluateCourseAccess, TIER_RANK } = require('../../utils/accessPolicy.util');
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function ctx(tier = 'free', access_rules = [], group_ids = []) {
|
||||
return { tier, access_rules, group_ids };
|
||||
}
|
||||
|
||||
function course(subscription = 'free') {
|
||||
return { subscription };
|
||||
}
|
||||
|
||||
// ── Free course is always accessible ─────────────────────────────────────────
|
||||
|
||||
describe('free course (rank 0)', () => {
|
||||
test('free user can access free course', () => {
|
||||
expect(evaluateCourseAccess(ctx('free'), course('free'))).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('premium user can access free course', () => {
|
||||
expect(evaluateCourseAccess(ctx('premium'), course('free'))).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('free user with no rules can still access free course', () => {
|
||||
expect(evaluateCourseAccess(ctx('free', []), course('free'))).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
});
|
||||
|
||||
// ── No access_rules — fallback tier rank comparison ──────────────────────────
|
||||
|
||||
describe('no access_rules (fallback mode)', () => {
|
||||
test('premium user can access premium course', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', []), course('premium'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('exclusive user can access exclusive course', () => {
|
||||
const result = evaluateCourseAccess(ctx('exclusive', []), course('exclusive'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: true, reason: null });
|
||||
});
|
||||
|
||||
test('free user cannot access premium course', () => {
|
||||
const result = evaluateCourseAccess(ctx('free', []), course('premium'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'tier_rank' });
|
||||
});
|
||||
|
||||
test('premium user cannot access exclusive course', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', []), course('exclusive'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'tier_rank' });
|
||||
});
|
||||
|
||||
test('unknown course subscription slug → denied (safe default)', () => {
|
||||
const result = evaluateCourseAccess(ctx('exclusive', []), course('unknown-tier'), TIER_RANK);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule: course_subscription_access ─────────────────────────────────────────
|
||||
|
||||
describe('rule: course_subscription_access', () => {
|
||||
const rules = [{ type: 'course_subscription_access', levels: ['free', 'premium'] }];
|
||||
|
||||
test('plan covers premium → allowed', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('plan does not cover exclusive → denied', () => {
|
||||
const result = evaluateCourseAccess(ctx('exclusive', rules), course('exclusive'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'subscription_access' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule: required_active_tier ────────────────────────────────────────────────
|
||||
|
||||
describe('rule: required_active_tier', () => {
|
||||
const rules = [{ type: 'required_active_tier', tier: 'premium' }];
|
||||
|
||||
test('premium user meets premium requirement → allowed', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('exclusive user exceeds premium requirement → allowed', () => {
|
||||
const result = evaluateCourseAccess(ctx('exclusive', rules), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('free user fails premium requirement → denied', () => {
|
||||
const result = evaluateCourseAccess(ctx('free', rules), course('premium'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'required_tier' });
|
||||
});
|
||||
|
||||
test('unknown required tier slug → always denied', () => {
|
||||
const badRules = [{ type: 'required_active_tier', tier: 'ghost-tier' }];
|
||||
const result = evaluateCourseAccess(ctx('exclusive', badRules), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Rule: group_restriction ───────────────────────────────────────────────────
|
||||
|
||||
describe('rule: group_restriction', () => {
|
||||
const rules = [{ type: 'group_restriction', group_ids: [5, 10] }];
|
||||
|
||||
test('user in allowed group → allowed', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules, [10, 20]), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('user not in any allowed group → denied', () => {
|
||||
const result = evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK);
|
||||
expect(result).toEqual({ allowed: false, reason: 'group_restriction' });
|
||||
});
|
||||
|
||||
test('empty group_ids on rule → no restriction (passes)', () => {
|
||||
const openRules = [{ type: 'group_restriction', group_ids: [] }];
|
||||
const result = evaluateCourseAccess(ctx('premium', openRules, []), course('premium'), TIER_RANK);
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multiple rules evaluated together ────────────────────────────────────────
|
||||
|
||||
describe('multiple rules', () => {
|
||||
test('fails if any one rule blocks', () => {
|
||||
const rules = [
|
||||
{ type: 'course_subscription_access', levels: ['premium', 'exclusive'] },
|
||||
{ type: 'required_active_tier', tier: 'premium' },
|
||||
{ type: 'group_restriction', group_ids: [7] },
|
||||
];
|
||||
// All pass
|
||||
expect(evaluateCourseAccess(ctx('premium', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(true);
|
||||
|
||||
// Fails group_restriction
|
||||
expect(evaluateCourseAccess(ctx('premium', rules, [99]), course('premium'), TIER_RANK).allowed).toBe(false);
|
||||
|
||||
// Fails required_active_tier
|
||||
expect(evaluateCourseAccess(ctx('free', rules, [7]), course('premium'), TIER_RANK).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
'use strict';
|
||||
|
||||
const { fmtDate, fmtDateTime, fmtTime } = require('../../utils/datetime.util');
|
||||
|
||||
const ISO = '2026-06-20T14:30:00.000Z'; // Saturday, June 20, 2026, 2:30 PM UTC
|
||||
|
||||
// ── fmtDate ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fmtDate()', () => {
|
||||
test('null → returns —', () => {
|
||||
expect(fmtDate(null)).toBe('—');
|
||||
});
|
||||
|
||||
test('empty string → returns —', () => {
|
||||
expect(fmtDate('')).toBe('—');
|
||||
});
|
||||
|
||||
test('formats year correctly', () => {
|
||||
expect(fmtDate(ISO)).toContain('2026');
|
||||
});
|
||||
|
||||
test('formats month correctly (June)', () => {
|
||||
expect(fmtDate(ISO)).toContain('June');
|
||||
});
|
||||
|
||||
test('formats day correctly (20)', () => {
|
||||
expect(fmtDate(ISO)).toContain('20');
|
||||
});
|
||||
|
||||
test('accepts Date object as well as ISO string', () => {
|
||||
const d = new Date(ISO);
|
||||
expect(fmtDate(d)).toContain('2026');
|
||||
});
|
||||
});
|
||||
|
||||
// ── fmtDateTime ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fmtDateTime()', () => {
|
||||
test('null → returns —', () => {
|
||||
expect(fmtDateTime(null)).toBe('—');
|
||||
});
|
||||
|
||||
test('contains date portion', () => {
|
||||
expect(fmtDateTime(ISO)).toContain('2026');
|
||||
expect(fmtDateTime(ISO)).toContain('June');
|
||||
});
|
||||
|
||||
test('contains time portion (2:30 PM UTC)', () => {
|
||||
const out = fmtDateTime(ISO);
|
||||
expect(out).toMatch(/2:30/);
|
||||
expect(out).toContain('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
// ── fmtTime ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fmtTime()', () => {
|
||||
test('null → returns —', () => {
|
||||
expect(fmtTime(null)).toBe('—');
|
||||
});
|
||||
|
||||
test('outputs time with AM/PM', () => {
|
||||
expect(fmtTime(ISO)).toMatch(/\d+:\d{2}\s*(AM|PM)/);
|
||||
});
|
||||
|
||||
test('contains timezone label (UTC)', () => {
|
||||
expect(fmtTime(ISO)).toContain('UTC');
|
||||
});
|
||||
|
||||
test('locale option changes output', () => {
|
||||
const en = fmtTime(ISO, { locale: 'en-US' });
|
||||
expect(en).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../../utils/otp.util');
|
||||
|
||||
// ── generateOTP ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateOTP()', () => {
|
||||
test('returns a 6-character string', () => {
|
||||
expect(generateOTP()).toHaveLength(6);
|
||||
});
|
||||
|
||||
test('contains only digits', () => {
|
||||
expect(generateOTP()).toMatch(/^\d{6}$/);
|
||||
});
|
||||
|
||||
test('value is within 000000-999999', () => {
|
||||
const n = Number(generateOTP());
|
||||
expect(n).toBeGreaterThanOrEqual(0);
|
||||
expect(n).toBeLessThanOrEqual(999999);
|
||||
});
|
||||
|
||||
test('zero-pads values under 100000', () => {
|
||||
// Run many samples to catch low values; deterministic check via string length
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(generateOTP()).toHaveLength(6);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── getOTPExpiry ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getOTPExpiry()', () => {
|
||||
test('returns a Date instance', () => {
|
||||
expect(getOTPExpiry()).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test('expiry is in the future', () => {
|
||||
expect(getOTPExpiry().getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
test('default offset is approximately 10 minutes', () => {
|
||||
const before = Date.now();
|
||||
const expiry = getOTPExpiry().getTime();
|
||||
const after = Date.now();
|
||||
const tenMin = 10 * 60 * 1000;
|
||||
expect(expiry).toBeGreaterThanOrEqual(before + tenMin - 100);
|
||||
expect(expiry).toBeLessThanOrEqual(after + tenMin + 100);
|
||||
});
|
||||
|
||||
test('custom minutes are respected', () => {
|
||||
const before = Date.now();
|
||||
const expiry = getOTPExpiry(5).getTime();
|
||||
const fiveMin = 5 * 60 * 1000;
|
||||
expect(expiry).toBeGreaterThanOrEqual(before + fiveMin - 100);
|
||||
});
|
||||
});
|
||||
|
||||
// ── isOTPExpired ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('isOTPExpired()', () => {
|
||||
test('null → expired (true)', () => {
|
||||
expect(isOTPExpired(null)).toBe(true);
|
||||
});
|
||||
|
||||
test('undefined → expired (true)', () => {
|
||||
expect(isOTPExpired(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
test('past date → expired (true)', () => {
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
expect(isOTPExpired(past)).toBe(true);
|
||||
});
|
||||
|
||||
test('future date → not expired (false)', () => {
|
||||
const future = new Date(Date.now() + 60_000);
|
||||
expect(isOTPExpired(future)).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts ISO string as well as Date', () => {
|
||||
const future = new Date(Date.now() + 60_000).toISOString();
|
||||
expect(isOTPExpired(future)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
|
||||
const R = require('../../utils/response.util');
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
_status: null,
|
||||
_body: null,
|
||||
status(code) { this._status = code; return this; },
|
||||
json(body) { this._body = body; return this; },
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── R.success ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('R.success()', () => {
|
||||
test('default status is 200', () => {
|
||||
const res = makeRes();
|
||||
R.success(res, 'OK');
|
||||
expect(res._status).toBe(200);
|
||||
});
|
||||
|
||||
test('envelope shape: status=success, message, data', () => {
|
||||
const res = makeRes();
|
||||
R.success(res, 'Created', { id: 1 }, 201);
|
||||
expect(res._body).toEqual({ status: 'success', message: 'Created', data: { id: 1 } });
|
||||
});
|
||||
|
||||
test('custom status code is used', () => {
|
||||
const res = makeRes();
|
||||
R.success(res, 'Created', null, 201);
|
||||
expect(res._status).toBe(201);
|
||||
});
|
||||
|
||||
test('data is null when omitted', () => {
|
||||
const res = makeRes();
|
||||
R.success(res, 'OK');
|
||||
expect(res._body.data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── R.error ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('R.error()', () => {
|
||||
test('default status is 500', () => {
|
||||
const res = makeRes();
|
||||
R.error(res, 'Something broke');
|
||||
expect(res._status).toBe(500);
|
||||
});
|
||||
|
||||
test('envelope shape: status=error, message', () => {
|
||||
const res = makeRes();
|
||||
R.error(res, 'Not found', 404);
|
||||
expect(res._body).toMatchObject({ status: 'error', message: 'Not found' });
|
||||
expect(res._status).toBe(404);
|
||||
});
|
||||
|
||||
test('errors field is absent when not provided', () => {
|
||||
const res = makeRes();
|
||||
R.error(res, 'Bad request', 400);
|
||||
expect(res._body).not.toHaveProperty('errors');
|
||||
});
|
||||
|
||||
test('errors field is included when provided', () => {
|
||||
const res = makeRes();
|
||||
const errs = [{ field: 'email', msg: 'Invalid' }];
|
||||
R.error(res, 'Validation failed', 422, errs);
|
||||
expect(res._body.errors).toEqual(errs);
|
||||
});
|
||||
});
|
||||
|
||||
// ── R.validationError ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('R.validationError()', () => {
|
||||
test('status is always 422', () => {
|
||||
const res = makeRes();
|
||||
R.validationError(res, []);
|
||||
expect(res._status).toBe(422);
|
||||
});
|
||||
|
||||
test('envelope shape: status=error, message=Validation failed, errors', () => {
|
||||
const res = makeRes();
|
||||
const errs = [{ field: 'name', msg: 'Required' }];
|
||||
R.validationError(res, errs);
|
||||
expect(res._body).toEqual({
|
||||
status: 'error',
|
||||
message: 'Validation failed',
|
||||
errors: errs,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
// Set secrets BEFORE requiring — jwt.sign/verify reads process.env at call time
|
||||
// but we want a clean, isolated secret that does not change across test runs.
|
||||
process.env.JWT_SECRET = 'test-jwt-secret-32chars-padding!!';
|
||||
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret-32chars-pad!!';
|
||||
process.env.JWT_EXPIRES_IN = '15m';
|
||||
process.env.JWT_REFRESH_EXPIRES_IN = '7d';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const {
|
||||
generateTokens,
|
||||
verifyAccessToken,
|
||||
verifyRefreshToken,
|
||||
hashToken,
|
||||
shouldRotateRefreshToken,
|
||||
} = require('../../utils/token.util');
|
||||
|
||||
const MOCK_USER = { user_id: 1, email: 'test@example.com', acc_type: 'user', reg_type: 'system' };
|
||||
|
||||
// ── generateTokens ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateTokens()', () => {
|
||||
test('returns accessToken and refreshToken strings', () => {
|
||||
const { accessToken, refreshToken } = generateTokens(MOCK_USER);
|
||||
expect(typeof accessToken).toBe('string');
|
||||
expect(typeof refreshToken).toBe('string');
|
||||
});
|
||||
|
||||
test('accessToken contains correct payload fields', () => {
|
||||
const { accessToken } = generateTokens(MOCK_USER);
|
||||
const decoded = jwt.decode(accessToken);
|
||||
expect(decoded.user_id).toBe(MOCK_USER.user_id);
|
||||
expect(decoded.email).toBe(MOCK_USER.email);
|
||||
expect(decoded.acc_type).toBe(MOCK_USER.acc_type);
|
||||
expect(decoded.reg_type).toBe(MOCK_USER.reg_type);
|
||||
});
|
||||
|
||||
test('refreshToken contains only user_id', () => {
|
||||
const { refreshToken } = generateTokens(MOCK_USER);
|
||||
const decoded = jwt.decode(refreshToken);
|
||||
expect(decoded.user_id).toBe(MOCK_USER.user_id);
|
||||
expect(decoded.email).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── verifyAccessToken ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('verifyAccessToken()', () => {
|
||||
test('valid token → returns decoded payload', () => {
|
||||
const { accessToken } = generateTokens(MOCK_USER);
|
||||
const decoded = verifyAccessToken(accessToken);
|
||||
expect(decoded.user_id).toBe(MOCK_USER.user_id);
|
||||
});
|
||||
|
||||
test('tampered token → throws JsonWebTokenError', () => {
|
||||
const { accessToken } = generateTokens(MOCK_USER);
|
||||
expect(() => verifyAccessToken(accessToken + 'tampered')).toThrow();
|
||||
});
|
||||
|
||||
test('expired token → throws TokenExpiredError', () => {
|
||||
const expired = jwt.sign(
|
||||
{ user_id: 99, exp: Math.floor(Date.now() / 1000) - 10 },
|
||||
process.env.JWT_SECRET
|
||||
);
|
||||
let err;
|
||||
try { verifyAccessToken(expired); } catch (e) { err = e; }
|
||||
expect(err.name).toBe('TokenExpiredError');
|
||||
});
|
||||
|
||||
test('token signed with wrong secret → throws', () => {
|
||||
const bad = jwt.sign({ user_id: 1 }, 'wrong-secret');
|
||||
expect(() => verifyAccessToken(bad)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── verifyRefreshToken ────────────────────────────────────────────────────────
|
||||
|
||||
describe('verifyRefreshToken()', () => {
|
||||
test('valid refresh token → returns payload with user_id', () => {
|
||||
const { refreshToken } = generateTokens(MOCK_USER);
|
||||
const decoded = verifyRefreshToken(refreshToken);
|
||||
expect(decoded.user_id).toBe(MOCK_USER.user_id);
|
||||
});
|
||||
|
||||
test('access token used as refresh token → throws (different secret)', () => {
|
||||
const { accessToken } = generateTokens(MOCK_USER);
|
||||
expect(() => verifyRefreshToken(accessToken)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── hashToken ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hashToken()', () => {
|
||||
test('returns a 64-character hex string (SHA-256)', () => {
|
||||
expect(hashToken('some-token')).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test('is deterministic — same input produces same hash', () => {
|
||||
expect(hashToken('abc')).toBe(hashToken('abc'));
|
||||
});
|
||||
|
||||
test('different inputs produce different hashes', () => {
|
||||
expect(hashToken('token-a')).not.toBe(hashToken('token-b'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── shouldRotateRefreshToken ──────────────────────────────────────────────────
|
||||
|
||||
describe('shouldRotateRefreshToken()', () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
test('expires in 10 minutes (within 1-day threshold) → should rotate', () => {
|
||||
const decoded = { exp: now + 600 };
|
||||
expect(shouldRotateRefreshToken(decoded, 1)).toBe(true);
|
||||
});
|
||||
|
||||
test('expires in 5 days (well beyond 1-day threshold) → should not rotate', () => {
|
||||
const decoded = { exp: now + 5 * 24 * 60 * 60 };
|
||||
expect(shouldRotateRefreshToken(decoded, 1)).toBe(false);
|
||||
});
|
||||
|
||||
test('custom threshold is respected', () => {
|
||||
const decoded = { exp: now + 2 * 24 * 60 * 60 }; // 2 days left
|
||||
expect(shouldRotateRefreshToken(decoded, 3)).toBe(true); // 3-day threshold → rotate
|
||||
expect(shouldRotateRefreshToken(decoded, 1)).toBe(false); // 1-day threshold → don't rotate
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user