mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
fix(fleet-snapshots): gate reads on admin role and encrypt content at rest (#1273)
* fix(fleet-snapshots): gate reads on admin role and encrypt content at rest Fleet snapshots capture every node's compose.yaml and .env, so the data is as sensitive as the live stacks. This hardens access and reliability across the snapshot pipeline. - Restrict snapshot reads to administrators. GET /api/fleet/snapshots and /:id now require the admin role, matching create, restore, and delete; the Fleet "Snapshots" tab and its panel render only for admins. Previously any authenticated user could enumerate snapshots and read every node's .env. - Encrypt snapshot file contents at rest with the instance key. Restore and cloud-archive paths decrypt on read, so cloud archives stay portable and a database copy no longer exposes stack secrets in plaintext. Rows written before this change still read back as plaintext. - Surface partial captures. A stack whose compose file cannot be read or fetched, or a file over the 1 MB capture cap, is recorded as a warning and shown on the snapshot instead of being silently dropped, so a snapshot is never mistaken for complete. Remote .env read errors are now distinguished from a genuinely absent .env. Adds route-authz, capture-warning, and encryption round-trip tests; updates the Fleet-Wide Backups feature docs. * fix(fleet-snapshots): gate cloud snapshot reads on admin role The cloud snapshot read routes were guarded by provider/license only, not by role, while their write counterparts (upload, delete) already required admin and the Cloud Backup settings surface is admin-only. Because a downloaded archive contains plaintext compose and .env files, a non-admin could list and download cloud snapshots and read every node's secrets, the same exposure the local snapshot reads were just closed against. - Require admin on GET /api/cloud-backup/snapshots, /status/:id, and /object/:keyB64/download, matching the local snapshot reads and the admin-only Cloud Backup settings section. - When capturing a remote node, treat a 200 response carrying X-Env-Exists: false as a stack with no .env (matching the local ENOENT path) instead of storing an empty .env that restore would later write back. Adds non-admin authorization tests for the cloud read routes and a remote absent-.env capture test.
This commit is contained in:
@@ -122,7 +122,10 @@ describe('POST /api/blueprints/:id/withdraw/:nodeId', () => {
|
||||
expect(fileRows).toHaveLength(1);
|
||||
expect(fileRows[0].stack_name).toBe(bp.name);
|
||||
expect(fileRows[0].filename).toBe('docker-compose.yml');
|
||||
expect(fileRows[0].content).toBe(compose);
|
||||
// Content is encrypted at rest; it decrypts back to the captured compose.
|
||||
const { CryptoService } = await import('../services/CryptoService');
|
||||
expect(CryptoService.getInstance().isEncrypted(fileRows[0].content)).toBe(true);
|
||||
expect(CryptoService.getInstance().decrypt(fileRows[0].content)).toBe(compose);
|
||||
expect(fileRows[0].node_id).toBe(node.id);
|
||||
});
|
||||
|
||||
|
||||
@@ -201,6 +201,47 @@ describe('Cloud backup tier gating', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud backup read routes are admin-only', () => {
|
||||
// The archive download returns plaintext compose/.env, so listing and
|
||||
// downloading cloud snapshots must be admin-gated like the local snapshot
|
||||
// reads, regardless of provider/tier.
|
||||
let viewerCookie: string;
|
||||
const keyB64 = Buffer.from('sencho/instances/x/snapshots/1.tar.gz').toString('base64url');
|
||||
|
||||
beforeAll(async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const bcrypt = (await import('bcrypt')).default;
|
||||
const hash = await bcrypt.hash('cloud-viewer-pass', 1);
|
||||
try { db.addUser({ username: 'cloud-viewer', password_hash: hash, role: 'viewer' }); } catch { /* may already exist */ }
|
||||
const login = await request(app).post('/api/auth/login').send({ username: 'cloud-viewer', password: 'cloud-viewer-pass' });
|
||||
const cookies = login.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
it('GET /snapshots returns 403 for a non-admin', async () => {
|
||||
const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('GET /status/:id returns 403 for a non-admin', async () => {
|
||||
const res = await request(app).get('/api/cloud-backup/status/1').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('GET /object/:keyB64/download returns 403 for a non-admin', async () => {
|
||||
const res = await request(app).get(`/api/cloud-backup/object/${keyB64}/download`).set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('does not block an admin on GET /snapshots', async () => {
|
||||
const res = await request(app).get('/api/cloud-backup/snapshots').set('Cookie', authCookie);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud backup config CRUD', () => {
|
||||
it('redacts secret_key on read; persists encrypted ciphertext', async () => {
|
||||
const putRes = await request(app)
|
||||
|
||||
@@ -192,8 +192,12 @@ describe('CloudBackupService — uploadSnapshot', () => {
|
||||
expect(parsed.instance_id).toBe('test-instance-id');
|
||||
expect(parsed.archive_version).toBe(1);
|
||||
|
||||
expect(entries.find(e => e.name === 'nodes/1_gateway/web/compose.yaml')).toBeDefined();
|
||||
expect(entries.find(e => e.name === 'nodes/1_gateway/web/.env')).toBeDefined();
|
||||
// Content is encrypted at rest but the archive must carry plaintext so a
|
||||
// downloaded snapshot restores on any instance (portability contract).
|
||||
const composeEntry = entries.find(e => e.name === 'nodes/1_gateway/web/compose.yaml');
|
||||
expect(composeEntry?.content).toBe('services: {}\n');
|
||||
const envEntry = entries.find(e => e.name === 'nodes/1_gateway/web/.env');
|
||||
expect(envEntry?.content).toBe('KEY=value\n');
|
||||
|
||||
expect(CloudBackupService.getInstance().getUploadStatus(snapshotId).status).toBe('success');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Fleet snapshot routes: admin-only read enforcement (a non-admin must not be
|
||||
* able to enumerate snapshots or read their secret-bearing .env content) and
|
||||
* content-at-rest encryption round-trip (file bodies stored as ciphertext, read
|
||||
* back as plaintext so restore and cloud-archive paths stay portable).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let CryptoService: typeof import('../services/CryptoService').CryptoService;
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
let snapshotId: number;
|
||||
|
||||
const VIEWER_USER = 'viewer-snap';
|
||||
const VIEWER_PASS = 'viewer-pass-123';
|
||||
const ENV_SECRET = 'DB_PASSWORD=s3cr3t-value\n';
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ CryptoService } = await import('../services/CryptoService'));
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const bcrypt = (await import('bcrypt')).default;
|
||||
const hash = await bcrypt.hash(VIEWER_PASS, 1);
|
||||
db.addUser({ username: VIEWER_USER, password_hash: hash, role: 'viewer' });
|
||||
const loginRes = await request(app).post('/api/auth/login').send({ username: VIEWER_USER, password: VIEWER_PASS });
|
||||
const cookies = loginRes.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
|
||||
snapshotId = db.createSnapshot('audit-seed', 'admin', 1, 1, '[]', '[]');
|
||||
db.insertSnapshotFiles(snapshotId, [
|
||||
{ nodeId: 1, nodeName: 'local', stackName: 'web', filename: 'compose.yaml', content: 'services: {}\n' },
|
||||
{ nodeId: 1, nodeName: 'local', stackName: 'web', filename: '.env', content: ENV_SECRET },
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('Fleet snapshot read authorization', () => {
|
||||
it('GET /api/fleet/snapshots requires authentication', async () => {
|
||||
const res = await request(app).get('/api/fleet/snapshots');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /api/fleet/snapshots returns 403 for a non-admin', async () => {
|
||||
const res = await request(app).get('/api/fleet/snapshots').set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/fleet/snapshots returns the list for an admin', async () => {
|
||||
const res = await request(app).get('/api/fleet/snapshots').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.snapshots)).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/fleet/snapshots/:id returns 403 for a non-admin', async () => {
|
||||
const res = await request(app).get(`/api/fleet/snapshots/${snapshotId}`).set('Cookie', viewerCookie);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('GET /api/fleet/snapshots/:id returns decrypted detail for an admin', async () => {
|
||||
const res = await request(app).get(`/api/fleet/snapshots/${snapshotId}`).set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
const files = res.body.nodes[0].stacks[0].files as Array<{ filename: string; content: string }>;
|
||||
const envFile = files.find(f => f.filename === '.env');
|
||||
expect(envFile?.content).toBe(ENV_SECRET);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshot content-at-rest encryption', () => {
|
||||
it('stores file content as ciphertext but reads it back as plaintext', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const raw = db.getDb().prepare(
|
||||
"SELECT content FROM fleet_snapshot_files WHERE snapshot_id = ? AND filename = '.env'",
|
||||
).get(snapshotId) as { content: string };
|
||||
|
||||
expect(CryptoService.getInstance().isEncrypted(raw.content)).toBe(true);
|
||||
expect(raw.content).not.toContain('s3cr3t');
|
||||
|
||||
const env = db.getSnapshotFiles(snapshotId).find(f => f.filename === '.env');
|
||||
expect(env?.content).toBe(ENV_SECRET);
|
||||
});
|
||||
|
||||
it('decrypts content on the restore read path (getSnapshotStackFiles)', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const files = db.getSnapshotStackFiles(snapshotId, 1, 'web');
|
||||
const env = files.find(f => f.filename === '.env');
|
||||
expect(env?.content).toBe(ENV_SECRET);
|
||||
});
|
||||
|
||||
it('reads a legacy plaintext row back verbatim (decrypt tolerates non-ciphertext)', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const legacyId = db.createSnapshot('legacy', 'admin', 1, 1, '[]', '[]');
|
||||
// Insert directly, bypassing insertSnapshotFiles' encryption, to simulate
|
||||
// a snapshot written before content-at-rest encryption shipped.
|
||||
db.getDb().prepare(
|
||||
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(legacyId, 1, 'local', 'legacy', 'compose.yaml', 'plain: text\n');
|
||||
|
||||
const files = db.getSnapshotFiles(legacyId);
|
||||
expect(files[0].content).toBe('plain: text\n');
|
||||
});
|
||||
});
|
||||
@@ -504,12 +504,22 @@ describe('Fleet snapshot admin enforcement', () => {
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('GET /api/fleet/snapshots succeeds for viewer (read-only)', async () => {
|
||||
it('GET /api/fleet/snapshots returns 403 for viewer', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/snapshots')
|
||||
.set('Authorization', viewerHeader);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
|
||||
it('GET /api/fleet/snapshots/1 returns 403 for viewer', async () => {
|
||||
mockTier('paid');
|
||||
const res = await request(app)
|
||||
.get('/api/fleet/snapshots/1')
|
||||
.set('Authorization', viewerHeader);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1412,6 +1412,7 @@ describe('SchedulerService - executeSnapshot', () => {
|
||||
1,
|
||||
1,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
|
||||
1,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Unit coverage for the fleet snapshot capture helpers: a stack whose compose
|
||||
* file cannot be captured must be surfaced as a warning rather than silently
|
||||
* dropped, a genuinely-absent .env must NOT warn, a real .env read error must,
|
||||
* and a file over the size cap must be skipped with a warning.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const mockGetStacks = vi.fn();
|
||||
const mockGetStackContent = vi.fn();
|
||||
const mockGetEnvContent = vi.fn();
|
||||
const mockGetProxyTarget = vi.fn();
|
||||
|
||||
vi.mock('../services/FileSystemService', () => ({
|
||||
FileSystemService: { getInstance: () => ({
|
||||
getStacks: mockGetStacks,
|
||||
getStackContent: mockGetStackContent,
|
||||
getEnvContent: mockGetEnvContent,
|
||||
}) },
|
||||
}));
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: { getInstance: () => ({ getProxyTarget: mockGetProxyTarget }) },
|
||||
}));
|
||||
|
||||
import {
|
||||
captureLocalNodeFiles,
|
||||
captureRemoteNodeFiles,
|
||||
MAX_SNAPSHOT_FILE_BYTES,
|
||||
} from '../utils/snapshot-capture';
|
||||
|
||||
const localNode = { id: 1, name: 'local', mode: 'proxy' as const };
|
||||
const remoteNode = { id: 2, name: 'remote', mode: 'proxy' as const };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetProxyTarget.mockReturnValue({ apiUrl: 'http://remote:1852', apiToken: 'tok' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('captureLocalNodeFiles', () => {
|
||||
it('captures compose and .env with no warnings on the happy path', async () => {
|
||||
mockGetStacks.mockResolvedValue(['web']);
|
||||
mockGetStackContent.mockResolvedValue('services: {}\n');
|
||||
mockGetEnvContent.mockResolvedValue('KEY=value\n');
|
||||
|
||||
const result = await captureLocalNodeFiles(localNode);
|
||||
|
||||
expect(result.stacks).toHaveLength(1);
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml', '.env']);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops the stack and warns when compose cannot be read', async () => {
|
||||
mockGetStacks.mockResolvedValue(['web']);
|
||||
mockGetStackContent.mockRejectedValue(new Error('EACCES'));
|
||||
|
||||
const result = await captureLocalNodeFiles(localNode);
|
||||
|
||||
expect(result.stacks).toHaveLength(0);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toMatchObject({ stackName: 'web' });
|
||||
expect(result.warnings[0].reason).toContain('compose.yaml could not be read');
|
||||
});
|
||||
|
||||
it('does not warn when the .env is simply absent (ENOENT)', async () => {
|
||||
mockGetStacks.mockResolvedValue(['web']);
|
||||
mockGetStackContent.mockResolvedValue('services: {}\n');
|
||||
mockGetEnvContent.mockRejectedValue(Object.assign(new Error('no file'), { code: 'ENOENT' }));
|
||||
|
||||
const result = await captureLocalNodeFiles(localNode);
|
||||
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml']);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns but still captures the stack when the .env read fails for a non-ENOENT reason', async () => {
|
||||
mockGetStacks.mockResolvedValue(['web']);
|
||||
mockGetStackContent.mockResolvedValue('services: {}\n');
|
||||
mockGetEnvContent.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' }));
|
||||
|
||||
const result = await captureLocalNodeFiles(localNode);
|
||||
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml']);
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0].reason).toContain('.env could not be read');
|
||||
});
|
||||
|
||||
it('skips a compose file that exceeds the size cap and warns', async () => {
|
||||
mockGetStacks.mockResolvedValue(['web']);
|
||||
mockGetStackContent.mockResolvedValue('x'.repeat(MAX_SNAPSHOT_FILE_BYTES + 1));
|
||||
|
||||
const result = await captureLocalNodeFiles(localNode);
|
||||
|
||||
expect(result.stacks).toHaveLength(0);
|
||||
expect(result.warnings[0].reason).toContain('exceeds');
|
||||
});
|
||||
|
||||
it('keeps the stack but warns when the .env exceeds the size cap', async () => {
|
||||
mockGetStacks.mockResolvedValue(['web']);
|
||||
mockGetStackContent.mockResolvedValue('services: {}\n');
|
||||
mockGetEnvContent.mockResolvedValue('x'.repeat(MAX_SNAPSHOT_FILE_BYTES + 1));
|
||||
|
||||
const result = await captureLocalNodeFiles(localNode);
|
||||
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml']);
|
||||
expect(result.warnings[0].reason).toContain('exceeds');
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureRemoteNodeFiles', () => {
|
||||
function mockFetchRoutes(routes: Record<string, Partial<Response> & { jsonValue?: unknown; textValue?: string; xEnvExists?: string }>) {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
const match = Object.keys(routes).find(k => url.endsWith(k));
|
||||
const r = match ? routes[match] : { ok: false, status: 404 };
|
||||
return {
|
||||
ok: r.ok ?? true,
|
||||
status: r.status ?? 200,
|
||||
headers: { get: (name: string) => name.toLowerCase() === 'x-env-exists' ? ((r as { xEnvExists?: string }).xEnvExists ?? null) : null },
|
||||
json: async () => (r as { jsonValue?: unknown }).jsonValue,
|
||||
text: async () => (r as { textValue?: string }).textValue ?? '',
|
||||
} as unknown as Response;
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
it('captures compose and .env with no warnings on the happy path', async () => {
|
||||
mockFetchRoutes({
|
||||
'/api/stacks': { ok: true, jsonValue: ['web'] },
|
||||
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
|
||||
'/api/stacks/web/env': { ok: true, textValue: 'KEY=value\n' },
|
||||
});
|
||||
|
||||
const result = await captureRemoteNodeFiles(remoteNode);
|
||||
|
||||
expect(result.stacks).toHaveLength(1);
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml', '.env']);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('drops the stack and warns when the remote compose fetch is not ok', async () => {
|
||||
mockFetchRoutes({
|
||||
'/api/stacks': { ok: true, jsonValue: ['web'] },
|
||||
'/api/stacks/web': { ok: false, status: 500 },
|
||||
});
|
||||
|
||||
const result = await captureRemoteNodeFiles(remoteNode);
|
||||
|
||||
expect(result.stacks).toHaveLength(0);
|
||||
expect(result.warnings[0].reason).toContain('HTTP 500');
|
||||
});
|
||||
|
||||
it('treats a remote .env as absent (no empty file) when X-Env-Exists is false', async () => {
|
||||
mockFetchRoutes({
|
||||
'/api/stacks': { ok: true, jsonValue: ['web'] },
|
||||
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
|
||||
'/api/stacks/web/env': { ok: true, textValue: '', xEnvExists: 'false' },
|
||||
});
|
||||
|
||||
const result = await captureRemoteNodeFiles(remoteNode);
|
||||
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml']);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('treats a 404 .env as absent without warning', async () => {
|
||||
mockFetchRoutes({
|
||||
'/api/stacks': { ok: true, jsonValue: ['web'] },
|
||||
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
|
||||
'/api/stacks/web/env': { ok: false, status: 404 },
|
||||
});
|
||||
|
||||
const result = await captureRemoteNodeFiles(remoteNode);
|
||||
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml']);
|
||||
expect(result.warnings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warns when the remote .env fetch fails for a non-404 reason', async () => {
|
||||
mockFetchRoutes({
|
||||
'/api/stacks': { ok: true, jsonValue: ['web'] },
|
||||
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
|
||||
'/api/stacks/web/env': { ok: false, status: 500 },
|
||||
});
|
||||
|
||||
const result = await captureRemoteNodeFiles(remoteNode);
|
||||
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml']);
|
||||
expect(result.warnings[0].reason).toContain('HTTP 500');
|
||||
});
|
||||
|
||||
it('keeps the stack but warns when the remote .env exceeds the size cap', async () => {
|
||||
mockFetchRoutes({
|
||||
'/api/stacks': { ok: true, jsonValue: ['web'] },
|
||||
'/api/stacks/web': { ok: true, textValue: 'services: {}\n' },
|
||||
'/api/stacks/web/env': { ok: true, textValue: 'x'.repeat(MAX_SNAPSHOT_FILE_BYTES + 1) },
|
||||
});
|
||||
|
||||
const result = await captureRemoteNodeFiles(remoteNode);
|
||||
|
||||
expect(result.stacks[0].files.map(f => f.filename)).toEqual(['compose.yaml']);
|
||||
expect(result.warnings[0].reason).toContain('exceeds');
|
||||
});
|
||||
|
||||
it('drops the stack and warns when the remote compose fetch throws', async () => {
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
if (url.endsWith('/api/stacks')) {
|
||||
return { ok: true, status: 200, json: async () => ['web'], text: async () => '' } as Response;
|
||||
}
|
||||
throw new Error('network down');
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await captureRemoteNodeFiles(remoteNode);
|
||||
|
||||
expect(result.stacks).toHaveLength(0);
|
||||
expect(result.warnings[0].reason).toContain('fetch error');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user