testing 101

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-30 19:31:45 +08:00
parent 89acdfc239
commit 1372f4e975
62 changed files with 6696 additions and 281 deletions
+258
View File
@@ -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();
});
});
+203
View File
@@ -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);
});
});
+235
View File
@@ -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);
});
});
+143
View File
@@ -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);
});
});
+74
View File
@@ -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();
});
});
+83
View File
@@ -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);
});
});
+92
View File
@@ -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,
});
});
});
+128
View File
@@ -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
});
});