Files
sencho/backend/src/__tests__/storage-probe-host-path.test.ts
T
Anso 9ea2864d60 feat: per-stack storage inventory and portability guardrails (#1399)
* feat: per-stack storage inventory and portability guardrails

Add a Storage tab to the stack Anatomy panel that derives a per-stack mount
inventory (bind mounts, named/anonymous volumes, tmpfs, docker socket;
read-only vs read-write; host-path existence, type, and owner) from the
effective Compose model, and classifies the stack as Portable, Partially
portable, Node-bound, or Unknown with the reasons behind it.

- New GET /api/stacks/:stackName/storage route (stack:read, Community), served
  by an on-demand, non-persisted service that renders the effective model,
  probes within-stack bind sources (symlink-escape aware), and runs the
  deterministic portability classifier.
- Extend the effective-model parser additively with a full per-mount inventory
  and service-level tmpfs, leaving the rule-facing binds/namedVolumes
  byte-identical for the existing preflight rules.
- New anonymous-volume preflight finding.
- Admin-visible "no recent snapshot" warning that reuses the existing hub-local
  snapshot-coverage endpoint, plus a static note distinguishing config
  snapshots from application-data backups.
- Surface storage assumptions in the Stack Dossier markdown export.
- Gate the tab behind a new compose-storage capability on both sides.

* docs: phrase the Storage tab availability as current behavior

Replace the "older Sencho version / until it is updated" wording in the
Storage feature page with present-tense, capability-based phrasing.
2026-06-20 15:06:26 -04:00

96 lines
4.1 KiB
TypeScript

/**
* probeHostPath: the lstat/readlink/realpath logic behind the storage
* inventory's bind-source classification. fs is mocked (real symlinks need
* privileges on Windows and are flaky), so these tests pin the kind taxonomy,
* the within-stack gate, and symlink-escape detection for both resolvable and
* broken links.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'path';
const m = vi.hoisted(() => ({ lstat: vi.fn(), realpath: vi.fn(), readlink: vi.fn() }));
vi.mock('fs', () => ({
default: { promises: { lstat: m.lstat, realpath: m.realpath, readlink: m.readlink } },
}));
import { probeHostPath } from '../services/storage/probeHostPath';
const STACK = path.resolve('/app/compose/mystack');
const enoent = () => Object.assign(new Error('not found'), { code: 'ENOENT' });
function stat(kind: 'dir' | 'file' | 'socket' | 'symlink', extra: { uid?: number; gid?: number; mode?: number } = {}) {
return {
isSymbolicLink: () => kind === 'symlink',
isDirectory: () => kind === 'dir',
isFile: () => kind === 'file',
isSocket: () => kind === 'socket',
uid: extra.uid,
gid: extra.gid,
mode: extra.mode,
};
}
beforeEach(() => {
m.lstat.mockReset();
m.realpath.mockReset();
m.readlink.mockReset();
});
describe('probeHostPath', () => {
it('classifies a within-stack directory, file, and socket', async () => {
m.lstat.mockResolvedValueOnce(stat('dir'));
expect(await probeHostPath(path.join(STACK, 'data'), STACK)).toMatchObject({ exists: true, kind: 'directory', withinStackDir: true });
m.lstat.mockResolvedValueOnce(stat('file'));
expect((await probeHostPath(path.join(STACK, 'cfg'), STACK)).kind).toBe('file');
m.lstat.mockResolvedValueOnce(stat('socket'));
expect((await probeHostPath(path.join(STACK, 'sock'), STACK)).kind).toBe('socket');
});
it('reports a within-stack path that does not exist as missing', async () => {
m.lstat.mockRejectedValue(enoent());
const p = await probeHostPath(path.join(STACK, 'gone'), STACK);
expect(p).toMatchObject({ exists: false, kind: 'missing', withinStackDir: true, lexicalWithinStackDir: true });
});
it('never probes an external absolute path', async () => {
const p = await probeHostPath(path.resolve('/mnt/media'), STACK);
expect(p).toMatchObject({ lexicalWithinStackDir: false, withinStackDir: false, exists: false, kind: 'unknown' });
expect(m.lstat).not.toHaveBeenCalled();
});
it('flags a resolvable symlink that escapes the stack dir', async () => {
m.lstat.mockResolvedValue(stat('symlink'));
m.realpath.mockResolvedValue(path.resolve('/mnt/data'));
const p = await probeHostPath(path.join(STACK, 'link'), STACK);
expect(p).toMatchObject({ kind: 'symlink', exists: true, escapes: true, withinStackDir: false });
});
it('flags a broken symlink whose readlink target escapes the stack dir', async () => {
m.lstat.mockResolvedValue(stat('symlink'));
m.realpath.mockRejectedValue(enoent());
m.readlink.mockResolvedValue('/mnt/data');
const p = await probeHostPath(path.join(STACK, 'broken'), STACK);
expect(p).toMatchObject({ kind: 'symlink', escapes: true, withinStackDir: false });
});
it('keeps a broken symlink whose target stays inside the stack dir within-stack', async () => {
m.lstat.mockResolvedValue(stat('symlink'));
m.realpath.mockRejectedValue(enoent());
m.readlink.mockResolvedValue('./sub');
const p = await probeHostPath(path.join(STACK, 'broken'), STACK);
expect(p).toMatchObject({ kind: 'symlink', escapes: false, withinStackDir: true });
});
it('populates uid/gid/mode from stat on POSIX', async () => {
const orig = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
try {
m.lstat.mockResolvedValue(stat('dir', { uid: 1000, gid: 1000, mode: 0o40755 }));
const p = await probeHostPath(path.join(STACK, 'data'), STACK);
expect(p).toMatchObject({ uid: 1000, gid: 1000, mode: '755' });
} finally {
Object.defineProperty(process, 'platform', { value: orig, configurable: true });
}
});
});