mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
0f9925e04f
* feat: block self-stack lifecycle ops with UI and preflight guardrails Refuse update, deploy, down, stop, and delete when the stack matches Sencho's compose project. Return 409 self_stack_protected. Expose isSelf on /statuses and disable guarded UI actions. Add SelfStackProtectedDialog and self-managed-stack preflight warning. Closes #1564 * fix: add missing stackSelfFlags mock to useSidebarContextMenu test The production hook now reads stackListState.stackSelfFlags[file], but the test mock did not include it, causing 6 tests to fail with TypeError: Cannot read properties of undefined (reading 'web.yml'). * fix: harden self-stack protection during startup Add a global environment preflight warning when Sencho is managed inside COMPOSE_DIR. Align status decoration and route guards on Docker label fallback detection. Block rollback and service-level stop on the protected self stack. * fix: add self_stack_location to diagnostics-route expected check IDs
362 lines
16 KiB
TypeScript
362 lines
16 KiB
TypeScript
/**
|
|
* Unit tests for EnvironmentCheckService.collectEnvironmentReport: the mapping
|
|
* from injected probe results to check rows, the per-check verdicts, and the
|
|
* guarantee that every non-pass row carries actionable remediation. IO is
|
|
* stubbed, so these run without a Docker daemon or filesystem.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
collectEnvironmentReport,
|
|
pickBackingMount,
|
|
type EnvironmentProbes,
|
|
type EnvironmentCheck,
|
|
} from '../services/EnvironmentCheckService';
|
|
|
|
function baseProbes(overrides: Partial<EnvironmentProbes> = {}): EnvironmentProbes {
|
|
return {
|
|
proto: 'https',
|
|
host: 'sencho.example.com',
|
|
composeDir: '/app/compose',
|
|
pingDocker: async () => { /* reachable */ },
|
|
composeVersion: async () => 'v2.29.0',
|
|
accessDir: async () => ({ exists: true, isDir: true, writable: true }),
|
|
bindMounts: async () => [{ source: '/app/compose', destination: '/app/compose' }],
|
|
selfStackDirectoryName: async () => null,
|
|
diskUsage: async () => ({ usePercent: 40, freeBytes: 50 * 1024 ** 3 }),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function byId(checks: EnvironmentCheck[], id: string): EnvironmentCheck {
|
|
const found = checks.find(c => c.id === id);
|
|
if (!found) throw new Error(`missing check: ${id}`);
|
|
return found;
|
|
}
|
|
|
|
// remediation only exists on warn / fail rows (discriminated union); this reads
|
|
// it without forcing a narrow at every assertion site.
|
|
function remediationOf(c: EnvironmentCheck): string | undefined {
|
|
return 'remediation' in c ? c.remediation : undefined;
|
|
}
|
|
|
|
describe('collectEnvironmentReport', () => {
|
|
it('passes every check on a healthy environment', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes());
|
|
expect(checks.map(c => c.id)).toEqual([
|
|
'docker_socket', 'docker_compose', 'compose_dir', 'self_stack_location', 'path_mapping', 'tls', 'disk_space',
|
|
]);
|
|
expect(checks.every(c => c.status === 'pass')).toBe(true);
|
|
});
|
|
|
|
it('every non-pass row carries remediation', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
pingDocker: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); },
|
|
composeVersion: async () => { throw new Error('not found'); },
|
|
accessDir: async () => ({ exists: false, isDir: false, writable: false }),
|
|
bindMounts: async () => [{ source: '/host/compose', destination: '/app/compose' }],
|
|
proto: 'http',
|
|
diskUsage: async () => ({ usePercent: 96, freeBytes: 1 * 1024 ** 3 }),
|
|
}));
|
|
for (const c of checks) {
|
|
if (c.status !== 'pass') expect(c.remediation, `${c.id} needs remediation`).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
describe('self_stack_location', () => {
|
|
it('warns when Sencho compose project is inside COMPOSE_DIR', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
selfStackDirectoryName: async () => 'sencho',
|
|
accessDir: async (dir) => ({
|
|
exists: dir.replace(/\\/g, '/').endsWith('/app/compose') || dir.replace(/\\/g, '/').endsWith('/app/compose/sencho'),
|
|
isDir: true,
|
|
writable: true,
|
|
}),
|
|
}));
|
|
const c = byId(checks, 'self_stack_location');
|
|
expect(c.status).toBe('warn');
|
|
expect(c.detail).toMatch(/inside COMPOSE_DIR/i);
|
|
expect(remediationOf(c)).toMatch(/Fleet -> Node Update/i);
|
|
});
|
|
|
|
it('passes when the running project is not a managed stack directory', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
selfStackDirectoryName: async () => 'sencho',
|
|
accessDir: async (dir) => ({
|
|
exists: dir.replace(/\\/g, '/').endsWith('/app/compose'),
|
|
isDir: true,
|
|
writable: true,
|
|
}),
|
|
}));
|
|
const c = byId(checks, 'self_stack_location');
|
|
expect(c.status).toBe('pass');
|
|
});
|
|
|
|
it('warns when self-stack location cannot be verified', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
selfStackDirectoryName: async () => { throw new Error('inspect failed'); },
|
|
}));
|
|
const c = byId(checks, 'self_stack_location');
|
|
expect(c.status).toBe('warn');
|
|
expect(c.detail).toMatch(/Could not verify/i);
|
|
});
|
|
});
|
|
|
|
describe('docker_socket', () => {
|
|
it('flags a permission error distinctly', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
pingDocker: async () => { throw Object.assign(new Error('denied'), { code: 'EACCES' }); },
|
|
}));
|
|
const c = byId(checks, 'docker_socket');
|
|
expect(c.status).toBe('fail');
|
|
// The permission remediation is the only one that mentions the docker group.
|
|
expect(remediationOf(c)).toMatch(/docker group/i);
|
|
});
|
|
|
|
it('flags an unreachable daemon with different guidance', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
pingDocker: async () => { throw Object.assign(new Error('down'), { code: 'ENOENT' }); },
|
|
}));
|
|
const c = byId(checks, 'docker_socket');
|
|
expect(c.status).toBe('fail');
|
|
expect(remediationOf(c)).toMatch(/running/i);
|
|
// Must not give the permission-fix advice for a daemon-down error.
|
|
expect(remediationOf(c)).not.toMatch(/docker group/i);
|
|
});
|
|
});
|
|
|
|
describe('docker_compose', () => {
|
|
it('fails when the plugin is absent', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
composeVersion: async () => { throw new Error('unknown command "compose"'); },
|
|
}));
|
|
const c = byId(checks, 'docker_compose');
|
|
expect(c.status).toBe('fail');
|
|
expect(remediationOf(c)).toMatch(/install/i);
|
|
});
|
|
|
|
it('warns (not fails) when the version check times out', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
composeVersion: async () => { throw Object.assign(new Error('timed out'), { killed: true, signal: 'SIGTERM', code: 'ETIMEDOUT' }); },
|
|
}));
|
|
const c = byId(checks, 'docker_compose');
|
|
expect(c.status).toBe('warn');
|
|
// A timeout must not tell the operator to install an already-present plugin.
|
|
expect(remediationOf(c)).not.toMatch(/install/i);
|
|
});
|
|
|
|
it('reports the version string on success', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({ composeVersion: async () => 'v2.31.0' }));
|
|
const c = byId(checks, 'docker_compose');
|
|
expect(c.status).toBe('pass');
|
|
expect(c.detail).toContain('v2.31.0');
|
|
});
|
|
});
|
|
|
|
describe('compose_dir', () => {
|
|
it('fails when the directory is missing', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
accessDir: async () => ({ exists: false, isDir: false, writable: false }),
|
|
}));
|
|
expect(byId(checks, 'compose_dir').status).toBe('fail');
|
|
});
|
|
|
|
it('fails when the directory is not writable', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
accessDir: async () => ({ exists: true, isDir: true, writable: false }),
|
|
}));
|
|
const c = byId(checks, 'compose_dir');
|
|
expect(c.status).toBe('fail');
|
|
expect(c.detail).toMatch(/not writable/i);
|
|
});
|
|
|
|
it('fails when the path exists but is not a directory', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
accessDir: async () => ({ exists: true, isDir: false, writable: false }),
|
|
}));
|
|
const c = byId(checks, 'compose_dir');
|
|
expect(c.status).toBe('fail');
|
|
expect(c.detail).toMatch(/not a directory/i);
|
|
});
|
|
});
|
|
|
|
describe('path_mapping', () => {
|
|
it('passes when not containerized', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({ bindMounts: async () => null }));
|
|
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
|
});
|
|
|
|
it('warns (not a false pass) when containerized but mounts are unreadable', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
bindMounts: async () => { throw new Error('self-inspect unavailable'); },
|
|
}));
|
|
const c = byId(checks, 'path_mapping');
|
|
expect(c.status).toBe('warn');
|
|
expect(c.detail).toMatch(/could not read/i);
|
|
});
|
|
|
|
it('warns when host and container paths differ', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
bindMounts: async () => [{ source: '/srv/host-compose', destination: '/app/compose' }],
|
|
}));
|
|
const c = byId(checks, 'path_mapping');
|
|
expect(c.status).toBe('warn');
|
|
expect(remediationOf(c)).toContain('/app/compose:/app/compose');
|
|
});
|
|
|
|
it('warns when the compose dir is not under a bind mount', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
bindMounts: async () => [{ source: '/srv/other', destination: '/data' }],
|
|
}));
|
|
expect(byId(checks, 'path_mapping').status).toBe('warn');
|
|
});
|
|
|
|
it('treats a trailing slash as equal', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
bindMounts: async () => [{ source: '/app/compose/', destination: '/app/compose' }],
|
|
}));
|
|
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
|
});
|
|
|
|
it('passes a 1:1 parent bind that covers the compose dir', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
composeDir: '/opt/compose',
|
|
bindMounts: async () => [{ source: '/opt', destination: '/opt' }],
|
|
}));
|
|
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
|
});
|
|
|
|
it('passes a 1:1 root bind that covers the compose dir', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
composeDir: '/opt/compose',
|
|
bindMounts: async () => [{ source: '/', destination: '/' }],
|
|
}));
|
|
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
|
});
|
|
|
|
it('warns when a parent bind maps the compose dir to a different host path', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
composeDir: '/opt/compose',
|
|
bindMounts: async () => [{ source: '/srv/opt', destination: '/opt' }],
|
|
}));
|
|
const c = byId(checks, 'path_mapping');
|
|
expect(c.status).toBe('warn');
|
|
expect(c.detail).toContain('/srv/opt/compose');
|
|
});
|
|
|
|
it('selects the longest-prefix mount when both a parent and child bind exist', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
composeDir: '/opt/compose',
|
|
bindMounts: async () => [
|
|
{ source: '/wrong', destination: '/opt' },
|
|
{ source: '/opt/compose', destination: '/opt/compose' },
|
|
],
|
|
}));
|
|
expect(byId(checks, 'path_mapping').status).toBe('pass');
|
|
});
|
|
});
|
|
|
|
describe('tls', () => {
|
|
it('warns on plain HTTP to a non-loopback host', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: 'sencho.example.com' }));
|
|
expect(byId(checks, 'tls').status).toBe('warn');
|
|
});
|
|
|
|
it('passes on HTTP to localhost', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: 'localhost:1852' }));
|
|
expect(byId(checks, 'tls').status).toBe('pass');
|
|
});
|
|
|
|
it('passes on HTTP to an IPv6 loopback literal', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'http', host: '[::1]:1852' }));
|
|
expect(byId(checks, 'tls').status).toBe('pass');
|
|
});
|
|
|
|
it('passes on HTTPS', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({ proto: 'https', host: 'sencho.example.com' }));
|
|
expect(byId(checks, 'tls').status).toBe('pass');
|
|
});
|
|
});
|
|
|
|
describe('disk_space', () => {
|
|
it('warns on high usage', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
diskUsage: async () => ({ usePercent: 95, freeBytes: 20 * 1024 ** 3 }),
|
|
}));
|
|
expect(byId(checks, 'disk_space').status).toBe('warn');
|
|
});
|
|
|
|
it('warns on low free space even when usage percent is moderate', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
diskUsage: async () => ({ usePercent: 70, freeBytes: 1 * 1024 ** 3 }),
|
|
}));
|
|
expect(byId(checks, 'disk_space').status).toBe('warn');
|
|
});
|
|
|
|
it('warns at exactly the usage threshold', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
diskUsage: async () => ({ usePercent: 90, freeBytes: 50 * 1024 ** 3 }),
|
|
}));
|
|
expect(byId(checks, 'disk_space').status).toBe('warn');
|
|
});
|
|
|
|
it('warns at exactly the free-space threshold', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
diskUsage: async () => ({ usePercent: 50, freeBytes: 2 * 1024 ** 3 - 1 }),
|
|
}));
|
|
expect(byId(checks, 'disk_space').status).toBe('warn');
|
|
});
|
|
|
|
it('warns (not a false pass) when usage cannot be determined', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({ diskUsage: async () => null }));
|
|
const c = byId(checks, 'disk_space');
|
|
expect(c.status).toBe('warn');
|
|
expect(remediationOf(c)).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe('pickBackingMount', () => {
|
|
it('returns null for an empty list', () => {
|
|
expect(pickBackingMount([], '/app/compose')).toBeNull();
|
|
});
|
|
|
|
it('prefers the longest matching prefix mount', () => {
|
|
const got = pickBackingMount([
|
|
{ mount: '/', use: 50, available: 100 },
|
|
{ mount: '/app', use: 60, available: 200 },
|
|
{ mount: '/app/compose', use: 70, available: 300 },
|
|
], '/app/compose');
|
|
expect(got).toEqual({ usePercent: 70, freeBytes: 300 });
|
|
});
|
|
|
|
it('does not treat /app as a prefix of /application', () => {
|
|
const got = pickBackingMount([
|
|
{ mount: '/', use: 10, available: 999 },
|
|
{ mount: '/app', use: 80, available: 5 },
|
|
], '/application/data');
|
|
// /app is not a path-segment prefix of /application, so root wins.
|
|
expect(got).toEqual({ usePercent: 10, freeBytes: 999 });
|
|
});
|
|
|
|
it('falls back to the C: mount on Windows when nothing matches', () => {
|
|
const got = pickBackingMount([
|
|
{ mount: 'D:', use: 20, available: 10 },
|
|
{ mount: 'C:', use: 30, available: 40 },
|
|
], 'E:\\compose');
|
|
expect(got).toEqual({ usePercent: 30, freeBytes: 40 });
|
|
});
|
|
});
|
|
|
|
it('degrades to a non-throwing report when probes reject', async () => {
|
|
const { checks } = await collectEnvironmentReport(baseProbes({
|
|
accessDir: async () => { throw new Error('stat blew up'); },
|
|
bindMounts: async () => { throw new Error('inspect blew up'); },
|
|
diskUsage: async () => { throw new Error('fsSize blew up'); },
|
|
}));
|
|
// accessDir rejection degrades to "missing" -> fail; a rejected bindMounts
|
|
// is unverifiable -> warn; a rejected diskUsage is unknown -> warn.
|
|
expect(byId(checks, 'compose_dir').status).toBe('fail');
|
|
expect(byId(checks, 'path_mapping').status).toBe('warn');
|
|
expect(byId(checks, 'disk_space').status).toBe('warn');
|
|
});
|
|
});
|