Files
sencho/backend/src/__tests__/stack-backup-route.test.ts
T
Anso 6fc7f200a6 fix(scheduled-ops): run stack lifecycle schedules on remote nodes and harden run visibility (#1260)
* fix(scheduled-ops): run stack lifecycle schedules on remote nodes and harden run visibility

Stack lifecycle schedules (Restart, Stop, Take Down, Start, Backup Stack
Files) now run against whichever node the schedule targets, local or
remote. Each remote run proxies to that node's own stack-operation
endpoint, so a hub-managed schedule reaches the node that actually holds
the stack. Restart with a service subset restarts each selected service
and, if one fails, names the services already restarted so run history
reflects the stack's partial state. Auto-start on a remote node runs that
node's own pre-deploy scan-policy check against the images it holds.

Add POST /api/stacks/:name/backup to trigger an on-demand backup of a
stack's compose and env files (the same rollback snapshot a deploy
takes); it backs the remote backup schedule and is available to operators
on its own.

A scheduled task that reaches execution on an unpaid licence is now
skipped and written to run history as a failed run, so a manual trigger
that returned a queued response never silently disappears.

Test plan:
- Backend unit + integration: scheduler-service (remote proxy per action,
  per-service fan-out, auto-start policy delegation, remote-failure and
  no-credentials paths, unpaid-tier skip), stack-backup-route
  (auth/role/paid/404/400/500), scheduled-tasks-routes.
- Frontend component test for the schedules view (list, prefill, node
  filter, create payload).
- tsc and lint clean on both packages.

* fix(scheduled-ops): lock the stack-files backup route against concurrent stack ops

The stack-files backup writes the same slot the pre-deploy rollback
snapshot uses, so running it while a deploy, update, or rollback is in
flight on the same stack could overwrite the rollback point. The backup
route now takes the per-stack operation lock (as deploy/down/restart do)
and returns 409 when the stack is busy, keeping the rollback snapshot
intact. Adds the 'backup' action to the stack-op lock type and a busy
participle for the 409 message.

* fix(scheduled-ops): enforce backup-path containment inline at the filesystem sink

The on-demand backup route passes the stack name straight into
backupStackFiles, so resolve the backup directory against the backup root
and confirm containment with an inline startsWith check before the
mkdir/copy/write sinks, matching the barrier restoreStackFiles already
uses. The stack name is validated at the route and again by
resolveStackDir, so this is defense in depth that also closes a
static path-injection finding on the new call path.
2026-05-31 17:47:34 -04:00

128 lines
5.2 KiB
TypeScript

/**
* Integration tests for POST /api/stacks/:stackName/backup, the on-demand
* stack-files backup trigger. Covers auth, role, paid gating, the success
* path, the missing-stack 404, name validation, and error propagation. The
* route exists so a scheduled auto_backup can run on a remote node through the
* proxy path, and so an operator can take a snapshot on demand.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
const { mockBackupStackFiles, mockHasComposeFile } = vi.hoisted(() => ({
mockBackupStackFiles: vi.fn(),
mockHasComposeFile: vi.fn(),
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
getBaseDir: () => '/tmp/compose',
hasComposeFile: mockHasComposeFile,
backupStackFiles: mockBackupStackFiles,
getStacks: vi.fn().mockResolvedValue([]),
}),
},
}));
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let adminCookie: string;
let viewerCookie: string;
let tierSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
const { LicenseService } = await import('../services/LicenseService');
tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
const viewerHash = await bcrypt.hash('viewerpass', 1);
DatabaseService.getInstance().addUser({ username: 'backup-viewer', password_hash: viewerHash, role: 'viewer' });
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'backup-viewer', password: 'viewerpass' });
const cookies = viewerRes.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
beforeEach(() => {
mockBackupStackFiles.mockReset().mockResolvedValue(undefined);
mockHasComposeFile.mockReset().mockResolvedValue(true);
tierSpy.mockReturnValue('paid');
});
describe('POST /api/stacks/:stackName/backup', () => {
it('backs up the stack files and returns success', async () => {
const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(mockBackupStackFiles).toHaveBeenCalledWith('web');
});
it('returns 401 without an auth cookie', async () => {
const res = await request(app).post('/api/stacks/web/backup');
expect(res.status).toBe(401);
expect(mockBackupStackFiles).not.toHaveBeenCalled();
});
it('returns 403 for a viewer (no deploy permission)', async () => {
const res = await request(app).post('/api/stacks/web/backup').set('Cookie', viewerCookie);
expect(res.status).toBe(403);
expect(mockBackupStackFiles).not.toHaveBeenCalled();
});
it('returns 403 on the community tier', async () => {
tierSpy.mockReturnValue('community');
const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
expect(res.status).toBe(403);
expect(mockBackupStackFiles).not.toHaveBeenCalled();
});
it('returns 404 when the stack does not exist', async () => {
mockHasComposeFile.mockResolvedValue(false);
const res = await request(app).post('/api/stacks/ghost/backup').set('Cookie', adminCookie);
expect(res.status).toBe(404);
expect(mockBackupStackFiles).not.toHaveBeenCalled();
});
it('returns 400 for an invalid stack name', async () => {
const res = await request(app).post('/api/stacks/..bad../backup').set('Cookie', adminCookie);
expect(res.status).toBe(400);
expect(mockBackupStackFiles).not.toHaveBeenCalled();
});
it('returns 500 when the backup operation fails', async () => {
mockBackupStackFiles.mockRejectedValue(new Error('disk full'));
const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
expect(res.status).toBe(500);
expect(res.body.error).toContain('disk full');
});
it('returns 409 when the stack is busy with another operation', async () => {
// The backup shares the rollback slot, so it must not run while a deploy
// holds the stack-op lock for the same stack.
const { StackOpLockService } = await import('../services/StackOpLockService');
const localNodeId = DatabaseService.getInstance().getNodes().find(n => n.type === 'local')!.id;
StackOpLockService.getInstance().tryAcquire(localNodeId, 'web', 'deploy', 'someone');
try {
const res = await request(app).post('/api/stacks/web/backup').set('Cookie', adminCookie);
expect(res.status).toBe(409);
expect(res.body.code).toBe('stack_op_in_progress');
expect(mockBackupStackFiles).not.toHaveBeenCalled();
} finally {
StackOpLockService.getInstance().release(localNodeId, 'web');
}
});
});