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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user