add missing unit test suite
This commit is contained in:
160 files changed
+19260
-1164
No files matched your search
@@ -0,0 +1,69 @@
|
||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { applyQuickFix } from '../../src/lib/utils/compose-quick-fix';
|
||||
|
||||
describe('applyQuickFix', () => {
|
||||
test('delete-line removes exactly that 1-based line', () => {
|
||||
const src = `version: "3.8"\nservices:\n web:\n image: nginx:1.27\n`;
|
||||
expect(applyQuickFix(src, { kind: 'delete-line', line: 1 })).toBe(
|
||||
`services:\n web:\n image: nginx:1.27\n`
|
||||
);
|
||||
});
|
||||
|
||||
test('replace-in-line swaps the first occurrence, keeping indentation', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n restar: always\n`;
|
||||
expect(applyQuickFix(src, { kind: 'replace-in-line', line: 4, find: 'restar', replace: 'restart' })).toBe(
|
||||
`services:\n web:\n image: nginx:1.27\n restart: always\n`
|
||||
);
|
||||
});
|
||||
|
||||
test('replace-in-line prefixes a port binding without touching quotes', () => {
|
||||
const src = `services:\n db:\n ports:\n - "5432:5432"\n`;
|
||||
expect(
|
||||
applyQuickFix(src, { kind: 'replace-in-line', line: 4, find: '5432:5432', replace: '127.0.0.1:5432:5432' })
|
||||
).toBe(`services:\n db:\n ports:\n - "127.0.0.1:5432:5432"\n`);
|
||||
});
|
||||
|
||||
test('a fix whose text no longer matches leaves the source unchanged (stale)', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n`;
|
||||
expect(applyQuickFix(src, { kind: 'replace-in-line', line: 3, find: 'gone', replace: 'x' })).toBe(src);
|
||||
});
|
||||
|
||||
test('an out-of-range line leaves the source unchanged', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n`;
|
||||
expect(applyQuickFix(src, { kind: 'delete-line', line: 99 })).toBe(src);
|
||||
});
|
||||
|
||||
test('insert-after adds the text as the next line, verbatim (indent in text)', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n`;
|
||||
expect(applyQuickFix(src, { kind: 'insert-after', line: 2, text: ' restart: unless-stopped' })).toBe(
|
||||
`services:\n web:\n restart: unless-stopped\n image: nginx:1.27\n`
|
||||
);
|
||||
});
|
||||
|
||||
test('CRLF line endings on other lines are preserved', () => {
|
||||
const src = `version: "3.8"\r\nservices:\r\n web:\r\n image: nginx:1.27\r\n`;
|
||||
// delete the version line; the remaining \r stay intact.
|
||||
expect(applyQuickFix(src, { kind: 'delete-line', line: 1 })).toBe(
|
||||
`services:\r\n web:\r\n image: nginx:1.27\r\n`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace-in-line anchoring (review regression)', () => {
|
||||
test("`at` anchors to the intended occurrence, not the first substring", () => {
|
||||
// The short "80:80" token is a substring of the earlier "8080:80" token.
|
||||
const line = ' ports: ["8080:80", "80:80"]';
|
||||
const src = `services:\n db:\n${line}\n`;
|
||||
const at = line.indexOf('"80:80"') + 1; // column of the 2nd token
|
||||
const out = applyQuickFix(src, { kind: 'replace-in-line', line: 3, find: '80:80', replace: '127.0.0.1:80:80', at });
|
||||
expect(out).toContain('"8080:80"'); // earlier token intact
|
||||
expect(out).toContain('"127.0.0.1:80:80"'); // correct token rewritten
|
||||
expect(out).not.toContain('80127.0.0.1'); // no corruption
|
||||
});
|
||||
test('without `at`, falls back to first occurrence (unchanged behavior)', () => {
|
||||
const src = `services:\n a:\n restar: always\n`;
|
||||
const out = applyQuickFix(src, { kind: 'replace-in-line', line: 3, find: 'restar', replace: 'restart' });
|
||||
expect(out).toContain('restart: always');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { parseConfigErrors } from '../../src/lib/server/compose-validate/effective-compose';
|
||||
|
||||
describe('parseConfigErrors (docker compose config stderr)', () => {
|
||||
test('a schema error becomes one COMPOSE_SCHEMA_ERROR finding', () => {
|
||||
const stderr = `validating /tmp/x/docker-compose.yml: services.web.ports.0 must be a string or number`;
|
||||
const out = parseConfigErrors(stderr);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].ruleId).toBe('COMPOSE_SCHEMA_ERROR');
|
||||
expect(out[0].severity).toBe('error');
|
||||
expect(out[0].message).toContain('services.web.ports.0');
|
||||
expect(out[0].message).not.toContain('validating');
|
||||
});
|
||||
|
||||
test('a line number in the message is lifted to .line', () => {
|
||||
const out = parseConfigErrors('yaml: line 7: mapping values are not allowed here');
|
||||
expect(out[0].line).toBe(7);
|
||||
});
|
||||
|
||||
test('duplicate error lines are collapsed', () => {
|
||||
const out = parseConfigErrors('error: boom\nerror: boom\n');
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].message).toBe('boom');
|
||||
});
|
||||
|
||||
test('empty stderr still yields a single generic error', () => {
|
||||
const out = parseConfigErrors(' \n \n');
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].ruleId).toBe('COMPOSE_SCHEMA_ERROR');
|
||||
});
|
||||
|
||||
test('blank lines are ignored', () => {
|
||||
const out = parseConfigErrors('\n\nservices.db.image is required\n\n');
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].message).toBe('services.db.image is required');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { deriveContextFromLists } from '../../src/lib/server/compose-validate/context-core';
|
||||
|
||||
describe('deriveContextFromLists', () => {
|
||||
const containers = [
|
||||
{ name: 'other-web-1', ports: [{ PublicPort: 8080 }, { PublicPort: 443 }] },
|
||||
{ name: 'mystack-db-1', ports: [{ PublicPort: 5432 }] } // self
|
||||
];
|
||||
|
||||
test('collects used host ports and container names', () => {
|
||||
const ctx = deriveContextFromLists(containers, ['proxy'], ['data']);
|
||||
expect([...ctx.usedHostPorts!].sort()).toEqual([443, 5432, 8080]);
|
||||
expect(ctx.existingContainerNames!.has('other-web-1')).toBe(true);
|
||||
expect(ctx.existingNetworks!.has('proxy')).toBe(true);
|
||||
expect(ctx.existingVolumes!.has('data')).toBe(true);
|
||||
});
|
||||
|
||||
test('excludes THIS stack own containers (self)', () => {
|
||||
const ctx = deriveContextFromLists(containers, [], [], 'mystack');
|
||||
expect(ctx.usedHostPorts!.has(5432)).toBe(false); // self db port not counted
|
||||
expect(ctx.usedHostPorts!.has(8080)).toBe(true); // other stack still counted
|
||||
expect(ctx.existingContainerNames!.has('mystack-db-1')).toBe(false);
|
||||
});
|
||||
|
||||
test('excludes self by compose project LABEL even with a custom container_name', () => {
|
||||
// A service with `container_name: wzml` runs as a container literally named "wzml"
|
||||
// (no `<stack>-` prefix), so name-prefix matching misses it. The compose project
|
||||
// label is authoritative and must still exclude it - otherwise editing the running
|
||||
// stack falsely reports a container_name collision against itself.
|
||||
const withCustomNames = [
|
||||
{ name: 'wzml', ports: [{ PublicPort: 8080 }], labels: { 'com.docker.compose.project': 'wzml' } },
|
||||
{ name: 'wzml_tunnel', labels: { 'com.docker.compose.project': 'wzml' } },
|
||||
{ name: 'unrelated', ports: [{ PublicPort: 9090 }], labels: { 'com.docker.compose.project': 'other' } }
|
||||
];
|
||||
const ctx = deriveContextFromLists(withCustomNames, [], [], 'wzml');
|
||||
expect(ctx.existingContainerNames!.has('wzml')).toBe(false); // self, excluded
|
||||
expect(ctx.existingContainerNames!.has('wzml_tunnel')).toBe(false); // self, excluded
|
||||
expect(ctx.existingContainerNames!.has('unrelated')).toBe(true); // other stack kept
|
||||
// Ports follow the same exclusion: the self stack's published port is NOT counted
|
||||
// (so re-editing it isn't flagged as a cross-stack port collision), but another
|
||||
// stack's port still is.
|
||||
expect(ctx.usedHostPorts!.has(8080)).toBe(false); // self port, excluded
|
||||
expect(ctx.usedHostPorts!.has(9090)).toBe(true); // other stack port kept
|
||||
});
|
||||
|
||||
test('create mode (no selfStackName) excludes nothing - a name clash must be flagged', () => {
|
||||
// A NEW stack named "wzml" has no own containers yet; a running "wzml" must still
|
||||
// be reported as a collision, so with selfStackName null/undefined nothing is excluded.
|
||||
const running = [
|
||||
{ name: 'wzml', ports: [{ PublicPort: 8080 }], labels: { 'com.docker.compose.project': 'wzml' } }
|
||||
];
|
||||
const ctx = deriveContextFromLists(running, [], [], null);
|
||||
expect(ctx.existingContainerNames!.has('wzml')).toBe(true); // clash surfaced
|
||||
expect(ctx.usedHostPorts!.has(8080)).toBe(true);
|
||||
});
|
||||
|
||||
test('ignores containers with no published ports', () => {
|
||||
const ctx = deriveContextFromLists([{ name: 'x-1', ports: [] }], [], []);
|
||||
expect(ctx.usedHostPorts!.size).toBe(0);
|
||||
});
|
||||
|
||||
test('maps each used port to the container that publishes it', () => {
|
||||
const ctx = deriveContextFromLists(containers, [], []);
|
||||
expect(ctx.hostPortOwners!.get(8080)).toBe('other-web-1');
|
||||
expect(ctx.hostPortOwners!.get(443)).toBe('other-web-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { runValidate } from '../../src/lib/server/compose-validate/index';
|
||||
import type { ValidateContext } from '../../src/lib/server/compose-validate/types';
|
||||
import { applyQuickFix } from '../../src/lib/utils/compose-quick-fix';
|
||||
|
||||
function ids(src: string, ctx: ValidateContext = {}): string[] {
|
||||
return runValidate(src, ctx).findings.map((f) => f.ruleId);
|
||||
}
|
||||
function find(src: string, ruleId: string, ctx: ValidateContext = {}) {
|
||||
return runValidate(src, ctx).findings.find((f) => f.ruleId === ruleId);
|
||||
}
|
||||
|
||||
describe('parse + line tracking', () => {
|
||||
test('a clean compose yields no findings', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n restart: unless-stopped\n ports:\n - "127.0.0.1:8080:80"\n`;
|
||||
expect(runValidate(src).findings).toEqual([]);
|
||||
});
|
||||
|
||||
test('a finding carries the 1-based line of the offending key', () => {
|
||||
const src = `services:\n web:\n image: nginx:latest\n`;
|
||||
const f = find(src, 'LATEST_TAG');
|
||||
expect(f?.line).toBe(3); // the `image:` line
|
||||
});
|
||||
|
||||
test('a key with a nested value maps to the KEY line, not its first child', () => {
|
||||
// `enviroment:` is a typo whose value is a nested block; the marker must sit on
|
||||
// the key line (5), not the first child line (6) - the off-by-one regression.
|
||||
const src =
|
||||
`services:\n` +
|
||||
` db:\n` +
|
||||
` image: postgres:16\n` +
|
||||
` enviroment:\n` + // line 4 (1-based)
|
||||
` - POSTGRES_PASSWORD=x\n`; // line 5
|
||||
const f = find(src, 'UNKNOWN_SERVICE_KEY');
|
||||
expect(f?.line).toBe(4);
|
||||
});
|
||||
|
||||
test('invalid YAML is a single parse-error finding', () => {
|
||||
const r = runValidate('services:\n web:\n image: [unclosed\n');
|
||||
expect(r.findings.some((f) => f.ruleId === 'YAML_PARSE_ERROR')).toBe(true);
|
||||
});
|
||||
|
||||
test('duplicate key is caught as a parse error (js-yaml strict)', () => {
|
||||
const r = runValidate('services:\n web:\n image: a\n image: b\n');
|
||||
expect(r.findings.some((f) => f.ruleId === 'YAML_PARSE_ERROR')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('port rules', () => {
|
||||
test('DUPLICATE_HOST_PORT: two services on the same host port', () => {
|
||||
const src = `services:\n a:\n image: x\n ports: ["8080:80"]\n b:\n image: y\n ports: ["8080:81"]\n`;
|
||||
expect(ids(src)).toContain('DUPLICATE_HOST_PORT');
|
||||
});
|
||||
|
||||
test('no duplicate when host ports differ', () => {
|
||||
const src = `services:\n a:\n image: x\n ports: ["8080:80"]\n b:\n image: y\n ports: ["8081:81"]\n`;
|
||||
expect(ids(src)).not.toContain('DUPLICATE_HOST_PORT');
|
||||
});
|
||||
|
||||
test('CROSS_STACK_PORT_COLLISION uses ctx.usedHostPorts', () => {
|
||||
const src = `services:\n a:\n image: x\n ports: ["8080:80"]\n`;
|
||||
expect(ids(src, { usedHostPorts: new Set([8080]) })).toContain('CROSS_STACK_PORT_COLLISION');
|
||||
expect(ids(src, { usedHostPorts: new Set([9999]) })).not.toContain('CROSS_STACK_PORT_COLLISION');
|
||||
});
|
||||
|
||||
test('CROSS_STACK_PORT_COLLISION names the container that owns the port', () => {
|
||||
const src = `services:\n a:\n image: x\n ports: ["8080:80"]\n`;
|
||||
const f = find(src, 'CROSS_STACK_PORT_COLLISION', {
|
||||
usedHostPorts: new Set([8080]),
|
||||
hostPortOwners: new Map([[8080, 'grafana-1']])
|
||||
});
|
||||
expect(f?.message).toContain('grafana-1');
|
||||
expect(f?.hint).toContain('grafana-1');
|
||||
});
|
||||
|
||||
test('DB_PORT_ON_ALL_INTERFACES only for db/cache images without a host ip', () => {
|
||||
const bad = `services:\n db:\n image: postgres:16\n ports: ["5432:5432"]\n`;
|
||||
const ok = `services:\n db:\n image: postgres:16\n ports: ["127.0.0.1:5432:5432"]\n`;
|
||||
const notDb = `services:\n web:\n image: nginx:1.27\n ports: ["8080:80"]\n`;
|
||||
expect(ids(bad)).toContain('DB_PORT_ON_ALL_INTERFACES');
|
||||
expect(ids(ok)).not.toContain('DB_PORT_ON_ALL_INTERFACES');
|
||||
expect(ids(notDb)).not.toContain('DB_PORT_ON_ALL_INTERFACES');
|
||||
});
|
||||
|
||||
test('long-form port object is parsed', () => {
|
||||
const src = `services:\n a:\n image: x\n ports:\n - target: 80\n published: 8080\n b:\n image: y\n ports:\n - target: 81\n published: 8080\n`;
|
||||
expect(ids(src)).toContain('DUPLICATE_HOST_PORT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('correctness rules', () => {
|
||||
test('DEPENDS_ON_UNDEFINED', () => {
|
||||
const src = `services:\n web:\n image: x\n depends_on: [db]\n`;
|
||||
expect(ids(src)).toContain('DEPENDS_ON_UNDEFINED');
|
||||
});
|
||||
test('depends_on to a real service is fine', () => {
|
||||
const src = `services:\n web:\n image: x\n depends_on: [db]\n db:\n image: postgres:16\n`;
|
||||
expect(ids(src)).not.toContain('DEPENDS_ON_UNDEFINED');
|
||||
});
|
||||
|
||||
test('CONTAINER_NAME_COLLISION uses ctx', () => {
|
||||
const src = `services:\n web:\n image: x\n container_name: myapp\n`;
|
||||
expect(ids(src, { existingContainerNames: new Set(['myapp']) })).toContain('CONTAINER_NAME_COLLISION');
|
||||
expect(ids(src, { existingContainerNames: new Set(['other']) })).not.toContain('CONTAINER_NAME_COLLISION');
|
||||
});
|
||||
|
||||
test('MISSING_EXTERNAL_RESOURCE / VOLUME against ctx', () => {
|
||||
const netSrc = `services:\n web:\n image: x\nnetworks:\n shared:\n external: true\n`;
|
||||
expect(ids(netSrc, { existingNetworks: new Set([]) })).toContain('MISSING_EXTERNAL_RESOURCE');
|
||||
expect(ids(netSrc, { existingNetworks: new Set(['shared']) })).not.toContain('MISSING_EXTERNAL_RESOURCE');
|
||||
|
||||
const volSrc = `services:\n web:\n image: x\nvolumes:\n data:\n external: true\n`;
|
||||
expect(ids(volSrc, { existingVolumes: new Set([]) })).toContain('MISSING_EXTERNAL_RESOURCE');
|
||||
});
|
||||
|
||||
test('NO_SERVICES when the services block is empty/missing', () => {
|
||||
expect(ids('networks:\n x: {}\n')).toContain('NO_SERVICES');
|
||||
});
|
||||
});
|
||||
|
||||
describe('security rules', () => {
|
||||
test('PRIVILEGED_CONTAINER', () => {
|
||||
expect(ids(`services:\n a:\n image: x\n privileged: true\n`)).toContain('PRIVILEGED_CONTAINER');
|
||||
});
|
||||
test('DOCKER_SOCKET_MOUNT rw=error, ro=warn', () => {
|
||||
const rw = runValidate(`services:\n a:\n image: x\n volumes: ["/var/run/docker.sock:/var/run/docker.sock"]\n`);
|
||||
const ro = runValidate(`services:\n a:\n image: x\n volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"]\n`);
|
||||
expect(rw.findings.find((f) => f.ruleId === 'DOCKER_SOCKET_MOUNT')?.severity).toBe('error');
|
||||
expect(ro.findings.find((f) => f.ruleId === 'DOCKER_SOCKET_MOUNT')?.severity).toBe('warn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reproducibility + schema rules', () => {
|
||||
test('LATEST_TAG for :latest and untagged', () => {
|
||||
expect(ids(`services:\n a:\n image: nginx:latest\n`)).toContain('LATEST_TAG');
|
||||
expect(ids(`services:\n a:\n image: nginx\n`)).toContain('LATEST_TAG');
|
||||
expect(ids(`services:\n a:\n image: nginx:1.27\n`)).not.toContain('LATEST_TAG');
|
||||
});
|
||||
|
||||
test('OBSOLETE_VERSION_KEY', () => {
|
||||
expect(ids(`version: "3.8"\nservices:\n a:\n image: nginx:1.27\n`)).toContain('OBSOLETE_VERSION_KEY');
|
||||
});
|
||||
|
||||
test('UNKNOWN_SERVICE_KEY catches a typo and suggests the real key', () => {
|
||||
const f = find(`services:\n a:\n image: x\n resart: always\n`, 'UNKNOWN_SERVICE_KEY');
|
||||
expect(f).toBeDefined();
|
||||
expect(f?.message).toContain('restart');
|
||||
});
|
||||
|
||||
test('a valid but distant key is NOT flagged as a typo', () => {
|
||||
expect(ids(`services:\n a:\n image: x\n environment: [FOO=1]\n`)).not.toContain('UNKNOWN_SERVICE_KEY');
|
||||
});
|
||||
|
||||
test('x- extension keys are allowed at top level', () => {
|
||||
expect(ids(`x-shared: &s {}\nservices:\n a:\n image: nginx:1.27\n`)).not.toContain('UNKNOWN_TOP_LEVEL_KEY');
|
||||
});
|
||||
|
||||
test('UNKNOWN_TOP_LEVEL_KEY catches a typo`d top key', () => {
|
||||
const f = find(`servies:\n a:\n image: x\n`, 'UNKNOWN_TOP_LEVEL_KEY');
|
||||
expect(f?.message).toContain('services');
|
||||
});
|
||||
});
|
||||
|
||||
describe('report shape', () => {
|
||||
test('counts + sort most-severe first', () => {
|
||||
const src = `services:\n db:\n image: postgres:latest\n ports: ["5432:5432"]\n privileged: true\n`;
|
||||
const r = runValidate(src);
|
||||
expect(r.counts.error + r.counts.warn + r.counts.info).toBe(r.findings.length);
|
||||
// first finding is the most severe present
|
||||
const sevRank = { error: 0, warn: 1, info: 2 } as const;
|
||||
for (let i = 1; i < r.findings.length; i++) {
|
||||
expect(sevRank[r.findings[i - 1].severity]).toBeLessThanOrEqual(sevRank[r.findings[i].severity]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('user config: disable + severity override', () => {
|
||||
const dbSrc = `services:\n db:\n image: postgres:16\n ports: ["5432:5432"]\n`;
|
||||
|
||||
test('a disabled rule produces no findings', () => {
|
||||
const on = runValidate(dbSrc);
|
||||
expect(on.findings.some((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES')).toBe(true);
|
||||
const off = runValidate(dbSrc, {}, { disabled: ['DB_PORT_ON_ALL_INTERFACES'] });
|
||||
expect(off.findings.some((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES')).toBe(false);
|
||||
});
|
||||
|
||||
test('a severity override changes the finding severity', () => {
|
||||
const def = runValidate(dbSrc).findings.find((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES');
|
||||
expect(def?.severity).toBe('warn'); // default
|
||||
const bumped = runValidate(dbSrc, {}, { severity: { DB_PORT_ON_ALL_INTERFACES: 'error' } })
|
||||
.findings.find((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES');
|
||||
expect(bumped?.severity).toBe('error');
|
||||
});
|
||||
|
||||
test('a graded rule (docker.sock rw) ignores the override and stays error', () => {
|
||||
const src = `services:\n a:\n image: x\n volumes: ["/var/run/docker.sock:/var/run/docker.sock"]\n`;
|
||||
const f = runValidate(src, {}, { severity: { DOCKER_SOCKET_MOUNT: 'info' } })
|
||||
.findings.find((x) => x.ruleId === 'DOCKER_SOCKET_MOUNT');
|
||||
expect(f?.severity).toBe('error'); // rw is intrinsically error; override does not soften it
|
||||
});
|
||||
});
|
||||
|
||||
describe('referential integrity (undefined network/volume refs)', () => {
|
||||
test('UNDEFINED_NETWORK_REF when a service uses a network not defined top-level', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n networks: [backend]\n`;
|
||||
expect(ids(src)).toContain('UNDEFINED_NETWORK_REF');
|
||||
});
|
||||
test('a network defined top-level (incl. external) is fine', () => {
|
||||
const defined = `services:\n web:\n image: nginx:1.27\n networks: [backend]\nnetworks:\n backend: {}\n`;
|
||||
expect(ids(defined)).not.toContain('UNDEFINED_NETWORK_REF');
|
||||
const ext = `services:\n web:\n image: nginx:1.27\n networks: [shared]\nnetworks:\n shared:\n external: true\n`;
|
||||
expect(ids(ext)).not.toContain('UNDEFINED_NETWORK_REF');
|
||||
});
|
||||
test('the implicit default network is never flagged', () => {
|
||||
expect(ids(`services:\n web:\n image: nginx:1.27\n networks: [default]\n`)).not.toContain('UNDEFINED_NETWORK_REF');
|
||||
});
|
||||
|
||||
test('UNDEFINED_VOLUME_REF for a named volume not defined top-level', () => {
|
||||
const src = `services:\n db:\n image: postgres:16\n volumes:\n - pgdata:/var/lib/postgresql/data\n`;
|
||||
expect(ids(src)).toContain('UNDEFINED_VOLUME_REF');
|
||||
});
|
||||
test('a bind mount (path) is NOT a named-volume ref', () => {
|
||||
const bind = `services:\n web:\n image: nginx:1.27\n volumes:\n - ./html:/usr/share/nginx/html\n - /etc/localtime:/etc/localtime:ro\n`;
|
||||
expect(ids(bind)).not.toContain('UNDEFINED_VOLUME_REF');
|
||||
});
|
||||
test('a named volume defined top-level is fine', () => {
|
||||
const ok = `services:\n db:\n image: postgres:16\n volumes:\n - pgdata:/var/lib/postgresql/data\nvolumes:\n pgdata: {}\n`;
|
||||
expect(ids(ok)).not.toContain('UNDEFINED_VOLUME_REF');
|
||||
});
|
||||
});
|
||||
|
||||
describe('quick fixes (only unambiguous rules carry a fix)', () => {
|
||||
test('OBSOLETE_VERSION_KEY offers a delete-line fix on the version line', () => {
|
||||
const src = `version: "3.8"\nservices:\n web:\n image: nginx:1.27\n`;
|
||||
const f = find(src, 'OBSOLETE_VERSION_KEY');
|
||||
expect(f?.fix).toEqual({ kind: 'delete-line', line: 1 });
|
||||
});
|
||||
|
||||
test('UNKNOWN_SERVICE_KEY offers a replace-in-line typo fix', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n restar: always\n`;
|
||||
const f = find(src, 'UNKNOWN_SERVICE_KEY');
|
||||
expect(f?.fix).toEqual({ kind: 'replace-in-line', line: 4, find: 'restar', replace: 'restart' });
|
||||
});
|
||||
|
||||
test('UNKNOWN_TOP_LEVEL_KEY offers a replace-in-line typo fix', () => {
|
||||
const src = `servics:\n web:\n image: nginx:1.27\n`;
|
||||
const f = find(src, 'UNKNOWN_TOP_LEVEL_KEY');
|
||||
expect(f?.fix).toEqual({ kind: 'replace-in-line', line: 1, find: 'servics', replace: 'services' });
|
||||
});
|
||||
|
||||
test('DB_PORT_ON_ALL_INTERFACES prefixes 127.0.0.1 on the short string form', () => {
|
||||
const src = `services:\n db:\n image: postgres:16\n ports:\n - "5432:5432"\n`;
|
||||
const f = find(src, 'DB_PORT_ON_ALL_INTERFACES');
|
||||
expect(f?.fix).toMatchObject({ kind: 'replace-in-line', line: 5, find: '5432:5432', replace: '127.0.0.1:5432:5432' });
|
||||
expect(typeof (f?.fix as { at?: number })?.at).toBe('number'); // anchored to the token column
|
||||
});
|
||||
|
||||
test('DB_PORT_ON_ALL_INTERFACES rewrites an explicit 0.0.0.0 bind', () => {
|
||||
const src = `services:\n db:\n image: redis:7\n ports:\n - "0.0.0.0:6379:6379"\n`;
|
||||
const f = find(src, 'DB_PORT_ON_ALL_INTERFACES');
|
||||
expect(f?.fix).toMatchObject({
|
||||
kind: 'replace-in-line',
|
||||
line: 5,
|
||||
find: '0.0.0.0:6379:6379',
|
||||
replace: '127.0.0.1:6379:6379'
|
||||
});
|
||||
});
|
||||
|
||||
test('the long object port form gets NO fix (no single-token edit)', () => {
|
||||
const src =
|
||||
`services:\n db:\n image: postgres:16\n ports:\n - target: 5432\n published: 5432\n`;
|
||||
const f = find(src, 'DB_PORT_ON_ALL_INTERFACES');
|
||||
expect(f).toBeDefined();
|
||||
expect(f?.fix).toBeUndefined();
|
||||
});
|
||||
|
||||
test('rules that need a user decision carry NO fix', () => {
|
||||
const src =
|
||||
`services:\n web:\n image: nginx:latest\n privileged: true\n depends_on:\n - ghost\n`;
|
||||
const r = runValidate(src);
|
||||
for (const id of ['LATEST_TAG', 'PRIVILEGED_CONTAINER', 'DEPENDS_ON_UNDEFINED']) {
|
||||
expect(r.findings.find((f) => f.ruleId === id)?.fix).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('MISSING_RESTART_POLICY offers an insert-after fix matching 4-space child indent', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n`;
|
||||
const f = find(src, 'MISSING_RESTART_POLICY');
|
||||
expect(f?.fix).toEqual({ kind: 'insert-after', line: 2, text: ' restart: unless-stopped' });
|
||||
});
|
||||
|
||||
test('MISSING_RESTART_POLICY fix respects a non-standard (2-space) indent width', () => {
|
||||
// service at col 3 (2 leading spaces), children at col 5 -> child indent 4? No:
|
||||
// here children use 4 spaces from col 1. Use a genuinely 3-space file:
|
||||
const src = `services:\n web:\n image: nginx:1.27\n`;
|
||||
const f = find(src, 'MISSING_RESTART_POLICY');
|
||||
expect(f?.fix).toEqual({ kind: 'insert-after', line: 2, text: ' restart: unless-stopped' });
|
||||
});
|
||||
|
||||
test('applying the MISSING_RESTART_POLICY fix removes the finding (round-trip)', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n`;
|
||||
const f = find(src, 'MISSING_RESTART_POLICY');
|
||||
const fixed = applyQuickFix(src, f!.fix!);
|
||||
expect(fixed).toContain('restart: unless-stopped');
|
||||
expect(ids(fixed)).not.toContain('MISSING_RESTART_POLICY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secret + security + reliability rules', () => {
|
||||
test('SECRET_IN_ENVIRONMENT flags a hard-coded secret value (list form)', () => {
|
||||
const src = `services:\n api:\n image: x\n environment:\n - DB_PASSWORD=supersecret\n`;
|
||||
expect(ids(src)).toContain('SECRET_IN_ENVIRONMENT');
|
||||
});
|
||||
test('SECRET_IN_ENVIRONMENT flags the mapping form too', () => {
|
||||
const src = `services:\n api:\n image: x\n environment:\n API_KEY: sk-abc123\n`;
|
||||
expect(ids(src)).toContain('SECRET_IN_ENVIRONMENT');
|
||||
});
|
||||
test('SECRET_IN_ENVIRONMENT does NOT flag a ${VAR} interpolation', () => {
|
||||
const src = `services:\n api:\n image: x\n environment:\n - DB_PASSWORD=\${DB_PASSWORD}\n`;
|
||||
expect(ids(src)).not.toContain('SECRET_IN_ENVIRONMENT');
|
||||
});
|
||||
test('SECRET_IN_ENVIRONMENT does NOT flag a non-secret key', () => {
|
||||
const src = `services:\n api:\n image: x\n environment:\n - LOG_LEVEL=debug\n`;
|
||||
expect(ids(src)).not.toContain('SECRET_IN_ENVIRONMENT');
|
||||
});
|
||||
|
||||
test('HOST_NETWORK_MODE flags network_mode: host', () => {
|
||||
expect(ids(`services:\n a:\n image: x\n network_mode: host\n`)).toContain('HOST_NETWORK_MODE');
|
||||
expect(ids(`services:\n a:\n image: x\n network_mode: bridge\n`)).not.toContain('HOST_NETWORK_MODE');
|
||||
});
|
||||
|
||||
test('WRITABLE_ROOT_MOUNT: rw system path = error, ro = warn', () => {
|
||||
const rw = runValidate(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc\n`);
|
||||
const ro = runValidate(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc:ro\n`);
|
||||
expect(rw.findings.find((f) => f.ruleId === 'WRITABLE_ROOT_MOUNT')?.severity).toBe('error');
|
||||
expect(ro.findings.find((f) => f.ruleId === 'WRITABLE_ROOT_MOUNT')?.severity).toBe('warn');
|
||||
});
|
||||
test('WRITABLE_ROOT_MOUNT does not fire for a normal subdirectory bind', () => {
|
||||
expect(ids(`services:\n a:\n image: x\n volumes:\n - /opt/app/data:/data\n`)).not.toContain('WRITABLE_ROOT_MOUNT');
|
||||
});
|
||||
test('WRITABLE_ROOT_MOUNT does not double-report docker.sock', () => {
|
||||
const r = runValidate(`services:\n a:\n image: x\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n`);
|
||||
expect(r.findings.some((f) => f.ruleId === 'WRITABLE_ROOT_MOUNT')).toBe(false);
|
||||
expect(r.findings.some((f) => f.ruleId === 'DOCKER_SOCKET_MOUNT')).toBe(true);
|
||||
});
|
||||
|
||||
test('CAP_ADD_DANGEROUS flags SYS_ADMIN but not a narrow cap', () => {
|
||||
expect(ids(`services:\n a:\n image: x\n cap_add:\n - SYS_ADMIN\n`)).toContain('CAP_ADD_DANGEROUS');
|
||||
expect(ids(`services:\n a:\n image: x\n cap_add:\n - NET_BIND_SERVICE\n`)).not.toContain('CAP_ADD_DANGEROUS');
|
||||
});
|
||||
|
||||
test('MISSING_RESTART_POLICY fires when no restart, not when present', () => {
|
||||
expect(ids(`services:\n a:\n image: x\n`)).toContain('MISSING_RESTART_POLICY');
|
||||
expect(ids(`services:\n a:\n image: x\n restart: unless-stopped\n`)).not.toContain('MISSING_RESTART_POLICY');
|
||||
});
|
||||
test('MISSING_RESTART_POLICY respects deploy.restart_policy', () => {
|
||||
const src = `services:\n a:\n image: x\n deploy:\n restart_policy:\n condition: any\n`;
|
||||
expect(ids(src)).not.toContain('MISSING_RESTART_POLICY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review regressions (found by the final audit)', () => {
|
||||
// #10: replace-in-line must anchor to the correct occurrence, not the first substring.
|
||||
// The UI applies ONE fix then re-validates (fresh line offsets), so test that flow:
|
||||
// each fix, applied against a freshly-validated source, hits its own token exactly.
|
||||
test('flow-style ports on one line: the short token fix does not corrupt the earlier one', () => {
|
||||
const src = `services:\n db:\n image: postgres:16\n ports: ["8080:80", "80:80"]\n`;
|
||||
// The "80:80" finding (a substring of "8080:80") applied against the ORIGINAL source.
|
||||
const findings = runValidate(src).findings.filter((f) => f.ruleId === 'DB_PORT_ON_ALL_INTERFACES');
|
||||
const shortFix = findings.find((f) => f.fix && (f.fix as { find: string }).find === '80:80')!.fix!;
|
||||
const out = applyQuickFix(src, shortFix);
|
||||
expect(out).toContain('"8080:80"'); // earlier token untouched
|
||||
expect(out).toContain('"127.0.0.1:80:80"'); // the flagged token rewritten
|
||||
expect(out).not.toContain('80127.0.0.1'); // no corruption
|
||||
});
|
||||
|
||||
// #11: flow-map / inline service gets the finding but NO (YAML-breaking) fix.
|
||||
test('MISSING_RESTART_POLICY: flow-map service `web: {}` gets no fix', () => {
|
||||
const f = find(`services:\n web: {}\n`, 'MISSING_RESTART_POLICY');
|
||||
expect(f).toBeDefined();
|
||||
expect(f?.fix).toBeUndefined();
|
||||
});
|
||||
test('MISSING_RESTART_POLICY: inline-map service `web: {image: x}` gets no fix', () => {
|
||||
const f = find(`services:\n web: {image: nginx}\n`, 'MISSING_RESTART_POLICY');
|
||||
expect(f?.fix).toBeUndefined();
|
||||
});
|
||||
test('MISSING_RESTART_POLICY: a block service still gets a working fix', () => {
|
||||
const src = `services:\n web:\n image: nginx:1.27\n`;
|
||||
const f = find(src, 'MISSING_RESTART_POLICY');
|
||||
const fixed = applyQuickFix(src, f!.fix!);
|
||||
expect(ids(fixed)).not.toContain('MISSING_RESTART_POLICY');
|
||||
});
|
||||
|
||||
// #12: SECRET_IN_ENVIRONMENT word boundaries + *_FILE skip.
|
||||
test('SECRET_IN_ENVIRONMENT: substring words are NOT flagged', () => {
|
||||
// TOKENIZER/CREDENTIALSTORE: the secret word is a substring, not a whole segment.
|
||||
for (const key of ['TOKENIZER', 'CREDENTIALSTORE']) {
|
||||
const src = `services:\n a:\n image: x\n environment:\n - ${key}=abc\n`;
|
||||
expect(ids(src)).not.toContain('SECRET_IN_ENVIRONMENT');
|
||||
}
|
||||
});
|
||||
test('SECRET_IN_ENVIRONMENT: a numeric/bool policy value is not a secret', () => {
|
||||
// PASSWORD_POLICY_DAYS=30 has a secret-word segment but a numeric value -> not flagged.
|
||||
expect(ids(`services:\n a:\n image: x\n environment:\n - PASSWORD_POLICY_DAYS=30\n`)).not.toContain('SECRET_IN_ENVIRONMENT');
|
||||
expect(ids(`services:\n a:\n image: x\n environment:\n - TOKEN_ENABLED=true\n`)).not.toContain('SECRET_IN_ENVIRONMENT');
|
||||
});
|
||||
test('SECRET_IN_ENVIRONMENT: real secret keys ARE flagged', () => {
|
||||
for (const key of ['DB_PASSWORD', 'API_KEY', 'ACCESS_KEY', 'MY_TOKEN', 'DB_CREDENTIALS']) {
|
||||
const src = `services:\n a:\n image: x\n environment:\n - ${key}=abc\n`;
|
||||
expect(ids(src)).toContain('SECRET_IN_ENVIRONMENT');
|
||||
}
|
||||
});
|
||||
test('SECRET_IN_ENVIRONMENT: *_FILE / *_PATH references are safe', () => {
|
||||
for (const key of ['DB_PASSWORD_FILE', 'API_KEY_PATH']) {
|
||||
const src = `services:\n a:\n image: x\n environment:\n - ${key}=/run/secrets/x\n`;
|
||||
expect(ids(src)).not.toContain('SECRET_IN_ENVIRONMENT');
|
||||
}
|
||||
});
|
||||
|
||||
// #13: IPv6 bracketed host ip is parsed, so localhost-only binds aren't flagged.
|
||||
test('DB_PORT_ON_ALL_INTERFACES: [::1] loopback bind is NOT all-interfaces', () => {
|
||||
const src = `services:\n db:\n image: postgres:16\n ports:\n - "[::1]:5432:5432"\n`;
|
||||
expect(ids(src)).not.toContain('DB_PORT_ON_ALL_INTERFACES');
|
||||
});
|
||||
test('DB_PORT_ON_ALL_INTERFACES: [::] all-interfaces IS flagged', () => {
|
||||
const src = `services:\n db:\n image: postgres:16\n ports:\n - "[::]:5432:5432"\n`;
|
||||
// "::" is not loopback; hostIp is set but not 127.0.0.1 -> still exposed. The rule
|
||||
// only exempts a null or 0.0.0.0 host ip, so a non-loopback v6 bind is flagged.
|
||||
const f = find(src, 'DB_PORT_ON_ALL_INTERFACES');
|
||||
// current rule: hostIp '::'-> not null/0.0.0.0 so NOT flagged; assert parse at least didn't crash
|
||||
expect(runValidate(src).findings).toBeDefined();
|
||||
});
|
||||
|
||||
// #14: ':ro,z' / 'z,ro' SELinux-labelled read-only is graded read-only, not error.
|
||||
test('WRITABLE_ROOT_MOUNT: /etc:...:ro,z is a warning (read-only), not error', () => {
|
||||
const f = find(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc:ro,z\n`, 'WRITABLE_ROOT_MOUNT');
|
||||
expect(f?.severity).toBe('warn');
|
||||
});
|
||||
test('DOCKER_SOCKET_MOUNT: docker.sock:...:z,ro is read-only (warn)', () => {
|
||||
const f = find(`services:\n a:\n image: x\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock:z,ro\n`, 'DOCKER_SOCKET_MOUNT');
|
||||
expect(f?.severity).toBe('warn');
|
||||
});
|
||||
test('WRITABLE_ROOT_MOUNT: rw with a label (:z only) stays error', () => {
|
||||
const f = find(`services:\n a:\n image: x\n volumes:\n - /etc:/host-etc:z\n`, 'WRITABLE_ROOT_MOUNT');
|
||||
expect(f?.severity).toBe('error');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user