Files
TREK/server/tests/integration/backup.test.ts
T
Maurice b3a085df3f refactor(server): make the state ports async and route every reader through the provided store
The process-state ports were meant to let a store shared between
processes replace the in-memory one, but they could not. The readers
outside the container (the broadcast functions, the backup restore's
permissions-cache flush, the pending-code sweep, hand-built consumers)
went straight to the module-level in-memory instances, so an overridden
provider was used by the gateway and the services while the broadcasts
and the restore flush kept using the old store. And every port was
synchronous, while a store in the database or on the network is async,
so plugging one in would have changed every signature anyway.

RateLimitStore, PendingCodeStore, OidcFlowStore and
PermissionsCacheStore now return promises, and RateLimitService,
OauthService, OidcService, PermissionsService and their callers await
them. The in-memory stores still decide synchronously, so who gets a
429, a code or a cached level is unchanged. RoomRegistry stays
synchronous: membership holds live sockets and broadcast() is a
synchronous call every domain makes, so several processes need a
fan-out in front of broadcast(), not a different place for the sets.

The process-wide ports get a ProcessStoreSlot. PermissionsModule,
OauthModule and RealtimeGatewayModule install the store the container
resolved and release it on shutdown, and the free functions, the sweep
and hand-built consumers read the slot, so overriding the provider
changes every reader. Tests provide fake stores and check that the
broadcasts, the restore flush and the sweep use them.

Two callers needed more than an await. The transit MCP tools counted in
a RateLimitService of their own that no provider could reach; they now
take the one RateLimitModule provides (their mcp_transit_* buckets never
overlap the REST ones). The link preview claimed its in-flight slot only
after the budget check, which now yields, so simultaneous askers would
each have been charged; it now claims the slot first, and an asker that
joined someone out of budget asks again on its own budget.

The unused putPendingCode/takePendingCode helpers are gone.
2026-10-08 18:41:01 +02:00

435 lines
16 KiB
TypeScript

/**
* Backup integration tests.
* Covers BACKUP-001 to BACKUP-008.
*
* Note: createBackup() is async and creates real files.
* These tests run in test env and may not have a full DB file to zip,
* but the service should handle gracefully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
// Mock filesystem-dependent service functions to avoid real disk I/O in tests
vi.mock('../../src/nest/backup/backup.impl', async () => {
const actual = await vi.importActual<typeof import('../../src/nest/backup/backup.impl')>('../../src/nest/backup/backup.impl');
return {
...actual,
createBackup: vi.fn().mockResolvedValue({
filename: 'backup-2026-04-03T06-00-00.zip',
size: 1024,
sizeText: '1.0 KB',
created_at: new Date().toISOString(),
}),
restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
restoreBackup: vi.fn().mockResolvedValue({ success: true }),
deleteBackup: vi.fn().mockReturnValue(undefined),
backupFileExists: vi.fn().mockReturnValue(false),
// Keep checkRateLimit from actual so rate-limit tests work correctly
checkRateLimit: vi.fn().mockReturnValue(true),
};
});
// The auto-settings routes live on AutoBackupJob now; keep its settings-file
// I/O off the real data/ dir (the scheduling itself is off under the test gate).
vi.mock('../../src/nest/backup/auto-backup.settings', async () => {
const actual = await vi.importActual<typeof import('../../src/nest/backup/auto-backup.settings')>('../../src/nest/backup/auto-backup.settings');
return {
...actual,
loadSettings: vi.fn(() => ({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })),
saveSettings: vi.fn(),
};
});
import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createAdmin, createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import * as backupService from '../../src/nest/backup/backup.impl';
import { DEFAULT_BACKUPS_ROOT } from '../../src/nest/storage/storage-paths';
import fs from 'fs';
import path from 'path';
import os from 'os';
let nestApp: INestApplication;
let app: Application;
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
describe('Backup access control', () => {
it('non-admin cannot access backup routes', async () => {
const { user } = createUser(testDb);
const res = await request(app)
.get('/api/backup/list')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(403);
});
});
describe('Backup list', () => {
it('BACKUP-001 — GET /backup/list returns backups array', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/backup/list')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(Array.isArray(res.body.backups)).toBe(true);
});
});
describe('Backup creation', () => {
it('BACKUP-001 — POST /backup/create creates a backup', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/backup/create')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.backup).toHaveProperty('filename');
expect(res.body.backup).toHaveProperty('size');
});
});
describe('Auto-backup settings', () => {
it('BACKUP-008 — GET /backup/auto-settings returns current config', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/backup/auto-settings')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('settings');
expect(res.body.settings).toHaveProperty('enabled');
});
it('BACKUP-008 — PUT /backup/auto-settings updates settings', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.put('/api/backup/auto-settings')
.set('Cookie', authCookie(admin.id))
.send({ enabled: false, interval: 'daily', keep_days: 7 });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('settings');
expect(res.body.settings).toHaveProperty('enabled');
expect(res.body.settings).toHaveProperty('interval');
});
});
describe('Backup security', () => {
it('BACKUP-007 — Download with path traversal filename is rejected', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.get('/api/backup/download/../../etc/passwd')
.set('Cookie', authCookie(admin.id));
// Express normalises the URL before routing; path traversal gets resolved
// to a path that matches no route → 404
expect(res.status).toBe(404);
});
it('BACKUP-007 — Delete with path traversal filename is rejected', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.delete('/api/backup/../../../etc/passwd')
.set('Cookie', authCookie(admin.id));
// Express normalises the URL, stripping traversal → no route match → 404
expect(res.status).toBe(404);
});
});
// ---------------------------------------------------------------------------
// Download
// ---------------------------------------------------------------------------
describe('Backup download', () => {
// Serving goes through the real StorageService now (sendBackupToResponse is
// NOT overridden in the impl mock), so the fixture must live in the real
// backups root. Only this one file is cleaned up — never the whole dir.
const downloadFixture = 'backup-2026-04-06T12-00-00.zip';
const downloadFixturePath = path.join(DEFAULT_BACKUPS_ROOT, downloadFixture);
beforeEach(() => {
vi.mocked(backupService.backupFileExists).mockResolvedValue(true);
});
afterAll(() => {
try { fs.unlinkSync(downloadFixturePath); } catch {}
});
it('BACKUP-INT-001 — GET /backup/download/:filename returns 200 with content-disposition', async () => {
const { user: admin } = createAdmin(testDb);
// (Re)write the fixture immediately before the request that serves it.
fs.mkdirSync(DEFAULT_BACKUPS_ROOT, { recursive: true });
fs.writeFileSync(downloadFixturePath, 'fake zip content');
const res = await request(app)
.get(`/api/backup/download/${downloadFixture}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toMatch(/attachment/i);
expect(res.headers['content-disposition']).toContain(downloadFixture);
// superagent has no parser for application/zip, so assert the byte count
// rather than the (unbuffered) body.
expect(res.headers['content-length']).toBe(String('fake zip content'.length));
});
it('BACKUP-INT-002 — GET /backup/download/:filename returns 400 for invalid filename', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.backupFileExists).mockResolvedValue(false);
const res = await request(app)
.get('/api/backup/download/not-a-valid-name.tar.gz')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid filename/i);
});
it('BACKUP-INT-003 — GET /backup/download/:filename returns 404 when file not found', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.backupFileExists).mockResolvedValue(false);
const res = await request(app)
.get('/api/backup/download/backup-2026-04-06T12-00-00.zip')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
});
// ---------------------------------------------------------------------------
// Restore from existing backup
// ---------------------------------------------------------------------------
describe('Backup restore', () => {
it('BACKUP-INT-004 — POST /backup/restore/:filename returns 200 on success', async () => {
const { user: admin } = createAdmin(testDb);
const filename = 'backup-2026-04-06T12-00-00.zip';
vi.mocked(backupService.backupFileExists).mockResolvedValue(true);
vi.mocked(backupService.restoreBackup).mockResolvedValue({ success: true });
const res = await request(app)
.post(`/api/backup/restore/${filename}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(vi.mocked(backupService.restoreBackup)).toHaveBeenCalledWith(expect.anything(), filename);
});
it('BACKUP-INT-005 — POST /backup/restore/:filename returns 404 when backup not found', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.backupFileExists).mockResolvedValue(false);
const res = await request(app)
.post('/api/backup/restore/backup-2026-04-06T12-00-00.zip')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('BACKUP-INT-006 — POST /backup/restore/:filename returns 400 for invalid filename', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/backup/restore/../../evil.zip')
.set('Cookie', authCookie(admin.id));
// Express resolves path traversal → no route or invalid filename check
expect([400, 404]).toContain(res.status);
});
it('BACKUP-INT-007 — POST /backup/restore/:filename returns 400 when the restore reports failure', async () => {
const { user: admin } = createAdmin(testDb);
const filename = 'backup-2026-04-06T12-00-00.zip';
vi.mocked(backupService.backupFileExists).mockResolvedValue(true);
vi.mocked(backupService.restoreBackup).mockResolvedValue({
success: false,
error: 'Invalid backup: travel.db not found',
status: 400,
});
const res = await request(app)
.post(`/api/backup/restore/${filename}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/travel\.db not found/i);
});
});
// ---------------------------------------------------------------------------
// Delete backup
// ---------------------------------------------------------------------------
describe('Backup delete', () => {
it('BACKUP-INT-008 — DELETE /backup/:filename returns 200 on success', async () => {
const { user: admin } = createAdmin(testDb);
const filename = 'backup-2026-04-06T12-00-00.zip';
vi.mocked(backupService.backupFileExists).mockResolvedValue(true);
vi.mocked(backupService.deleteBackup).mockReturnValue(undefined);
const res = await request(app)
.delete(`/api/backup/${filename}`)
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
// First arg is the app's real StorageService (BackupService forwards it).
expect(vi.mocked(backupService.deleteBackup)).toHaveBeenCalledWith(expect.anything(), filename);
});
it('BACKUP-INT-009 — DELETE /backup/:filename returns 404 when not found', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.backupFileExists).mockResolvedValue(false);
const res = await request(app)
.delete('/api/backup/backup-2026-04-06T12-00-00.zip')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
it('BACKUP-INT-010 — DELETE /backup/:filename returns 400 for invalid filename', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.delete('/api/backup/not-a-backup.tar.gz')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid filename/i);
});
});
// ---------------------------------------------------------------------------
// Rate limiter on POST /create
// ---------------------------------------------------------------------------
describe('Backup rate limiter', () => {
it('BACKUP-INT-011 — POST /backup/create returns 429 after 3 requests', async () => {
const { user: admin } = createAdmin(testDb);
// Allow first 3 calls, then block
let callCount = 0;
vi.mocked(backupService.checkRateLimit).mockImplementation(() => {
callCount++;
return callCount <= 3;
});
// First 3 succeed
for (let i = 0; i < 3; i++) {
const res = await request(app)
.post('/api/backup/create')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(200);
}
// 4th is rate-limited
const res = await request(app)
.post('/api/backup/create')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(429);
expect(res.body.error).toMatch(/too many/i);
});
});
// ---------------------------------------------------------------------------
// Upload-restore
// ---------------------------------------------------------------------------
describe('Backup upload-restore', () => {
it('BACKUP-INT-012 — POST /backup/upload-restore with zip file returns 200', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.restoreFromZip).mockResolvedValue({ success: true });
// Create a minimal fake zip buffer (just needs to pass multer's file filter)
const fakeZipBuffer = Buffer.from('PK\x03\x04'); // ZIP magic bytes
const res = await request(app)
.post('/api/backup/upload-restore')
.set('Cookie', authCookie(admin.id))
.attach('backup', fakeZipBuffer, { filename: 'test-restore.zip', contentType: 'application/zip' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(vi.mocked(backupService.restoreFromZip)).toHaveBeenCalled();
});
it('BACKUP-INT-013 — POST /backup/upload-restore with no file returns 400', async () => {
const { user: admin } = createAdmin(testDb);
const res = await request(app)
.post('/api/backup/upload-restore')
.set('Cookie', authCookie(admin.id));
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/no file/i);
});
it('BACKUP-INT-014 — POST /backup/upload-restore returns 400 when restore fails', async () => {
const { user: admin } = createAdmin(testDb);
vi.mocked(backupService.restoreFromZip).mockResolvedValue({
success: false,
error: 'Uploaded file is not a valid SQLite database',
status: 400,
});
const fakeZipBuffer = Buffer.from('PK\x03\x04');
const res = await request(app)
.post('/api/backup/upload-restore')
.set('Cookie', authCookie(admin.id))
.attach('backup', fakeZipBuffer, { filename: 'bad-restore.zip', contentType: 'application/zip' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not a valid SQLite/i);
});
});