Skip to content
Merged
17 changes: 17 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,23 @@ Mock async methods used by `authMiddleware`:
```
For per-source permission tests, mock `getUserPermissionSetAsync(userId, sourceId)` to return source-specific resource maps.

### Route Test Harness (preferred for new/changed route tests)
For route tests that exercise `requirePermission` / `optionalAuth`, use `createRouteTestApp()` (`src/server/test-helpers/routeTestApp.ts`) instead of monkey-patching `checkPermissionAsync` or mocking the whole `services/database.js` module. The harness wires real express-session (MemoryStore) + real auth middleware against the singleton's `:memory:` SQLite DB, and seeds per-test permission rows so that real SQL enforces isolation.

```typescript
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestApp({ mount: app => app.use('/', myRouter) });
await harness.grant(harness.limited.id, 'nodes', 'read', harness.sourceA);
});
afterEach(() => harness.cleanup());
```

- **New or changed route tests MUST use the harness.** Legacy tests using `vi.mock('../../services/database.js', ...)` convert opportunistically when the file is touched.
- The monkey-patch pattern (`vi.mock(...)` + fake `checkPermissionAsync` lambda) is deprecated for route tests. A fake that re-implements the logic under test cannot catch regressions in that logic.
- See `src/server/routes/sourceRoutes.permissions.test.ts` as the canonical template.
- Non-DB mocks (`sourceManagerRegistry`, `meshtasticManager`, service mocks) are still correct and must remain.

### Raw SQL Ban
- `src/services/database.ts` is raw-SQL-free. All domain queries live in `src/db/repositories/*`.
- ESLint rule (`no-restricted-syntax` in `eslint.config.mjs`) forbids `this.db.prepare`, `this.db.exec`, `postgresPool.query`, `mysqlPool.query` outside `src/server/migrations/**` and test files.
Expand Down
222 changes: 129 additions & 93 deletions src/server/routes/channelDatabaseRoutes.permissions.test.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,31 @@
/**
* Legacy session-mount channel-database — permission model tests (PR-B mirror)
* Legacy session-mount channel-database — permission model tests
*
* Parity coverage for the `/api/channel-database` mount. Both v1 (Bearer-token)
* and legacy (browser-session) mounts share `_channelDatabaseHandlers.ts`, so
* the same permission logic must hold here.
* Converted from the monkey-patch pattern (vi.mock('../../services/database.js')
* + vi.mock('../auth/authMiddleware.js')) to the real-middleware harness
* (createRouteTestApp). The harness uses the live DatabaseService singleton with
* real session + requireAuth + real checkPermissionAsync, seeding per-test
* permission rows instead of hand-rolling fake implementations.
*
* The `requireAuth()` middleware is mocked to a passthrough — these tests
* exercise handler logic, not auth-token validation.
* Permission model (global resource — channel_database is NOT source-scoped):
* channel_database:read → list / get (PSK masked) + retroactive-decrypt progress
* channel_database:write → create / update / delete / reorder + ACL management
*
* Source-isolation note: channel_database is a global-by-design resource
* (no sourceId column on its permissions). The isolation assertion below
* therefore proves that the REAL checkPermissionAsync enforces a global
* grant/no-grant boundary rather than a per-source row boundary.
* For per-source resource isolation see sourceRoutes.permissions.test.ts.
*
* Non-DB service mocks stay: channelDecryptionService and
* retroactiveDecryptionService would attempt real file/network I/O.
*
* See src/server/test-helpers/routeTestApp.ts for the design rationale.
*/

import { describe, it, expect, beforeEach, vi } from 'vitest';
import express, { Express } from 'express';
import request from 'supertest';

vi.mock('../auth/authMiddleware.js', () => ({
requireAuth: () => (_req: any, _res: any, next: any) => next(),
}));

vi.mock('../../services/database.js', () => ({
default: {
channelDatabase: {
getAllAsync: vi.fn(),
getByIdAsync: vi.fn(),
getPermissionsForUserAsync: vi.fn(),
getPermissionAsync: vi.fn(),
getPermissionsForChannelAsync: vi.fn(),
createAsync: vi.fn(),
updateAsync: vi.fn(),
deleteAsync: vi.fn(),
reorderAsync: vi.fn(),
setPermissionAsync: vi.fn(),
deletePermissionAsync: vi.fn(),
},
findUserByIdAsync: vi.fn(),
checkPermissionAsync: vi.fn(),
getDistinctEncryptedPacketSourceIdsAsync: vi.fn(),
drizzleDbType: 'sqlite',
},
}));
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

// Non-DB service mocks: keep these to avoid real I/O in tests.
vi.mock('../services/channelDecryptionService.js', () => ({
channelDecryptionService: { invalidateCache: vi.fn() },
}));
Expand All @@ -53,13 +40,9 @@ vi.mock('../services/retroactiveDecryptionService.js', () => ({

import channelDatabaseRoutes from './channelDatabaseRoutes.js';
import databaseService from '../../services/database.js';
import { createRouteTestApp, type RouteTestHarness } from '../test-helpers/routeTestApp.js';

const mockDb = databaseService as any;

const adminUser = { id: 1, username: 'admin', isAdmin: true, isActive: true };
const writerUser = { id: 50, username: 'writer', isAdmin: false, isActive: true };
const readerUser = { id: 60, username: 'reader', isAdmin: false, isActive: true };
const noPermsUser = { id: 70, username: 'nobody', isAdmin: false, isActive: true };
// ── shared test fixtures ──────────────────────────────────────────────────────

const fakePsk = Buffer.alloc(16, 0xab).toString('base64');
const channel1 = {
Expand All @@ -78,109 +61,162 @@ const channel1 = {
updatedAt: 1000,
};

const createApp = (user: any): Express => {
const app = express();
app.use(express.json());
app.use((req: any, _res, next) => {
req.user = user;
next();
// ── harness wiring ────────────────────────────────────────────────────────────

let harness: RouteTestHarness;

beforeEach(async () => {
harness = await createRouteTestApp({
// channelDatabaseRoutes apply requireAuth() internally; the harness mounts
// optionalAuth() before the router so the session is available, and
// requireAuth() reads req.session.userId set by loginAs().
mount: (app) => app.use('/api/channel-database', channelDatabaseRoutes),
});
app.use('/api/channel-database', channelDatabaseRoutes);
return app;
};

beforeEach(() => {
vi.clearAllMocks();
mockDb.channelDatabase.getAllAsync.mockResolvedValue([channel1]);
mockDb.channelDatabase.getByIdAsync.mockResolvedValue(channel1);
mockDb.channelDatabase.getPermissionsForUserAsync.mockResolvedValue([]);
mockDb.channelDatabase.getPermissionAsync.mockResolvedValue(null);
mockDb.checkPermissionAsync.mockResolvedValue(false);
// Spy on channelDatabase repo data methods so tests control returned rows
// while real checkPermissionAsync enforces access gates.
vi.spyOn(databaseService.channelDatabase, 'getAllAsync').mockResolvedValue([channel1 as any]);
vi.spyOn(databaseService.channelDatabase, 'getByIdAsync').mockResolvedValue(channel1 as any);
vi.spyOn(databaseService.channelDatabase, 'getPermissionsForUserAsync').mockResolvedValue([]);
vi.spyOn(databaseService.channelDatabase, 'getPermissionAsync').mockResolvedValue(null);
vi.spyOn(databaseService.channelDatabase, 'createAsync').mockResolvedValue(42);

// No channel_database permission grants by default — tests add what they need.
});

afterEach(async () => {
vi.restoreAllMocks();
await harness.cleanup();
});

// ── describe 1: permission isolation (global-resource proof) ─────────────────
//
// channel_database is global — no sourceId scoping on the resource itself.
// The assertions below prove the real checkPermissionAsync enforces the
// grant/no-grant boundary correctly.

describe('permission isolation — global resource (real checkPermissionAsync)', () => {
it('user WITH global channel_database:read grant can list channels (200)', async () => {
// Global grant (no sourceId arg → sourceId=NULL row)
await harness.grant(harness.limited.id, 'channel_database', 'read');
// Per-entry permission: canRead=true for channel 1
vi.spyOn(databaseService.channelDatabase, 'getPermissionsForUserAsync').mockResolvedValue([
{ userId: harness.limited.id, channelDatabaseId: 1, canViewOnMap: false, canRead: true } as any,
]);

const agent = await harness.loginAs(harness.limited);
const res = await agent.get('/api/channel-database');
expect(res.status).toBe(200);
});

it('user WITHOUT channel_database:read grant is denied (403 — real checkPermissionAsync)', async () => {
// No grant seeded — real checkPermissionAsync finds no row → false → 403.
// Previously a `mockResolvedValue(false)` hid any real implementation bug.
const agent = await harness.loginAs(harness.limited);
const res = await agent.get('/api/channel-database');
expect(res.status).toBe(403);
});
});

// ── describe 2: GET / permission filtering ───────────────────────────────────

describe('Legacy mount — GET / permission filtering', () => {
it('admin: returns channels with full PSK', async () => {
const res = await request(createApp(adminUser)).get('/api/channel-database');
const agent = await harness.loginAs(harness.admin);
const res = await agent.get('/api/channel-database');
expect(res.status).toBe(200);
expect(res.body.data[0].psk).toBeDefined();
});

it('non-admin with :read + per-entry canRead: returns entry with masked PSK', async () => {
mockDb.checkPermissionAsync.mockImplementation(
async (_uid: number, resource: string, action: string) =>
resource === 'channel_database' && action === 'read'
);
mockDb.channelDatabase.getPermissionsForUserAsync.mockResolvedValue([
{ userId: 60, channelDatabaseId: 1, canViewOnMap: false, canRead: true },
await harness.grant(harness.limited.id, 'channel_database', 'read');
vi.spyOn(databaseService.channelDatabase, 'getPermissionsForUserAsync').mockResolvedValue([
{ userId: harness.limited.id, channelDatabaseId: 1, canViewOnMap: false, canRead: true } as any,
]);
const res = await request(createApp(readerUser)).get('/api/channel-database');

const agent = await harness.loginAs(harness.limited);
const res = await agent.get('/api/channel-database');
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
expect(res.body.data[0].psk).toBeUndefined();
expect(res.body.data[0].pskPreview).toMatch(/\.\.\.$/);
});

it('non-admin without :read: returns 403', async () => {
const res = await request(createApp(noPermsUser)).get('/api/channel-database');
// No grants for limited — real checkPermissionAsync returns false.
const agent = await harness.loginAs(harness.limited);
const res = await agent.get('/api/channel-database');
expect(res.status).toBe(403);
});
});

// ── describe 3: GET /:id permission filtering ─────────────────────────────────

describe('Legacy mount — GET /:id permission filtering', () => {
it('non-admin with :read + per-entry canRead: returns channel with masked PSK', async () => {
mockDb.checkPermissionAsync.mockImplementation(
async (_uid: number, resource: string, action: string) =>
resource === 'channel_database' && action === 'read'
await harness.grant(harness.limited.id, 'channel_database', 'read');
vi.spyOn(databaseService.channelDatabase, 'getPermissionAsync').mockResolvedValue(
{ userId: harness.limited.id, channelDatabaseId: 1, canViewOnMap: false, canRead: true } as any
);
mockDb.channelDatabase.getPermissionAsync.mockResolvedValue({
userId: 60,
channelDatabaseId: 1,
canViewOnMap: false,
canRead: true,
});
const res = await request(createApp(readerUser)).get('/api/channel-database/1');

const agent = await harness.loginAs(harness.limited);
const res = await agent.get('/api/channel-database/1');
expect(res.status).toBe(200);
expect(res.body.data.psk).toBeUndefined();
expect(res.body.data.pskPreview).toMatch(/\.\.\.$/);
});

it('non-admin with :read but no per-entry row: returns 404', async () => {
mockDb.checkPermissionAsync.mockImplementation(
async (_uid: number, resource: string, action: string) =>
resource === 'channel_database' && action === 'read'
);
const res = await request(createApp(readerUser)).get('/api/channel-database/1');
await harness.grant(harness.limited.id, 'channel_database', 'read');
// getPermissionAsync already mocked to null (beforeEach default)

const agent = await harness.loginAs(harness.limited);
const res = await agent.get('/api/channel-database/1');
expect(res.status).toBe(404);
});

it('non-admin without :read: returns 403', async () => {
const res = await request(createApp(noPermsUser)).get('/api/channel-database/1');
const agent = await harness.loginAs(harness.limited);
const res = await agent.get('/api/channel-database/1');
expect(res.status).toBe(403);
});
});

// ── describe 4: Write endpoints require channel_database:write ────────────────

describe('Legacy mount — Write endpoints require channel_database:write', () => {
it('POST / → 403 for :read-only user', async () => {
mockDb.checkPermissionAsync.mockImplementation(
async (_uid: number, resource: string, action: string) =>
resource === 'channel_database' && action === 'read'
);
const res = await request(createApp(readerUser))
await harness.grant(harness.limited.id, 'channel_database', 'read');

const agent = await harness.loginAs(harness.limited);
const res = await agent
.post('/api/channel-database')
.send({ name: 'X', psk: fakePsk });
expect(res.status).toBe(403);
});

it('POST / → 201 for non-admin with :write', async () => {
mockDb.checkPermissionAsync.mockImplementation(
async (_uid: number, resource: string, action: string) =>
resource === 'channel_database' && action === 'write'
await harness.grant(harness.limited.id, 'channel_database', 'write');
vi.spyOn(databaseService.channelDatabase, 'getByIdAsync').mockResolvedValue(
{ ...channel1, id: 42 } as any
);
mockDb.channelDatabase.createAsync.mockResolvedValue(42);
mockDb.channelDatabase.getByIdAsync.mockResolvedValue({ ...channel1, id: 42 });
const res = await request(createApp(writerUser))

const agent = await harness.loginAs(harness.limited);
const res = await agent
.post('/api/channel-database')
.send({ name: 'X', psk: fakePsk });
expect(res.status).toBe(201);
});

it('admin bypasses write check (real admin bypass via resolveCallerScope)', async () => {
vi.spyOn(databaseService.channelDatabase, 'getByIdAsync').mockResolvedValue(
{ ...channel1, id: 42 } as any
);

const agent = await harness.loginAs(harness.admin);
const res = await agent
.post('/api/channel-database')
.send({ name: 'AdminChannel', psk: fakePsk });
expect(res.status).toBe(201);
});
});
Loading
Loading