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.
This commit is contained in:
Anso
2026-06-20 15:06:26 -04:00
committed by GitHub
parent 57a0856ffc
commit 9ea2864d60
26 changed files with 1591 additions and 12 deletions
@@ -15,7 +15,7 @@ import { assembleStackNetworkFacts } from '../services/network/composeNetworkIns
function effSvc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
networks: [], extraHosts: [], labelKeys: [], ...over,
};
@@ -159,3 +159,77 @@ describe('parseEffectiveModel', () => {
expect(empty.networks).toEqual({});
});
});
describe('parseStorageMounts (storage inventory field)', () => {
it('captures every long-form mount type with the read-only flag', () => {
const m = parseEffectiveModel({
services: {
app: {
volumes: [
{ type: 'bind', source: '/srv/data', target: '/data', read_only: true },
{ type: 'volume', source: 'cache', target: '/var/cache' },
{ type: 'volume', target: '/anon' }, // anonymous: a volume with no source
{ type: 'tmpfs', target: '/run' },
],
},
},
}, 'p');
expect(m.services[0].storageMounts).toEqual([
{ type: 'bind', source: '/srv/data', target: '/data', readOnly: true },
{ type: 'named', source: 'cache', target: '/var/cache', readOnly: false },
{ type: 'anonymous', target: '/anon', readOnly: false },
{ type: 'tmpfs', target: '/run', readOnly: false },
]);
});
it('captures the service-level tmpfs field in string and array form', () => {
const single = parseEffectiveModel({ services: { s: { tmpfs: '/tmp' } } }, 'p');
expect(single.services[0].storageMounts).toEqual([{ type: 'tmpfs', target: '/tmp', readOnly: false }]);
const many = parseEffectiveModel({ services: { s: { tmpfs: ['/tmp', '/run'] } } }, 'p');
expect(many.services[0].storageMounts).toEqual([
{ type: 'tmpfs', target: '/tmp', readOnly: false },
{ type: 'tmpfs', target: '/run', readOnly: false },
]);
});
it('parses short-form binds, named volumes, and read-only / comma options', () => {
const m = parseEffectiveModel({
services: { s: { volumes: ['/host:/data:ro', './rel:/rel', 'vol:/v:rw,Z'] } },
}, 'p');
expect(m.services[0].storageMounts).toEqual([
{ type: 'bind', source: '/host', target: '/data', readOnly: true },
{ type: 'bind', source: './rel', target: '/rel', readOnly: false },
{ type: 'named', source: 'vol', target: '/v', readOnly: false },
]);
});
it('treats a single-token short volume as an anonymous mount at that container path', () => {
const m = parseEffectiveModel({ services: { s: { volumes: ['/data'] } } }, 'p');
expect(m.services[0].storageMounts).toEqual([{ type: 'anonymous', target: '/data', readOnly: false }]);
});
it('drops an unparseable single-token short volume rather than inventing a mount', () => {
const m = parseEffectiveModel({ services: { s: { volumes: ['notapath'] } } }, 'p');
expect(m.services[0].storageMounts).toEqual([]);
});
it('splits a Windows-drive short-form source without the drive colon breaking it', () => {
const m = parseEffectiveModel({ services: { s: { volumes: ['C:\\data:/data:ro'] } } }, 'p');
expect(m.services[0].storageMounts).toEqual([{ type: 'bind', source: 'C:\\data', target: '/data', readOnly: true }]);
});
it('records a named volume by its compose key even when the top-level name differs', () => {
const m = parseEffectiveModel({
services: { s: { volumes: [{ type: 'volume', source: 'cache', target: '/c' }] } },
volumes: { cache: { name: 'myapp_cache' } },
}, 'p');
expect(m.services[0].storageMounts).toEqual([{ type: 'named', source: 'cache', target: '/c', readOnly: false }]);
expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false, internal: false });
});
it('leaves the rule-facing binds and namedVolumes byte-identical', () => {
const m = parseEffectiveModel(render(), 'fallback');
expect(m.services[0].binds).toEqual([{ source: '/srv/data', target: '/data' }]);
expect(m.services[0].namedVolumes).toEqual(['cache']);
});
});
+10 -2
View File
@@ -12,7 +12,7 @@ import type { PreflightContext, PreflightFinding } from '../services/preflight/t
function svc(over: Partial<EffService> = {}): EffService {
return {
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [],
name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [],
privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [],
networks: [], extraHosts: [], labelKeys: [], ...over,
};
@@ -210,6 +210,14 @@ describe('network / volume rules', () => {
expect(ids(f, 'new-network')[0].severity).toBe('info');
expect(ids(f, 'new-volume')[0].message).toContain('proj_data');
});
it('flags an anonymous volume as info and stays silent without one', () => {
const anon = model([svc({ storageMounts: [{ type: 'anonymous', target: '/data', readOnly: false }] })]);
const f = runRules(ctx({ model: anon }));
expect(ids(f, 'anonymous-volume')[0].severity).toBe('info');
expect(ids(f, 'anonymous-volume')[0].message).toContain('/data');
const named = model([svc({ storageMounts: [{ type: 'named', source: 'db', target: '/db', readOnly: false }] })]);
expect(ids(runRules(ctx({ model: named })), 'anonymous-volume')).toHaveLength(0);
});
});
describe('container_name rules', () => {
@@ -341,7 +349,7 @@ describe('rule registry completeness', () => {
'render-failed', 'env-unset', 'env-file-missing', 'port-conflict-node', 'port-conflict-internal', 'port-exposed-all-interfaces',
'bind-path-missing', 'bind-path-permission', 'docker-socket-mount', 'privileged', 'network-mode-host',
'uid-gid-risk', 'image-latest', 'no-restart-policy', 'no-healthcheck', 'deploy-swarm-only',
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume',
'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume', 'anonymous-volume',
'container-name-internal-dup', 'container-name-collision',
'exposure-internal-published', 'sensitive-service-broad-exposure', 'exposure-unclassified',
'exposure-port-vs-dossier', 'reverse-proxy-undocumented', 'effective-model-expanded',
@@ -0,0 +1,186 @@
/**
* Storage inventory core: the pure mount builder, the portability classifier
* (every verdict and its edge cases), and the assembler's renderable/stateful
* handling. These are pure functions over fixtures, so no filesystem or docker
* is touched here (the live probe is covered by storage-probe-host-path.test.ts).
*/
import { describe, it, expect } from 'vitest';
import { buildMounts, classifyPortability, assembleStorageInventory } from '../services/storage/inventory';
import type { HostPathProbe, StorageMount } from '../services/storage/types';
import type { EffectiveModel } from '../services/preflight/effectiveModel';
function probe(over: Partial<HostPathProbe> = {}): HostPathProbe {
return {
lexicalWithinStackDir: true, withinStackDir: true, exists: true, kind: 'directory',
escapes: false, uid: null, gid: null, mode: null, ...over,
};
}
/** A bind whose source resolves inside the stack directory. */
function withinBind(over: Partial<StorageMount> = {}): StorageMount {
return { type: 'bind', source: '/app/stack/data', target: '/data', readOnly: false, service: 'app', probe: probe(), externalNamed: false, ...over };
}
/** A bind whose source is an external absolute host path (unprobed view). */
function externalBind(over: Partial<StorageMount> = {}): StorageMount {
return {
type: 'bind', source: '/mnt/media', target: '/media', readOnly: false, service: 'app',
probe: probe({ lexicalWithinStackDir: false, withinStackDir: false, exists: false, kind: 'unknown' }),
externalNamed: false, ...over,
};
}
function socketBind(over: Partial<StorageMount> = {}): StorageMount {
return {
type: 'bind', source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: false, service: 'app',
probe: probe({ lexicalWithinStackDir: false, withinStackDir: false, exists: false, kind: 'unknown' }),
externalNamed: false, ...over,
};
}
function namedVol(over: Partial<StorageMount> = {}): StorageMount {
return { type: 'named', source: 'db', target: '/db', readOnly: false, service: 'app', probe: null, externalNamed: false, ...over };
}
function anonVol(over: Partial<StorageMount> = {}): StorageMount {
return { type: 'anonymous', target: '/anon', readOnly: false, service: 'app', probe: null, externalNamed: false, ...over };
}
function tmpfsMount(over: Partial<StorageMount> = {}): StorageMount {
return { type: 'tmpfs', target: '/run', readOnly: false, service: 'app', probe: null, externalNamed: false, ...over };
}
const verdict = (mounts: StorageMount[]) => classifyPortability(mounts, true);
const status = (mounts: StorageMount[]) => verdict(mounts).status;
describe('classifyPortability', () => {
it('is node-bound when the Docker socket is mounted', () => {
const v = verdict([socketBind()]);
expect(v.status).toBe('node-bound');
expect(v.reasons.some(r => r.includes('Docker socket'))).toBe(true);
});
it('is node-bound for an external bind, read-only or not', () => {
expect(status([externalBind()])).toBe('node-bound');
expect(status([externalBind({ readOnly: true })])).toBe('node-bound');
expect(verdict([externalBind()]).reasons.some(r => r.includes('outside the stack directory'))).toBe(true);
});
it('detects a Docker socket mounted by target alone and reports it once', () => {
const v = verdict([socketBind({ source: '/host/custom', target: '/var/run/docker.sock' })]);
expect(v.status).toBe('node-bound');
expect(v.reasons.filter(r => r.includes('Docker socket'))).toHaveLength(1);
expect(v.reasons.some(r => r.includes('outside the stack directory'))).toBe(false);
});
it('accumulates every node-bound reason (socket and external bind together)', () => {
const v = verdict([socketBind(), externalBind()]);
expect(v.status).toBe('node-bound');
expect(v.reasons.some(r => r.includes('Docker socket'))).toBe(true);
expect(v.reasons.some(r => r.includes('outside the stack directory'))).toBe(true);
});
it('is node-bound for a within-stack symlink that resolves outside the stack dir', () => {
const escaping = withinBind({ probe: probe({ kind: 'symlink', escapes: true, withinStackDir: false }) });
const v = verdict([escaping]);
expect(v.status).toBe('node-bound');
expect(v.reasons.some(r => r.includes('symlink'))).toBe(true);
});
it('treats a broken symlink that escapes as node-bound, but one that stays inside as portable', () => {
const brokenEscape = withinBind({ probe: probe({ kind: 'symlink', exists: true, escapes: true, withinStackDir: false }) });
expect(status([brokenEscape])).toBe('node-bound');
const brokenInside = withinBind({ probe: probe({ kind: 'symlink', exists: true, escapes: false, withinStackDir: true }) });
expect(status([brokenInside])).toBe('portable');
});
it('is portable for within-stack binds only, including a bind that is the stack dir itself', () => {
expect(status([withinBind()])).toBe('portable');
expect(status([withinBind({ source: '/app/stack', target: '/app' })])).toBe('portable');
});
it('is partially portable for named or anonymous volumes', () => {
expect(status([namedVol()])).toBe('partially-portable');
expect(status([anonVol()])).toBe('partially-portable');
});
it('adds a distinct reason for an external named volume but stays partially portable', () => {
const v = verdict([namedVol({ externalNamed: true })]);
expect(v.status).toBe('partially-portable');
expect(v.reasons.some(r => r.includes('pre-existing'))).toBe(true);
});
it('is portable for tmpfs-only and for no mounts at all', () => {
expect(status([tmpfsMount()])).toBe('portable');
expect(status([])).toBe('portable');
});
it('is partially portable for a mix of within-stack bind and named volume', () => {
expect(status([withinBind(), namedVol()])).toBe('partially-portable');
});
it('is unknown when the model is unrenderable', () => {
const v = classifyPortability([], false);
expect(v.status).toBe('unknown');
expect(v.reasons[0]).toContain('could not render');
});
});
describe('buildMounts', () => {
function model(): EffectiveModel {
return {
projectName: 'app',
services: [
{
name: 'web', image: 'nginx', ports: [], binds: [], namedVolumes: [],
storageMounts: [
{ type: 'bind', source: '/app/stack/conf', target: '/conf', readOnly: true },
{ type: 'named', source: 'shared', target: '/s', readOnly: false },
],
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
},
],
networks: {},
volumes: { shared: { name: 'shared_vol', external: true, internal: false } },
};
}
it('flattens mounts with their service, attaches bind probes, and marks external named volumes', () => {
const probes = new Map<string, HostPathProbe>([['/app/stack/conf', probe({ kind: 'directory' })]]);
const mounts = buildMounts(model(), probes);
expect(mounts).toHaveLength(2);
expect(mounts[0]).toMatchObject({ service: 'web', type: 'bind', probe: { kind: 'directory' } });
expect(mounts[1]).toMatchObject({ service: 'web', type: 'named', probe: null, externalNamed: true });
});
});
describe('assembleStorageInventory', () => {
it('returns an unrenderable, stateless, unknown inventory when the model is null', () => {
const inv = assembleStorageInventory('web', null, 'boom', new Map());
expect(inv).toMatchObject({ renderable: false, renderError: 'boom', stateful: false, mounts: [] });
expect(inv.portability.status).toBe('unknown');
});
it('marks a stack with persistent storage stateful and a tmpfs-only stack stateless', () => {
const stateful: EffectiveModel = {
projectName: 'a', services: [{
name: 'app', ports: [], binds: [], namedVolumes: [],
storageMounts: [{ type: 'named', source: 'db', target: '/db', readOnly: false }],
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
}], networks: {}, volumes: {},
};
expect(assembleStorageInventory('a', stateful, null, new Map()).stateful).toBe(true);
const ephemeral: EffectiveModel = {
...stateful,
services: [{ ...stateful.services[0], storageMounts: [{ type: 'tmpfs', target: '/run', readOnly: false }] }],
};
expect(assembleStorageInventory('a', ephemeral, null, new Map()).stateful).toBe(false);
});
it('does not mark a docker-socket-only stack as stateful (the socket holds no data)', () => {
const socketOnly: EffectiveModel = {
projectName: 'a', services: [{
name: 'app', ports: [], binds: [], namedVolumes: [],
storageMounts: [{ type: 'bind', source: '/var/run/docker.sock', target: '/var/run/docker.sock', readOnly: false }],
privileged: false, hasHealthcheck: true, envKeys: [], networks: [], extraHosts: [], labelKeys: [],
}], networks: {}, volumes: {},
};
expect(assembleStorageInventory('a', socketOnly, null, new Map()).stateful).toBe(false);
});
});
@@ -0,0 +1,95 @@
/**
* 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 });
}
});
});
+113
View File
@@ -0,0 +1,113 @@
/**
* GET /api/stacks/:stackName/storage: returns the per-stack storage inventory +
* portability verdict. Requires stack:read, rejects unauthenticated and
* missing-stack requests, degrades to an unknown verdict when the model is
* unrenderable, and never leaks raw docker stderr. Docker render is mocked.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs';
import path from 'path';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { ComposeService } from '../services/ComposeService';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let stackDir: string;
const STACK = 'storageroute';
function stubRender(result: { rendered: string | null; stderr?: string }) {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockResolvedValue({ rendered: result.rendered, stderr: result.stderr ?? '', code: 0, timedOut: false }),
} as unknown as ComposeService);
}
/** A rendered model with a named volume and an external bind (so the verdict is node-bound). */
function renderedModel(): string {
return JSON.stringify({
name: STACK,
services: {
app: {
image: 'nginx:1.27',
volumes: [
{ type: 'volume', source: 'data', target: '/data' },
{ type: 'bind', source: '/mnt/media', target: '/media' },
],
},
},
networks: {},
volumes: { data: { name: `${STACK}_data` } },
});
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '5m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
describe('storage route', () => {
beforeEach(() => {
stackDir = path.join(process.env.COMPOSE_DIR as string, STACK);
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx:1.27\n');
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(stackDir, { recursive: true, force: true });
});
it('returns the inventory, mounts, and a portability verdict', async () => {
stubRender({ rendered: renderedModel() });
const res = await request(app).get(`/api/stacks/${STACK}/storage`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.renderable).toBe(true);
expect(res.body.stateful).toBe(true);
expect(res.body.mounts.map((mnt: { type: string }) => mnt.type).sort()).toEqual(['bind', 'named']);
expect(res.body.mounts.every((mnt: { service: string }) => mnt.service === 'app')).toBe(true);
expect(res.body.portability.status).toBe('node-bound');
});
it('degrades to an unknown verdict when the model cannot be rendered', async () => {
stubRender({ rendered: null, stderr: 'required variable "FOO" is missing' });
const res = await request(app).get(`/api/stacks/${STACK}/storage`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.renderable).toBe(false);
expect(res.body.portability.status).toBe('unknown');
});
it('never leaks raw docker stderr into the response', async () => {
const secret = 'super-secret-env-value-9f3a';
stubRender({ rendered: null, stderr: `boom DB_PASSWORD=${secret}` });
const res = await request(app).get(`/api/stacks/${STACK}/storage`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(JSON.stringify(res.body)).not.toContain(secret);
});
it('degrades to unknown when docker compose cannot be started (spawn failure)', async () => {
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
renderConfig: vi.fn().mockRejectedValue(new Error('spawn docker ENOENT')),
} as unknown as ComposeService);
const res = await request(app).get(`/api/stacks/${STACK}/storage`).set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.renderable).toBe(false);
expect(res.body.portability.status).toBe('unknown');
expect(res.body.mounts).toEqual([]);
});
it('rejects an unauthenticated request', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/storage`);
expect(res.status).toBe(401);
});
it('returns 404 for a stack that does not exist', async () => {
stubRender({ rendered: renderedModel() });
const res = await request(app).get('/api/stacks/nope-not-here/storage').set('Authorization', authHeader);
expect(res.status).toBe(404);
});
});