224 lines
6.6 KiB
TypeScript
224 lines
6.6 KiB
TypeScript
import express, { type ErrorRequestHandler } from 'express';
|
|
import request from 'supertest';
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
const authMocks = vi.hoisted(() => {
|
|
const state: {
|
|
context: {
|
|
user?: { id: string; role: 'anon' | 'authenticated' | 'project_admin' };
|
|
hasApiKey?: boolean;
|
|
};
|
|
} = { context: {} };
|
|
|
|
return {
|
|
state,
|
|
verifyUser: vi.fn((req, _res, next) => {
|
|
if ('user' in state.context) {
|
|
req.user = state.context.user;
|
|
}
|
|
if ('hasApiKey' in state.context) {
|
|
req.hasApiKey = state.context.hasApiKey;
|
|
}
|
|
next();
|
|
}),
|
|
};
|
|
});
|
|
|
|
const proxyMocks = vi.hoisted(() => ({
|
|
forward: vi.fn(),
|
|
forwardAsAdmin: vi.fn(),
|
|
forwardAsUser: vi.fn(),
|
|
filterHeaders: vi.fn((headers: Record<string, unknown>) => headers),
|
|
}));
|
|
|
|
const databaseMocks = vi.hoisted(() => ({
|
|
getColumnTypeMap: vi.fn(),
|
|
}));
|
|
|
|
const socketMocks = vi.hoisted(() => ({
|
|
broadcastToRoom: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/api/middlewares/auth.js', () => ({
|
|
verifyUser: authMocks.verifyUser,
|
|
}));
|
|
|
|
vi.mock('../../src/services/database/postgrest-proxy.service.js', () => ({
|
|
PostgrestProxyService: {
|
|
getInstance: () => proxyMocks,
|
|
filterHeaders: proxyMocks.filterHeaders,
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/infra/database/database.manager.js', () => ({
|
|
DatabaseManager: {
|
|
getColumnTypeMap: databaseMocks.getColumnTypeMap,
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../src/infra/socket/socket.manager.js', () => ({
|
|
SocketManager: {
|
|
getInstance: () => socketMocks,
|
|
},
|
|
}));
|
|
|
|
const routeErrorHandler: ErrorRequestHandler = (error, _req, res, next) => {
|
|
void next;
|
|
|
|
const statusCode =
|
|
error instanceof Error && 'statusCode' in error && typeof error.statusCode === 'number'
|
|
? error.statusCode
|
|
: 500;
|
|
const message = error instanceof Error ? error.message : 'Internal server error';
|
|
|
|
res.status(statusCode).json({ message });
|
|
};
|
|
|
|
async function createApp() {
|
|
vi.resetModules();
|
|
const [{ databaseRecordsRouter }, { databaseRpcRouter }] = await Promise.all([
|
|
import('../../src/api/routes/database/records.routes.js'),
|
|
import('../../src/api/routes/database/rpc.routes.js'),
|
|
]);
|
|
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/database/records', databaseRecordsRouter);
|
|
app.use('/api/database/rpc', databaseRpcRouter);
|
|
app.use(routeErrorHandler);
|
|
return app;
|
|
}
|
|
|
|
function mockProxyResponses() {
|
|
proxyMocks.forward.mockResolvedValue({ data: [{ via: 'user' }], status: 200, headers: {} });
|
|
proxyMocks.forwardAsUser.mockResolvedValue({ data: [{ via: 'user' }], status: 200, headers: {} });
|
|
proxyMocks.forwardAsAdmin.mockResolvedValue({
|
|
data: [{ via: 'project_admin' }],
|
|
status: 200,
|
|
headers: {},
|
|
});
|
|
}
|
|
|
|
describe('Database Records Route Authentication', () => {
|
|
beforeEach(() => {
|
|
authMocks.state.context = {};
|
|
databaseMocks.getColumnTypeMap.mockResolvedValue({});
|
|
mockProxyResponses();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
test('forwards project admin requests as PostgREST admin', async () => {
|
|
authMocks.state.context = {
|
|
user: { id: 'local:admin', role: 'project_admin' },
|
|
hasApiKey: false,
|
|
};
|
|
const app = await createApp();
|
|
|
|
await request(app).get('/api/database/records/widgets?select=id').expect(200);
|
|
|
|
expect(authMocks.verifyUser).toHaveBeenCalledOnce();
|
|
expect(proxyMocks.forwardAsAdmin).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: 'GET',
|
|
path: '/widgets',
|
|
query: expect.objectContaining({ select: 'id' }),
|
|
})
|
|
);
|
|
expect(proxyMocks.forward).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('forwards API key requests as PostgREST admin', async () => {
|
|
authMocks.state.context = { hasApiKey: true };
|
|
const app = await createApp();
|
|
|
|
await request(app).get('/api/database/records/widgets?select=id').expect(200);
|
|
|
|
expect(authMocks.verifyUser).toHaveBeenCalledOnce();
|
|
expect(proxyMocks.forwardAsAdmin).toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: 'GET', path: '/widgets' })
|
|
);
|
|
expect(proxyMocks.forward).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('forwards normal user requests without admin override', async () => {
|
|
authMocks.state.context = {
|
|
user: { id: 'user-id', role: 'authenticated' },
|
|
hasApiKey: false,
|
|
};
|
|
const app = await createApp();
|
|
|
|
await request(app).get('/api/database/records/widgets?select=id').expect(200);
|
|
|
|
expect(authMocks.verifyUser).toHaveBeenCalledOnce();
|
|
expect(proxyMocks.forwardAsUser).toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: 'GET', path: '/widgets' }),
|
|
expect.objectContaining({ id: 'user-id', role: 'authenticated' })
|
|
);
|
|
expect(proxyMocks.forwardAsAdmin).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Database RPC Route Authentication', () => {
|
|
beforeEach(() => {
|
|
authMocks.state.context = {};
|
|
mockProxyResponses();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
test('forwards project admin RPC calls as PostgREST admin', async () => {
|
|
authMocks.state.context = {
|
|
user: { id: 'local:admin', role: 'project_admin' },
|
|
hasApiKey: false,
|
|
};
|
|
const app = await createApp();
|
|
|
|
await request(app).post('/api/database/rpc/do_work').send({ value: 1 }).expect(200);
|
|
|
|
expect(authMocks.verifyUser).toHaveBeenCalledOnce();
|
|
expect(proxyMocks.forwardAsAdmin).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
path: '/rpc/do_work',
|
|
body: { value: 1 },
|
|
})
|
|
);
|
|
expect(proxyMocks.forward).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('forwards API key RPC calls as PostgREST admin', async () => {
|
|
authMocks.state.context = { hasApiKey: true };
|
|
const app = await createApp();
|
|
|
|
await request(app).post('/api/database/rpc/do_work').send({ value: 1 }).expect(200);
|
|
|
|
expect(authMocks.verifyUser).toHaveBeenCalledOnce();
|
|
expect(proxyMocks.forwardAsAdmin).toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: 'POST', path: '/rpc/do_work' })
|
|
);
|
|
expect(proxyMocks.forward).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('forwards normal user RPC calls without admin override', async () => {
|
|
authMocks.state.context = {
|
|
user: { id: 'user-id', role: 'authenticated' },
|
|
hasApiKey: false,
|
|
};
|
|
const app = await createApp();
|
|
|
|
await request(app).post('/api/database/rpc/do_work').send({ value: 1 }).expect(200);
|
|
|
|
expect(authMocks.verifyUser).toHaveBeenCalledOnce();
|
|
expect(proxyMocks.forwardAsUser).toHaveBeenCalledWith(
|
|
expect.objectContaining({ method: 'POST', path: '/rpc/do_work' }),
|
|
expect.objectContaining({ id: 'user-id', role: 'authenticated' })
|
|
);
|
|
expect(proxyMocks.forwardAsAdmin).not.toHaveBeenCalled();
|
|
});
|
|
});
|