feat(resources): bind prune to fingerprinted itemized plans (#1611)

* feat(resources): bind prune to fingerprinted itemized plans

* fix(resources): repair prune plan volume usage and preview list

Source volume RefCount from docker df, keep preview rows from flex-shrinking, tighten managed image attribution and becomesFree, and stop audit summaries from claiming success on rejected prunes.
This commit is contained in:
Anso
2026-07-11 00:11:37 -04:00
committed by GitHub
parent 4834e2e51d
commit 213d3d5d3a
13 changed files with 1713 additions and 155 deletions
+7
View File
@@ -138,6 +138,13 @@ describe('getAuditSummary()', () => {
expect(getAuditSummary('POST', '/users/42/mfa/reset')).toBe('Reset two-factor authentication: 42');
expect(getAuditSummary('POST', '/users/42/mfa/reset')).not.toBe('Created user: 42');
});
it('does not claim prune success on 409/400 responses', () => {
expect(getAuditSummary('POST', '/system/prune/system', 409)).toBe('Prune blocked: plan stale');
expect(getAuditSummary('POST', '/system/prune/system', 400)).toBe('Prune request rejected');
expect(getAuditSummary('POST', '/system/prune/system', 500)).toBe('Prune failed (500)');
expect(getAuditSummary('POST', '/system/prune/system', 200)).toBe('Pruned system resources');
});
});
// ---- DatabaseService audit methods ----
+15 -15
View File
@@ -511,8 +511,8 @@ describe('DockerController - managed image prune accounting', () => {
],
};
const unusedManagedListMock = [
{ Id: 'img-a', Containers: 0, Size: 1000 },
{ Id: 'img-b', Containers: 0, Size: 1000 },
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-c', Containers: 1, Size: 800 }, // in-use; filtered by Containers===0
];
@@ -543,8 +543,8 @@ describe('DockerController - managed image prune accounting', () => {
})
.mockResolvedValueOnce({ LayersSize: 3400, Images: [] });
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000 },
{ Id: 'img-b', Containers: 0, Size: 1000 },
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
const removeFn = vi.fn().mockResolvedValue(undefined);
@@ -571,8 +571,8 @@ describe('DockerController - managed image prune accounting', () => {
})
.mockRejectedValueOnce(new Error('df unavailable after prune'));
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000 },
{ Id: 'img-b', Containers: 0, Size: 1000 },
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) });
@@ -593,7 +593,7 @@ describe('DockerController - managed image prune accounting', () => {
.mockRejectedValueOnce(new Error('df unavailable before'))
.mockRejectedValueOnce(new Error('df unavailable after'));
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000 },
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
const removeFn = vi.fn().mockResolvedValue(undefined);
@@ -619,8 +619,8 @@ describe('DockerController - managed image prune accounting', () => {
],
});
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000 },
{ Id: 'img-b', Containers: 0, Size: 1000 },
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
@@ -644,8 +644,8 @@ describe('DockerController - managed image prune accounting', () => {
};
const dfAfter = { LayersSize: 3400, Images: [] };
const listImages = [
{ Id: 'img-a', Containers: 0, Size: 1000 },
{ Id: 'img-b', Containers: 0, Size: 1000 },
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
];
// Estimate path: df called once.
@@ -669,8 +669,8 @@ describe('DockerController - managed image prune accounting', () => {
// The accounting should still process img-b, defaulting its SharedSize to 0.
mockDocker.df.mockResolvedValue({ Images: [{ Id: 'img-a', SharedSize: 400 }] });
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000 },
{ Id: 'img-b', Containers: 0, Size: 1000 },
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
@@ -687,8 +687,8 @@ describe('DockerController - managed image prune accounting', () => {
// rather than failing the prune.
mockDocker.df.mockRejectedValue(new Error('daemon df failed'));
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', Containers: 0, Size: 1000 },
{ Id: 'img-b', Containers: 0, Size: 1000 },
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
]);
mockDocker.listContainers.mockResolvedValue([]);
+431
View File
@@ -0,0 +1,431 @@
/**
* Unit tests for fingerprinted prune plans: fingerprint stability, managed
* container enumeration, stale detection, per-item skip on race, and no
* unplanned deletes.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
fingerprintPrunePlan,
normalizePruneTargets,
PrunePlanStaleError,
} from '../services/prunePlan';
const { mockDocker } = vi.hoisted(() => {
const mockDocker = {
df: vi.fn(),
listImages: vi.fn().mockResolvedValue([]),
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
listNetworks: vi.fn().mockResolvedValue([]),
listContainers: vi.fn().mockResolvedValue([]),
getContainer: vi.fn(),
getImage: vi.fn(),
getVolume: vi.fn(),
getNetwork: vi.fn(),
pruneContainers: vi.fn(),
pruneImages: vi.fn(),
pruneNetworks: vi.fn(),
pruneVolumes: vi.fn(),
};
return { mockDocker };
});
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDocker: () => mockDocker,
getDefaultNodeId: () => 1,
getComposeDir: () => '/test/compose',
}),
},
}));
vi.mock('../services/SelfIdentityService', () => ({
default: {
getInstance: () => ({
isOwnContainer: () => false,
isOwnImage: () => false,
isOwnVolume: () => false,
isOwnNetwork: () => false,
}),
},
}));
vi.mock('child_process', () => ({
exec: vi.fn(),
execFile: vi.fn(),
}));
vi.mock('util', () => ({
promisify: () => vi.fn(),
}));
import DockerController from '../services/DockerController';
import { CacheService } from '../services/CacheService';
beforeEach(() => {
vi.clearAllMocks();
CacheService.getInstance().invalidate('project-name-map');
mockDocker.listImages.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listContainers.mockResolvedValue([]);
// Real Docker listVolumes returns UsageData: null; RefCount lives on df.
mockDocker.df.mockResolvedValue({ Volumes: [], Images: [], LayersSize: 0 });
});
describe('fingerprintPrunePlan', () => {
it('is stable for the same sorted target:id pairs regardless of input order', () => {
const a = fingerprintPrunePlan(1, 'managed', ['volumes', 'images'], [
{ target: 'images', id: 'img-b' },
{ target: 'volumes', id: 'vol-a' },
]);
const b = fingerprintPrunePlan(1, 'managed', ['volumes', 'images'], [
{ target: 'volumes', id: 'vol-a' },
{ target: 'images', id: 'img-b' },
]);
expect(a).toBe(b);
expect(a).toMatch(/^[a-f0-9]{64}$/);
});
it('changes when scope, targets, nodeId, or items change', () => {
const base = fingerprintPrunePlan(1, 'managed', ['volumes'], [{ target: 'volumes', id: 'v1' }]);
expect(fingerprintPrunePlan(2, 'managed', ['volumes'], [{ target: 'volumes', id: 'v1' }])).not.toBe(base);
expect(fingerprintPrunePlan(1, 'all', ['volumes'], [{ target: 'volumes', id: 'v1' }])).not.toBe(base);
expect(fingerprintPrunePlan(1, 'managed', ['images'], [{ target: 'volumes', id: 'v1' }])).not.toBe(base);
expect(fingerprintPrunePlan(1, 'managed', ['volumes'], [{ target: 'volumes', id: 'v2' }])).not.toBe(base);
});
});
describe('normalizePruneTargets', () => {
it('keeps single-target order and sorts multi-target into dependency order', () => {
expect(normalizePruneTargets(['images'])).toEqual(['images']);
expect(normalizePruneTargets(['images', 'volumes', 'containers'])).toEqual([
'volumes', 'containers', 'images',
]);
});
});
describe('DockerController.buildPrunePlan', () => {
it('enumerates managed stopped containers and never calls pruneSystem', async () => {
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c-managed-exited',
Names: ['/stack_web_1'],
State: 'exited',
ImageID: 'img-managed',
Labels: { 'com.docker.compose.project': 'my-stack' },
SizeRw: 100,
},
{
Id: 'c-unmanaged',
Names: ['/other'],
State: 'exited',
ImageID: 'img-other',
Labels: { 'com.docker.compose.project': 'foreign' },
},
{
Id: 'c-running',
Names: ['/stack_db_1'],
State: 'running',
ImageID: 'img-managed',
Labels: { 'com.docker.compose.project': 'my-stack' },
},
]);
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['containers'], 'managed', ['my-stack'], 1);
expect(plan.items).toHaveLength(1);
expect(plan.items[0]).toMatchObject({
target: 'containers',
id: 'c-managed-exited',
name: 'stack_web_1',
});
expect(mockDocker.pruneContainers).not.toHaveBeenCalled();
expect(mockDocker.getContainer).not.toHaveBeenCalled();
});
it('does not call any remove APIs while building a plan', async () => {
mockDocker.listVolumes.mockResolvedValue({
Volumes: [{
Name: 'my-stack_data',
Labels: { 'com.docker.compose.project': 'my-stack' },
UsageData: null,
}],
});
mockDocker.df.mockResolvedValue({
Volumes: [{ Name: 'my-stack_data', UsageData: { RefCount: 0, Size: 50 } }],
Images: [],
LayersSize: 0,
});
const remove = vi.fn();
mockDocker.getVolume.mockReturnValue({ remove });
const dc = DockerController.getInstance(1);
await dc.buildPrunePlan(['volumes'], 'managed', ['my-stack'], 1);
expect(remove).not.toHaveBeenCalled();
expect(mockDocker.getVolume).not.toHaveBeenCalled();
});
it('plans dangling volumes using df RefCount when listVolumes UsageData is null', async () => {
mockDocker.listVolumes.mockResolvedValue({
Volumes: [{
Name: 'my-stack_data',
Labels: { 'com.docker.compose.project': 'my-stack' },
UsageData: null,
}],
});
mockDocker.df.mockResolvedValue({
Volumes: [{ Name: 'my-stack_data', UsageData: { RefCount: 0, Size: 42 } }],
Images: [],
LayersSize: 0,
});
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['volumes'], 'managed', ['my-stack'], 1);
expect(plan.items).toEqual([
expect.objectContaining({
target: 'volumes',
id: 'my-stack_data',
sizeBytes: 42,
}),
]);
});
it('excludes unattributed unused images from managed scope', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-external', RepoTags: ['saelix/sencho:pr-1610'], Size: 100, Containers: 0 },
{
Id: 'img-labeled',
RepoTags: ['my-stack-web:latest'],
Size: 50,
Containers: 0,
Labels: { 'com.docker.compose.project': 'my-stack' },
},
]);
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['images'], 'managed', ['my-stack'], 1);
expect(plan.items.map((i) => i.id)).toEqual(['img-labeled']);
});
it('does not mark an image free when only some referencing containers are planned', async () => {
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c-exited',
Names: ['/exited'],
State: 'exited',
ImageID: 'img-shared',
Labels: { 'com.docker.compose.project': 'my-stack' },
},
{
Id: 'c-running',
Names: ['/running'],
State: 'running',
ImageID: 'img-shared',
Labels: { 'com.docker.compose.project': 'my-stack' },
},
]);
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-shared', RepoTags: ['alpine:3.19'], Size: 7_000_000, Containers: 2 },
]);
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['containers', 'images'], 'managed', ['my-stack'], 1);
expect(plan.items.some((i) => i.target === 'containers' && i.id === 'c-exited')).toBe(true);
expect(plan.items.some((i) => i.target === 'images' && i.id === 'img-shared')).toBe(false);
});
it('uses SharedSize when estimating reclaimable image bytes', async () => {
mockDocker.listImages.mockResolvedValue([
{ Id: 'img-a', RepoTags: ['a:1'], Size: 1_000_000_000, Containers: 0 },
{ Id: 'img-b', RepoTags: ['b:1'], Size: 1_000_000_000, Containers: 0 },
]);
mockDocker.df.mockResolvedValue({
Volumes: [],
Images: [
{ Id: 'img-a', SharedSize: 900_000_000, Size: 1_000_000_000 },
{ Id: 'img-b', SharedSize: 900_000_000, Size: 1_000_000_000 },
],
LayersSize: 1_100_000_000,
});
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['images'], 'all', [], 1);
// Unique bytes per image = Size - SharedSize = 100MB each, not full 1GB each.
expect(plan.reclaimableBytes).toBe(200_000_000);
});
});
describe('DockerController.executePrunePlan', () => {
it('throws PrunePlanStaleError when the fingerprint no longer matches', async () => {
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/c1'],
State: 'exited',
ImageID: 'img1',
Labels: { 'com.docker.compose.project': 'my-stack' },
},
]);
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['containers'], 'managed', ['my-stack'], 1);
const stale = { ...plan, fingerprint: 'deadbeef' };
await expect(dc.executePrunePlan(stale, ['my-stack'])).rejects.toBeInstanceOf(PrunePlanStaleError);
});
it('skips a planned container that becomes running before delete', async () => {
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/c1'],
State: 'exited',
ImageID: 'img1',
Labels: { 'com.docker.compose.project': 'my-stack' },
},
]);
const remove = vi.fn();
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({
State: { Status: 'running' },
Config: { Labels: { 'com.docker.compose.project': 'my-stack' } },
Image: 'img1',
}),
remove,
});
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['containers'], 'managed', ['my-stack'], 1);
const result = await dc.executePrunePlan(plan, ['my-stack']);
expect(remove).not.toHaveBeenCalled();
expect(result.outcomes).toEqual([
expect.objectContaining({
id: 'c1',
target: 'containers',
status: 'skipped',
reason: expect.stringMatching(/running/i),
}),
]);
});
it('skips a planned image when its container removal failed and the image is still referenced', async () => {
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/c1'],
State: 'exited',
ImageID: 'img1',
Labels: { 'com.docker.compose.project': 'my-stack' },
},
]);
mockDocker.listImages.mockResolvedValue([
{ Id: 'img1', RepoTags: ['app:latest'], Size: 1000, Containers: 1 },
]);
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
const containerRemove = vi.fn().mockRejectedValue(new Error('busy'));
mockDocker.getContainer.mockReturnValue({
inspect: vi.fn().mockResolvedValue({
State: { Status: 'exited' },
Config: { Labels: { 'com.docker.compose.project': 'my-stack' } },
Image: 'img1',
}),
remove: containerRemove,
});
const imageRemove = vi.fn();
mockDocker.getImage.mockReturnValue({ remove: imageRemove });
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['containers', 'images'], 'managed', ['my-stack'], 1);
expect(plan.items.some((i) => i.target === 'images' && i.id === 'img1')).toBe(true);
// After failed container remove, image list still reports Containers > 0.
mockDocker.listImages.mockResolvedValue([
{ Id: 'img1', RepoTags: ['app:latest'], Size: 1000, Containers: 1 },
]);
const result = await dc.executePrunePlan(plan, ['my-stack']);
const imageOutcome = result.outcomes.find((o) => o.target === 'images' && o.id === 'img1');
expect(imageOutcome).toMatchObject({ status: 'skipped' });
expect(imageRemove).not.toHaveBeenCalled();
});
it('never deletes items that are not in the plan', async () => {
mockDocker.listVolumes.mockResolvedValue({
Volumes: [
{
Name: 'planned-vol',
Labels: { 'com.docker.compose.project': 'my-stack' },
UsageData: null,
},
{
Name: 'other-vol',
Labels: { 'com.docker.compose.project': 'my-stack' },
UsageData: null,
},
],
});
mockDocker.df.mockResolvedValue({
Volumes: [
{ Name: 'planned-vol', UsageData: { RefCount: 0, Size: 10 } },
{ Name: 'other-vol', UsageData: { RefCount: 0, Size: 20 } },
],
Images: [],
LayersSize: 0,
});
const removed: string[] = [];
mockDocker.getVolume.mockImplementation((name: string) => ({
remove: vi.fn().mockImplementation(async () => { removed.push(name); }),
}));
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['volumes'], 'managed', ['my-stack'], 1);
// Execute a surgically narrowed plan that only includes planned-vol.
const narrow = {
...plan,
items: plan.items.filter((i) => i.id === 'planned-vol'),
fingerprint: fingerprintPrunePlan(1, 'managed', ['volumes'], [{ target: 'volumes', id: 'planned-vol' }]),
reclaimableBytes: 10,
};
// assertPlanFresh rebuilds from Docker and will see both volumes, so the
// fingerprint will mismatch. Instead spy assertPlanFresh to accept the
// narrowed plan as fresh so we can assert execute only touches plan items.
vi.spyOn(dc, 'assertPlanFresh').mockResolvedValue(narrow);
await dc.executePrunePlan(narrow, ['my-stack']);
expect(removed).toEqual(['planned-vol']);
expect(removed).not.toContain('other-vol');
});
it('removes with force:false', async () => {
mockDocker.listVolumes.mockResolvedValue({
Volumes: [{
Name: 'v1',
Labels: { 'com.docker.compose.project': 'my-stack' },
UsageData: null,
}],
});
mockDocker.df.mockResolvedValue({
Volumes: [{ Name: 'v1', UsageData: { RefCount: 0, Size: 5 } }],
Images: [],
LayersSize: 0,
});
const remove = vi.fn().mockResolvedValue(undefined);
mockDocker.getVolume.mockReturnValue({ remove });
const dc = DockerController.getInstance(1);
const plan = await dc.buildPrunePlan(['volumes'], 'managed', ['my-stack'], 1);
await dc.executePrunePlan(plan, ['my-stack']);
expect(remove).toHaveBeenCalledWith({ force: false });
});
});
@@ -1,12 +1,10 @@
/**
* Route-level test for F-6: when `docker system df` is slow, the prune
* estimate endpoints must return 503 with code `docker_df_slow` instead
* of hanging the admin's tab. Mirrors the pattern from
* system-maintenance-self-protect.test.ts.
* Route-level tests for prune estimate timeouts (F-6) and the fingerprinted
* prune plan / stale-409 path used by Resources.
*
* Uses real timers because supertest dispatches lazily and vi.useFakeTimers
* does not compose cleanly with that pattern. Each timeout test waits the
* full 8s `withTimeout` budget, so two such tests add ~17s to the file.
* full 8s withTimeout budget, so two such tests add ~17s to the file.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
@@ -48,6 +46,18 @@ function stubEstimate(impl: () => Promise<{ reclaimableBytes: number }>) {
} as unknown as ReturnType<typeof DockerController.getInstance>);
}
function samplePlan(fingerprint = 'abc123') {
return {
scope: 'managed' as const,
targets: ['volumes' as const],
items: [{ target: 'volumes' as const, id: 'v1', name: 'v1', sizeBytes: 42 }],
reclaimableBytes: 42,
fingerprint,
createdAt: Date.now(),
nodeId: 1,
};
}
describe('Prune estimate endpoints return 503 on slow docker df (F-6)', () => {
it('POST /api/system/prune/estimate returns 503 docker_df_slow when estimateSystemReclaim never settles', async () => {
stubFsStacks();
@@ -63,15 +73,15 @@ describe('Prune estimate endpoints return 503 on slow docker df (F-6)', () => {
expect(res.status).toBe(503);
expect(res.body.code).toBe('docker_df_slow');
expect(res.body.error).toMatch(/Docker daemon is busy/);
// Confirm the timeout actually fired (~8s), not an unrelated early
// 5xx that happened to look right.
expect(elapsed).toBeGreaterThanOrEqual(7_500);
expect(elapsed).toBeLessThan(15_000);
}, 20_000);
it('POST /api/system/prune/system dry-run returns 503 docker_df_slow when estimateSystemReclaim never settles', async () => {
it('POST /api/system/prune/system dry-run returns 503 docker_df_slow when buildPrunePlan never settles', async () => {
stubFsStacks();
stubEstimate(() => new Promise(() => { /* never resolves */ }));
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
buildPrunePlan: vi.fn().mockImplementation(() => new Promise(() => { /* never resolves */ })),
} as unknown as ReturnType<typeof DockerController.getInstance>);
const res = await request(app)
.post('/api/system/prune/system')
@@ -108,3 +118,85 @@ describe('Prune estimate endpoints return 503 on slow docker df (F-6)', () => {
expect(res.body.code).not.toBe('docker_df_slow');
});
});
describe('Prune plan routes', () => {
it('POST /api/system/prune/plan returns an itemized plan', async () => {
stubFsStacks();
const plan = samplePlan('fp-plan');
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
buildPrunePlan: vi.fn().mockResolvedValue(plan),
} as unknown as ReturnType<typeof DockerController.getInstance>);
const res = await request(app)
.post('/api/system/prune/plan')
.set('Authorization', authHeader)
.send({ target: 'volumes', scope: 'managed' });
expect(res.status).toBe(200);
expect(res.body.fingerprint).toBe('fp-plan');
expect(res.body.items).toHaveLength(1);
expect(res.body.reclaimableBytes).toBe(42);
});
it('POST /api/system/prune/system with a matching fingerprint executes the plan', async () => {
stubFsStacks();
const plan = samplePlan('fp-ok');
const executePrunePlan = vi.fn().mockResolvedValue({
outcomes: [{ id: 'v1', target: 'volumes', status: 'removed', sizeBytes: 42 }],
reclaimedBytes: 42,
success: true,
});
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
buildPrunePlan: vi.fn().mockResolvedValue(plan),
executePrunePlan,
} as unknown as ReturnType<typeof DockerController.getInstance>);
const res = await request(app)
.post('/api/system/prune/system')
.set('Authorization', authHeader)
.send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-ok' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.reclaimedBytes).toBe(42);
expect(res.body.outcomes).toHaveLength(1);
expect(executePrunePlan).toHaveBeenCalled();
});
it('POST /api/system/prune/system returns 409 PRUNE_PLAN_STALE on fingerprint mismatch', async () => {
stubFsStacks();
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
buildPrunePlan: vi.fn().mockResolvedValue(samplePlan('fp-current')),
executePrunePlan: vi.fn(),
} as unknown as ReturnType<typeof DockerController.getInstance>);
const res = await request(app)
.post('/api/system/prune/system')
.set('Authorization', authHeader)
.send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-stale' });
expect(res.status).toBe(409);
expect(res.body.code).toBe('PRUNE_PLAN_STALE');
});
it('dry-run returns plan fields without executing deletes', async () => {
stubFsStacks();
const plan = samplePlan('fp-dry');
const executePrunePlan = vi.fn();
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
buildPrunePlan: vi.fn().mockResolvedValue(plan),
executePrunePlan,
} as unknown as ReturnType<typeof DockerController.getInstance>);
const res = await request(app)
.post('/api/system/prune/system')
.set('Authorization', authHeader)
.send({ target: 'volumes', scope: 'managed', dryRun: true });
expect(res.status).toBe(200);
expect(res.body.dryRun).toBe(true);
expect(res.body.fingerprint).toBe('fp-dry');
expect(res.body.items).toHaveLength(1);
expect(executePrunePlan).not.toHaveBeenCalled();
});
});