godcrm/backend/middleware/__tests__/fileGuard.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

354 lines
11 KiB
JavaScript

// ADR-0016 Phase 1+2 + Phase 5 — fileGuard middleware unit tests.
//
// Covers the visibility decisions:
// - public -> serve without auth
// - internal -> require valid JWT (user present on req)
// - private + member -> serve
// - private + outsider -> 403
// - orphan (no col, P5):
// files.visibility=internal -> require JWT, no space check
// files.visibility=public -> serve without auth
// files.visibility=private -> JWT + space check
// - avatar prefix (P5) -> internal (no DB lookup needed)
// - unknown path -> 404
//
// We mock the database layer and the `authenticate` middleware so the
// middleware's branching logic is exercised in isolation.
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../database/connection.js', () => ({
dbGet: vi.fn(),
safeJsonParse: (v, d = null) => {
if (v === null || v === undefined) return d;
if (typeof v === 'object') return v;
try { return JSON.parse(v); } catch { return d; }
}
}));
vi.mock('../auth.js', () => ({
authenticate: vi.fn((req, res, next) => next())
}));
vi.mock('../../services/space/access.js', () => ({
checkUserSpaceAccess: vi.fn()
}));
vi.mock('../../utils/logger.js', () => ({
apiLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }
}));
import { dbGet } from '../../database/connection.js';
import { authenticate } from '../auth.js';
import { checkUserSpaceAccess } from '../../services/space/access.js';
import { createFileGuard } from '../fileGuard.js';
function makeRes() {
const res = {
statusCode: 200,
headersSent: false,
body: null,
headers: {}
};
res.status = vi.fn((code) => { res.statusCode = code; return res; });
res.json = vi.fn((b) => { res.body = b; res.headersSent = true; return res; });
res.setHeader = vi.fn((k, v) => { res.headers[k] = v; });
return res;
}
describe('fileGuard (ADR-0016)', () => {
let guard;
beforeEach(() => {
vi.clearAllMocks();
guard = createFileGuard('/uploads');
});
it('returns 404 when there is no `files` row for the path', async () => {
dbGet.mockResolvedValueOnce(null); // files lookup
const req = { path: '/spaces/1/missing.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(dbGet).toHaveBeenCalledWith(
'SELECT id, column_id, space_id, url, visibility FROM files WHERE url = ?',
['/uploads/spaces/1/missing.png']
);
expect(res.statusCode).toBe(404);
expect(res.body.error.code).toBe('FILE_NOT_FOUND');
expect(next).not.toHaveBeenCalled();
});
it('serves PUBLIC files without auth', async () => {
dbGet
.mockResolvedValueOnce({ id: 1, column_id: 7, space_id: 12, url: '/uploads/foo.png' }) // file
.mockResolvedValueOnce({ id: 7, config: JSON.stringify({ visibility: 'public' }) }); // column
const req = { path: '/foo.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(authenticate).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
expect(res.statusCode).toBe(200);
});
it('requires JWT for INTERNAL visibility', async () => {
dbGet
.mockResolvedValueOnce({ id: 2, column_id: 8, space_id: 12, url: '/uploads/bar.png' })
.mockResolvedValueOnce({ id: 8, config: { visibility: 'internal' } });
// authenticate populates req.user → next()
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
const req = { path: '/bar.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(authenticate).toHaveBeenCalledOnce();
expect(checkUserSpaceAccess).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});
it('returns 401 for INTERNAL when authenticate fails (no req.user)', async () => {
dbGet
.mockResolvedValueOnce({ id: 3, column_id: 9, space_id: 12, url: '/uploads/baz.png' })
.mockResolvedValueOnce({ id: 9, config: { visibility: 'internal' } });
// authenticate writes 401 directly (matches the real implementation)
authenticate.mockImplementationOnce((req, res /*, next*/) => {
res.status(401).json({ error: 'No authentication provided' });
});
const req = { path: '/baz.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it('PRIVATE: allows space members through', async () => {
dbGet
.mockResolvedValueOnce({ id: 4, column_id: 10, space_id: 12, url: '/uploads/p.png' })
.mockResolvedValueOnce({ id: 10, config: { visibility: 'private' } })
.mockResolvedValueOnce({ // space lookup
id: 12, type: 'team', owner_id: 1, visibility: 'private', access_control: null
});
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
checkUserSpaceAccess.mockResolvedValueOnce(true);
const req = { path: '/p.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(checkUserSpaceAccess).toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});
it('PRIVATE: rejects non-members with 403', async () => {
dbGet
.mockResolvedValueOnce({ id: 5, column_id: 11, space_id: 13, url: '/uploads/secret.png' })
.mockResolvedValueOnce({ id: 11, config: { visibility: 'private' } })
.mockResolvedValueOnce({
id: 13, type: 'team', owner_id: 2, visibility: 'private', access_control: null
});
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
checkUserSpaceAccess.mockResolvedValueOnce(false);
const req = { path: '/secret.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(res.statusCode).toBe(403);
expect(res.body.error.code).toBe('FORBIDDEN');
expect(next).not.toHaveBeenCalled();
});
it('orphan file (no column_id) with files.visibility=private — outsider rejected', async () => {
dbGet
.mockResolvedValueOnce({
id: 6, column_id: null, space_id: 14, url: '/uploads/orphan.png', visibility: 'private'
})
.mockResolvedValueOnce({
id: 14, type: 'team', owner_id: 3, visibility: 'private', access_control: null
});
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
checkUserSpaceAccess.mockResolvedValueOnce(false);
const req = { path: '/orphan.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(res.statusCode).toBe(403);
expect(next).not.toHaveBeenCalled();
});
it('orphan file with no space_id at all is rejected with 403', async () => {
dbGet.mockResolvedValueOnce({
id: 7, column_id: null, space_id: null, url: '/uploads/system.png', visibility: 'private'
});
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
const req = { path: '/system.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(res.statusCode).toBe(403);
expect(next).not.toHaveBeenCalled();
});
// ─── ADR-0016 Phase 5 — orphan visibility resolution ────────────────────
it('orphan file with files.visibility=internal: JWT only, no space check', async () => {
dbGet.mockResolvedValueOnce({
id: 100, column_id: null, space_id: 14, url: '/uploads/spaces/14/chat.png', visibility: 'internal'
});
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
const req = { path: '/spaces/14/chat.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(authenticate).toHaveBeenCalledOnce();
expect(checkUserSpaceAccess).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});
it('orphan file with files.visibility=public: served without auth', async () => {
dbGet.mockResolvedValueOnce({
id: 101, column_id: null, space_id: null, url: '/uploads/desktop-releases/v1.glb', visibility: 'public'
});
const req = { path: '/desktop-releases/v1.glb' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(authenticate).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledOnce();
});
it('orphan file with bogus files.visibility falls back to private', async () => {
dbGet
.mockResolvedValueOnce({
id: 102, column_id: null, space_id: 14, url: '/uploads/x.png', visibility: 'wide-open'
})
.mockResolvedValueOnce({
id: 14, type: 'team', owner_id: 3, visibility: 'private', access_control: null
});
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
checkUserSpaceAccess.mockResolvedValueOnce(false);
const req = { path: '/x.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(res.statusCode).toBe(403);
expect(next).not.toHaveBeenCalled();
});
it('avatar prefix /uploads/avatars/* → internal (no DB lookup, JWT required)', async () => {
// No DB lookup expected — prefix rule resolves visibility synchronously.
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
const req = { path: '/avatars/42_1234.jpg' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(dbGet).not.toHaveBeenCalled();
expect(authenticate).toHaveBeenCalledOnce();
expect(next).toHaveBeenCalledOnce();
});
it('avatar prefix → 401 when no JWT', async () => {
authenticate.mockImplementationOnce((req, res /*, next*/) => {
res.status(401).json({ error: 'No authentication provided' });
});
const req = { path: '/avatars/42_1234.jpg' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
expect(dbGet).not.toHaveBeenCalled();
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it('unknown visibility value falls back to private', async () => {
dbGet
.mockResolvedValueOnce({ id: 8, column_id: 20, space_id: 15, url: '/uploads/x.png' })
.mockResolvedValueOnce({ id: 20, config: { visibility: 'wide-open' } })
.mockResolvedValueOnce({
id: 15, type: 'team', owner_id: 4, visibility: 'private', access_control: null
});
authenticate.mockImplementationOnce((req, res, next) => {
req.user = { id: 99, role: 'editor' };
next();
});
checkUserSpaceAccess.mockResolvedValueOnce(false);
const req = { path: '/x.png' };
const res = makeRes();
const next = vi.fn();
await guard(req, res, next);
// Bogus visibility -> private path -> outsider rejected
expect(res.statusCode).toBe(403);
expect(next).not.toHaveBeenCalled();
});
it('throws if mountPrefix is malformed', () => {
expect(() => createFileGuard('uploads')).toThrow();
expect(() => createFileGuard('')).toThrow();
});
});