mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
575 lines
26 KiB
TypeScript
575 lines
26 KiB
TypeScript
/**
|
|
* Label inventory service, provenance, redaction, and GET routes.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import request from 'supertest';
|
|
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
|
import { ComposeService } from '../services/ComposeService';
|
|
import DockerController from '../services/DockerController';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import { NodeRegistry } from '../services/NodeRegistry';
|
|
import {
|
|
buildNodeLabelInventory,
|
|
buildStackLabelInventory,
|
|
} from '../services/LabelInventoryService';
|
|
import { REDACTED_SENTINEL } from '../helpers/labelValueRedaction';
|
|
|
|
let tmpDir: string;
|
|
let app: import('express').Express;
|
|
let authCookie: string;
|
|
let nodeId: number;
|
|
|
|
function composeDir(): string { return process.env.COMPOSE_DIR as string; }
|
|
|
|
function writeStack(stack: string, files: Record<string, string>): void {
|
|
const dir = path.join(composeDir(), stack);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
|
|
}
|
|
|
|
function stubRender(serviceLabels: Record<string, Record<string, string>> | null): void {
|
|
const rendered = serviceLabels === null
|
|
? null
|
|
: JSON.stringify({
|
|
name: 'proj',
|
|
services: Object.fromEntries(
|
|
Object.entries(serviceLabels).map(([s, labels]) => [s, { labels }]),
|
|
),
|
|
});
|
|
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
|
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr: '', code: rendered === null ? 1 : 0, timedOut: false }),
|
|
} as unknown as ComposeService);
|
|
}
|
|
|
|
interface StubRow {
|
|
id: string;
|
|
name: string;
|
|
state: string;
|
|
stack: string | null;
|
|
service: string | null;
|
|
labels: Record<string, string>;
|
|
inspectFailed?: boolean;
|
|
imageId?: string;
|
|
}
|
|
|
|
/**
|
|
* Stub DockerController for the label inventory. `images` maps an image id to its label
|
|
* map, or to `null` to simulate an image inspect failure. An image id absent from the map
|
|
* inspects successfully with no labels. Returns the `inspectImageLabels` spy so tests can
|
|
* assert deduplication.
|
|
*/
|
|
function stubDockerList(
|
|
rows: StubRow[],
|
|
opts: { images?: Record<string, Record<string, string> | null> } = {},
|
|
): { inspectImageLabels: ReturnType<typeof vi.fn> } {
|
|
const withDefaults = rows.map(r => ({ inspectFailed: false, imageId: 'img-default', ...r }));
|
|
const images = opts.images ?? {};
|
|
const inspectImageLabels = vi.fn(async (imageId: string) => {
|
|
if (imageId in images) {
|
|
const labels = images[imageId];
|
|
return labels === null ? null : { labels };
|
|
}
|
|
return { labels: {} };
|
|
});
|
|
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
|
listContainersForLabelInventory: vi.fn().mockResolvedValue(withDefaults),
|
|
getContainersByStack: vi.fn().mockImplementation(async (stack: string) =>
|
|
withDefaults.filter(r => r.stack === stack).map(r => ({
|
|
Id: r.id,
|
|
Names: [`/${r.name}`],
|
|
State: r.state,
|
|
Service: r.service,
|
|
})),
|
|
),
|
|
inspectContainerLabelsAndImage: vi.fn().mockImplementation(async (id: string) => {
|
|
const row = withDefaults.find(r => r.id === id);
|
|
if (!row || row.inspectFailed) return null;
|
|
return { labels: row.labels, imageId: row.imageId };
|
|
}),
|
|
inspectImageLabels,
|
|
} as unknown as DockerController);
|
|
return { inspectImageLabels };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
({ app } = await import('../index'));
|
|
authCookie = await loginAsTestAdmin(app);
|
|
nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
|
|
});
|
|
|
|
afterAll(() => cleanupTestDb(tmpDir));
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
describe('buildNodeLabelInventory', () => {
|
|
it('builds inverted index and compose-system provenance', async () => {
|
|
stubDockerList([
|
|
{
|
|
id: 'c1',
|
|
name: 'web-1',
|
|
state: 'running',
|
|
stack: 'mys',
|
|
service: 'web',
|
|
imageId: 'img1',
|
|
labels: {
|
|
'com.docker.compose.service': 'web',
|
|
'traefik.enable': 'true',
|
|
},
|
|
},
|
|
]);
|
|
const inv = await buildNodeLabelInventory(nodeId);
|
|
expect(inv.containers).toHaveLength(1);
|
|
expect(inv.byLabel).toHaveLength(2);
|
|
const svc = inv.containers[0].labels.find(l => l.key === 'com.docker.compose.service');
|
|
expect(svc?.source).toBe('compose-system');
|
|
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
|
expect(traefik?.source).toBe('runtime');
|
|
expect(inv.partial).toBe(false);
|
|
});
|
|
|
|
it('attributes image-inherited labels to the image, but runtime overrides stay runtime', async () => {
|
|
stubDockerList([
|
|
{
|
|
id: 'c1',
|
|
name: 'plex-1',
|
|
state: 'running',
|
|
stack: 'media',
|
|
service: 'plex',
|
|
imageId: 'plex-img',
|
|
labels: {
|
|
'org.opencontainers.image.title': 'Plex',
|
|
'traefik.enable': 'true',
|
|
},
|
|
},
|
|
], { images: { 'plex-img': { 'org.opencontainers.image.title': 'Plex', 'traefik.enable': 'false' } } });
|
|
const inv = await buildNodeLabelInventory(nodeId);
|
|
const oci = inv.containers[0].labels.find(l => l.key === 'org.opencontainers.image.title');
|
|
expect(oci?.source).toBe('image');
|
|
// Same key on the image but a different value: the container overrides it, so runtime.
|
|
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
|
expect(traefik?.source).toBe('runtime');
|
|
expect(inv.partial).toBe(false);
|
|
});
|
|
|
|
it('marks labels unknown and the inventory partial when the image inspect fails', async () => {
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'broken', labels: { 'custom.label': 'v' } },
|
|
], { images: { broken: null } });
|
|
const inv = await buildNodeLabelInventory(nodeId);
|
|
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
|
expect(inv.partial).toBe(true);
|
|
});
|
|
|
|
it('treats an empty image id as unknown and partial without inspecting an empty id', async () => {
|
|
const { inspectImageLabels } = stubDockerList([
|
|
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: '', labels: { 'custom.label': 'v' } },
|
|
]);
|
|
const inv = await buildNodeLabelInventory(nodeId);
|
|
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
|
expect(inv.partial).toBe(true);
|
|
expect(inspectImageLabels).not.toHaveBeenCalledWith('');
|
|
});
|
|
|
|
it('inspects each shared image only once (dedup)', async () => {
|
|
const { inspectImageLabels } = stubDockerList([
|
|
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
|
|
{ id: 'c2', name: 'a-2', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
|
|
], { images: { shared: {} } });
|
|
await buildNodeLabelInventory(nodeId);
|
|
const sharedCalls = inspectImageLabels.mock.calls.filter(c => c[0] === 'shared');
|
|
expect(sharedCalls).toHaveLength(1);
|
|
});
|
|
|
|
it('redacts secret-like label values by default', async () => {
|
|
stubDockerList([
|
|
{
|
|
id: 'c1',
|
|
name: 'app-1',
|
|
state: 'running',
|
|
stack: 'sec',
|
|
service: 'app',
|
|
imageId: 'img1',
|
|
labels: { 'my.api.token': 'super-secret', 'traefik.enable': 'true' },
|
|
},
|
|
]);
|
|
const inv = await buildNodeLabelInventory(nodeId);
|
|
const token = inv.containers[0].labels.find(l => l.key === 'my.api.token');
|
|
expect(token?.value).toBe(REDACTED_SENTINEL);
|
|
expect(token?.redacted).toBe(true);
|
|
const plain = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
|
expect(plain?.value).toBe('true');
|
|
expect(plain?.redacted).toBeUndefined();
|
|
});
|
|
|
|
it('redacts Traefik basicauth and digestauth label values', async () => {
|
|
stubDockerList([
|
|
{
|
|
id: 'c1', name: 'web-1', state: 'running', stack: 's', service: 'web', imageId: 'img1',
|
|
labels: {
|
|
'traefik.http.middlewares.foo.basicauth.users': 'admin:$apr1$abc123',
|
|
'traefik.http.middlewares.bar.digestauth.users': 'admin:realm:deadbeef',
|
|
'traefik.enable': 'true',
|
|
},
|
|
},
|
|
]);
|
|
const inv = await buildNodeLabelInventory(nodeId);
|
|
const basic = inv.containers[0].labels.find(l => l.key.endsWith('basicauth.users'));
|
|
const digest = inv.containers[0].labels.find(l => l.key.endsWith('digestauth.users'));
|
|
expect(basic?.value).toBe(REDACTED_SENTINEL);
|
|
expect(basic?.redacted).toBe(true);
|
|
expect(digest?.value).toBe(REDACTED_SENTINEL);
|
|
expect(digest?.redacted).toBe(true);
|
|
expect(inv.containers[0].labels.find(l => l.key === 'traefik.enable')?.redacted).toBeUndefined();
|
|
});
|
|
|
|
it('reveals secret-like values when revealSecrets is true', async () => {
|
|
stubDockerList([
|
|
{
|
|
id: 'c1',
|
|
name: 'app-1',
|
|
state: 'running',
|
|
stack: 'sec',
|
|
service: 'app',
|
|
imageId: 'img1',
|
|
labels: { 'api.token': 'visible-when-revealed' },
|
|
},
|
|
]);
|
|
const inv = await buildNodeLabelInventory(nodeId, { revealSecrets: true });
|
|
expect(inv.containers[0].labels[0].value).toBe('visible-when-revealed');
|
|
expect(inv.containers[0].labels[0].redacted).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('buildStackLabelInventory', () => {
|
|
it('reconciles declared and runtime labels', async () => {
|
|
writeStack('lbl1', {
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n compose.only: "1"\n',
|
|
});
|
|
stubRender({ web: { 'traefik.enable': 'true', 'compose.only': '1' } });
|
|
stubDockerList([
|
|
{
|
|
id: 'c1',
|
|
name: 'lbl1-web-1',
|
|
state: 'running',
|
|
stack: 'lbl1',
|
|
service: 'web',
|
|
imageId: 'img1',
|
|
labels: {
|
|
'traefik.enable': 'true',
|
|
'runtime.only': '1',
|
|
'com.docker.compose.service': 'web',
|
|
},
|
|
},
|
|
]);
|
|
const inv = await buildStackLabelInventory(nodeId, 'lbl1');
|
|
expect(inv.renderable).toBe(true);
|
|
expect(inv.partial).toBe(false);
|
|
const web = inv.services.find(s => s.service === 'web');
|
|
expect(web?.declaredLabels.map(l => l.key)).toEqual(['compose.only', 'traefik.enable']);
|
|
expect(web?.replicas[0].onlyInCompose).toEqual(['compose.only']);
|
|
expect(web?.replicas[0].onlyOnContainer).toContain('runtime.only');
|
|
expect(web?.replicas[0].inBoth).toContain('traefik.enable');
|
|
expect(web?.replicas[0].changed).toEqual([]);
|
|
});
|
|
|
|
it('flags a value that drifted between compose and runtime as changed, not inBoth', async () => {
|
|
writeStack('drift1', {
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n watchtower.enable: "true"\n',
|
|
});
|
|
stubRender({ web: { 'watchtower.enable': 'true' } });
|
|
stubDockerList([
|
|
{
|
|
id: 'c1',
|
|
name: 'drift1-web-1',
|
|
state: 'running',
|
|
stack: 'drift1',
|
|
service: 'web',
|
|
imageId: 'img1',
|
|
labels: { 'watchtower.enable': 'false' },
|
|
},
|
|
]);
|
|
const inv = await buildStackLabelInventory(nodeId, 'drift1');
|
|
const web = inv.services.find(s => s.service === 'web');
|
|
expect(web?.replicas[0].changed).toEqual(['watchtower.enable']);
|
|
expect(web?.replicas[0].inBoth).not.toContain('watchtower.enable');
|
|
});
|
|
|
|
it('detects drift on a secret-like key while its value stays redacted', async () => {
|
|
writeStack('drift2', {
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n auth.token: "declared"\n',
|
|
});
|
|
stubRender({ web: { 'auth.token': 'declared' } });
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'drift2-web-1', state: 'running', stack: 'drift2', service: 'web', imageId: 'img1', labels: { 'auth.token': 'runtime' } },
|
|
]);
|
|
const inv = await buildStackLabelInventory(nodeId, 'drift2');
|
|
const web = inv.services.find(s => s.service === 'web');
|
|
expect(web?.replicas[0].changed).toEqual(['auth.token']);
|
|
const rt = web?.replicas[0].runtimeLabels.find(l => l.key === 'auth.token');
|
|
expect(rt?.value).toBe(REDACTED_SENTINEL);
|
|
expect(rt?.redacted).toBe(true);
|
|
});
|
|
|
|
it('marks a replica inspectFailed and skips reconciliation instead of reporting false drift', async () => {
|
|
writeStack('fail1', {
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n',
|
|
});
|
|
stubRender({ web: { 'traefik.enable': 'true' } });
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'fail1-web-1', state: 'running', stack: 'fail1', service: 'web', imageId: 'img1', labels: {}, inspectFailed: true },
|
|
]);
|
|
const inv = await buildStackLabelInventory(nodeId, 'fail1');
|
|
const web = inv.services.find(s => s.service === 'web');
|
|
expect(web?.replicas[0].inspectFailed).toBe(true);
|
|
expect(web?.replicas[0].onlyInCompose).toEqual([]);
|
|
expect(web?.replicas[0].runtimeLabels).toEqual([]);
|
|
expect(inv.partial).toBe(true);
|
|
});
|
|
|
|
it('inspects each shared image only once across replicas (dedup)', async () => {
|
|
writeStack('ddup', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
|
stubRender({ web: {} });
|
|
const { inspectImageLabels } = stubDockerList([
|
|
{ id: 'c1', name: 'ddup-web-1', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
|
|
{ id: 'c2', name: 'ddup-web-2', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
|
|
], { images: { shared: {} } });
|
|
await buildStackLabelInventory(nodeId, 'ddup');
|
|
expect(inspectImageLabels.mock.calls.filter(c => c[0] === 'shared')).toHaveLength(1);
|
|
});
|
|
|
|
it('attributes provenance on the stack path: compose wins over image, image labels tagged image', async () => {
|
|
writeStack('prov1', {
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n foo: "bar"\n',
|
|
});
|
|
stubRender({ web: { foo: 'bar' } });
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'prov1-web-1', state: 'running', stack: 'prov1', service: 'web', imageId: 'img1', labels: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } },
|
|
], { images: { img1: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } } });
|
|
const inv = await buildStackLabelInventory(nodeId, 'prov1');
|
|
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
|
// foo is on both the image and the Compose file with the same value: Compose wins.
|
|
expect(rep?.runtimeLabels.find(l => l.key === 'foo')?.source).toBe('compose');
|
|
expect(rep?.runtimeLabels.find(l => l.key === 'org.opencontainers.image.title')?.source).toBe('image');
|
|
expect(inv.partial).toBe(false);
|
|
});
|
|
|
|
it('marks stack runtime labels unknown and the inventory partial when the image inspect fails', async () => {
|
|
writeStack('prov2', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
|
stubRender({ web: {} });
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'prov2-web-1', state: 'running', stack: 'prov2', service: 'web', imageId: 'broken', labels: { 'custom.label': 'v' } },
|
|
], { images: { broken: null } });
|
|
const inv = await buildStackLabelInventory(nodeId, 'prov2');
|
|
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
|
expect(rep?.runtimeLabels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
|
expect(inv.partial).toBe(true);
|
|
});
|
|
|
|
it('parses list-form compose labels', async () => {
|
|
writeStack('lbl2', {
|
|
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n - "watchtower.enable=true"\n',
|
|
});
|
|
stubRender({ web: { 'watchtower.enable': 'true' } });
|
|
stubDockerList([]);
|
|
const inv = await buildStackLabelInventory(nodeId, 'lbl2');
|
|
expect(inv.services[0].declaredLabels[0]).toMatchObject({ key: 'watchtower.enable', value: 'true', source: 'compose' });
|
|
});
|
|
|
|
it('sets renderable false when compose render fails', async () => {
|
|
writeStack('lbl3', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
|
stubRender(null);
|
|
stubDockerList([]);
|
|
const inv = await buildStackLabelInventory(nodeId, 'lbl3');
|
|
expect(inv.renderable).toBe(false);
|
|
});
|
|
|
|
it('resolves non-system labels to unknown and skips reconciliation when render fails', async () => {
|
|
writeStack('rf1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
|
stubRender(null);
|
|
stubDockerList([
|
|
{
|
|
id: 'c1', name: 'rf1-web-1', state: 'running', stack: 'rf1', service: 'web', imageId: 'img1',
|
|
labels: { 'traefik.enable': 'true', 'com.docker.compose.service': 'web' },
|
|
},
|
|
]);
|
|
const inv = await buildStackLabelInventory(nodeId, 'rf1');
|
|
expect(inv.renderable).toBe(false);
|
|
// Render failure is signalled by renderable, not partial (which is for inspect failures).
|
|
expect(inv.partial).toBe(false);
|
|
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
|
expect(rep?.runtimeLabels.find(l => l.key === 'traefik.enable')?.source).toBe('unknown');
|
|
expect(rep?.runtimeLabels.find(l => l.key === 'com.docker.compose.service')?.source).toBe('compose-system');
|
|
expect(rep?.onlyOnContainer).toEqual([]);
|
|
expect(rep?.changed).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('GET /api/system/container-labels', () => {
|
|
it('requires authentication', async () => {
|
|
const res = await request(app).get('/api/system/container-labels');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('returns node inventory', async () => {
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { foo: 'bar' } },
|
|
]);
|
|
const res = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodeId).toBe(nodeId);
|
|
expect(res.body.containers).toHaveLength(1);
|
|
});
|
|
|
|
it('redacts secrets by default and reveals them for an admin with reveal=1', async () => {
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
|
|
]);
|
|
const redacted = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
|
|
const rLabel = redacted.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
|
|
expect(rLabel.value).toBe(REDACTED_SENTINEL);
|
|
expect(rLabel.redacted).toBe(true);
|
|
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
|
|
]);
|
|
const revealed = await request(app).get('/api/system/container-labels?reveal=1').set('Cookie', authCookie);
|
|
const vLabel = revealed.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
|
|
expect(vLabel.value).toBe('s3cr3t');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/stacks/:stackName/label-inventory', () => {
|
|
it('returns 404 for unknown stack', async () => {
|
|
const res = await request(app).get('/api/stacks/missing-stack/label-inventory').set('Cookie', authCookie);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('returns stack inventory', async () => {
|
|
writeStack('route1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
|
stubRender({ web: {} });
|
|
stubDockerList([]);
|
|
const res = await request(app).get('/api/stacks/route1/label-inventory').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.stackName).toBe('route1');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/fleet/container-labels', () => {
|
|
it('requires authentication', async () => {
|
|
const res = await request(app).get('/api/fleet/container-labels');
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('aggregates the local node with no node errors', async () => {
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { 'shared.label': 'v' } },
|
|
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'img1', labels: { 'shared.label': 'v' } },
|
|
]);
|
|
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodeErrors).toEqual({});
|
|
const shared = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'shared.label');
|
|
expect(shared).toHaveLength(1);
|
|
expect(shared[0].containers).toHaveLength(2);
|
|
});
|
|
|
|
it('keeps the same key=value distinct when the source differs', async () => {
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'ia', labels: { 'dup.label': 'v' } },
|
|
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'ib', labels: { 'dup.label': 'v' } },
|
|
], { images: { ia: { 'dup.label': 'v' }, ib: {} } });
|
|
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
|
const dup = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'dup.label');
|
|
expect(dup).toHaveLength(2);
|
|
// The server sorts by key, value, then source; assert that order directly (no re-sort).
|
|
expect(dup.map((r: { source: string }) => r.source)).toEqual(['image', 'runtime']);
|
|
});
|
|
|
|
it('degrades an unreachable remote into nodeErrors without failing the whole request', async () => {
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
|
|
]);
|
|
const db = DatabaseService.getInstance();
|
|
const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 }));
|
|
try {
|
|
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
|
expect(res.body.aggregatedByLabel.some((r: { key: string }) => r.key === 'foo')).toBe(true);
|
|
} finally {
|
|
db.deleteNode(remoteId);
|
|
}
|
|
});
|
|
|
|
it('degrades a malformed remote payload into nodeErrors, not a 500', async () => {
|
|
stubDockerList([
|
|
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
|
|
]);
|
|
const db = DatabaseService.getInstance();
|
|
const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
|
// byLabel row missing valid key/value/source: must be rejected by the deep guard.
|
|
const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 });
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
try {
|
|
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
|
} finally {
|
|
db.deleteNode(remoteId);
|
|
}
|
|
});
|
|
|
|
it('rejects a remote row with an invalid source value via the source allowlist', async () => {
|
|
stubDockerList([]);
|
|
const db = DatabaseService.getInstance();
|
|
const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
|
const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] });
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
try {
|
|
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodeErrors[remoteId]).toBe('Remote returned an unexpected label-inventory payload');
|
|
} finally {
|
|
db.deleteNode(remoteId);
|
|
}
|
|
});
|
|
|
|
it('degrades a remote with a malformed inventory container into nodeErrors', async () => {
|
|
stubDockerList([]);
|
|
const db = DatabaseService.getInstance();
|
|
const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
|
const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] });
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
try {
|
|
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
|
} finally {
|
|
db.deleteNode(remoteId);
|
|
}
|
|
});
|
|
|
|
it('rejects a remote row with a malformed nested container ref', async () => {
|
|
stubDockerList([]);
|
|
const db = DatabaseService.getInstance();
|
|
const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
|
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
|
const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] });
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
try {
|
|
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
|
} finally {
|
|
db.deleteNode(remoteId);
|
|
}
|
|
});
|
|
});
|