Files
sencho/backend/src/__tests__/network-topology.test.ts
T
Anso f23b7e1bac feat: ordered multi-file Compose for Git sources (#1380)
* feat: ordered multi-file Compose for Git sources

Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.

- Pick and reorder compose files from the repository tree (drag to reorder on
  desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
  start/stop/restart/down, image scans, Compose Doctor) and the container
  lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
  source does not change deploy args until the pull is applied, and apply
  materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
  pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
  before, and existing rows keep working via the single-path fallback.

Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).

* fix: harden multi-file Git source (hash, unlink, collisions, node id)

- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
  stack is not flagged as locally edited: create/apply hash the fetched files
  (repo paths) while pull hashes the on-disk files (materialized paths), which
  previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
  spec lives on the source row, so removing it would silently revert deploys to
  root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
  file equal to or nested under compose.yaml, an ancestor/descendant overlap
  between selected files, and a project directory nested under a compose file
  (previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
  and passes its node id to the authored prefix, instead of the process default.

* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)

- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
  trim() is optional-chained, so a reusable field component tolerates partial
  props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
  picker's per-file "Remove <path>" buttons no longer collide with the broad
  /remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
  mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
  clearing the js/path-injection alert. The containment check is equivalent and
  contextDir is also validated upstream.

* test: update Git source E2E spec for the multi-file compose picker

The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:

- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
  "Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
  Enter, then remove the default compose.yaml).

* test: match the footer Remove button with an exact Playwright name

Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
2026-06-17 13:24:55 -04:00

585 lines
20 KiB
TypeScript

/**
* Unit tests for DockerController.getTopologyData() - network topology
* data assembly, filtering, container-to-network mapping, and edge cases.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── Hoisted mocks ──────────────────────────────────────────────────────
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().mockResolvedValue({ SpaceReclaimed: 0 }),
pruneImages: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
pruneNetworks: vi.fn().mockResolvedValue({}),
pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
createNetwork: vi.fn(),
};
return { mockDocker };
});
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDocker: () => mockDocker,
getDefaultNodeId: () => 1,
}),
},
}));
// Prevent COMPOSE_DIR filesystem reads (resolveProjectNameMap reads compose files).
// With these mocked, fs.readFile returns ENOENT and the map falls back to
// { stackName: stackName } for each stack passed in.
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();
// The project-name-map is cached for 60 seconds. Invalidate between tests so
// that different stack-name inputs do not leak across tests.
CacheService.getInstance().invalidate('project-name-map');
});
// ── Basic happy path ──────────────────────────────────────────────────
describe('DockerController.getTopologyData - basic', () => {
it('returns networks with their containers', async () => {
mockDocker.listNetworks.mockResolvedValue([
{
Id: 'net-a',
Name: 'app_default',
Driver: 'bridge',
Scope: 'local',
Labels: { 'com.docker.compose.project': 'app' },
},
{
Id: 'net-b',
Name: 'monitoring_default',
Driver: 'bridge',
Scope: 'local',
Labels: { 'com.docker.compose.project': 'monitoring' },
},
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/app-web'],
State: 'running',
Image: 'nginx:latest',
Labels: { 'com.docker.compose.project': 'app' },
NetworkSettings: {
Networks: { app_default: { NetworkID: 'net-a', IPAddress: '172.20.0.2' } },
},
},
{
Id: 'c2',
Names: ['/monitoring-prom'],
State: 'running',
Image: 'prom/prometheus',
Labels: { 'com.docker.compose.project': 'monitoring' },
NetworkSettings: {
Networks: { monitoring_default: { NetworkID: 'net-b', IPAddress: '172.21.0.5' } },
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData(['app', 'monitoring'], false);
expect(result).toHaveLength(2);
const appNet = result.find(n => n.Id === 'net-a')!;
expect(appNet.Name).toBe('app_default');
expect(appNet.managedStatus).toBe('managed');
expect(appNet.managedBy).toBe('app');
expect(appNet.containers).toHaveLength(1);
expect(appNet.containers[0]).toMatchObject({
id: 'c1',
name: 'app-web',
ip: '172.20.0.2',
state: 'running',
image: 'nginx:latest',
stack: 'app',
});
const monNet = result.find(n => n.Id === 'net-b')!;
expect(monNet.containers).toHaveLength(1);
expect(monNet.containers[0].name).toBe('monitoring-prom');
});
it('returns empty array when no networks exist', async () => {
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result).toEqual([]);
});
it('returns networks with empty containers array when no containers are connected', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'net-x', Name: 'isolated', Driver: 'bridge', Scope: 'local', Labels: {} },
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result).toHaveLength(1);
expect(result[0].containers).toEqual([]);
});
});
// ── System network filtering ──────────────────────────────────────────
describe('DockerController.getTopologyData - system network filtering', () => {
const systemNetworks = [
{ Id: 'sys-bridge', Name: 'bridge', Driver: 'bridge', Scope: 'local' },
{ Id: 'sys-host', Name: 'host', Driver: 'host', Scope: 'local' },
{ Id: 'sys-none', Name: 'none', Driver: 'null', Scope: 'local' },
];
const userNetwork = {
Id: 'net-user',
Name: 'app_default',
Driver: 'bridge',
Scope: 'local',
Labels: { 'com.docker.compose.project': 'app' },
};
const bridgeContainer = {
Id: 'c-bridge',
Names: ['/host-container'],
State: 'running',
Image: 'busybox',
Labels: {},
NetworkSettings: {
Networks: { bridge: { NetworkID: 'sys-bridge', IPAddress: '172.17.0.2' } },
},
};
const userContainer = {
Id: 'c-user',
Names: ['/app-svc'],
State: 'running',
Image: 'nginx',
Labels: { 'com.docker.compose.project': 'app' },
NetworkSettings: {
Networks: { app_default: { NetworkID: 'net-user', IPAddress: '172.20.0.2' } },
},
};
it('excludes system networks when includeSystem is false', async () => {
mockDocker.listNetworks.mockResolvedValue([...systemNetworks, userNetwork]);
mockDocker.listContainers.mockResolvedValue([bridgeContainer, userContainer]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData(['app'], false);
expect(result).toHaveLength(1);
expect(result[0].Name).toBe('app_default');
// Bridge container is not visible because its only network was filtered out
expect(result[0].containers.find(c => c.id === 'c-bridge')).toBeUndefined();
});
it('includes system networks when includeSystem is true', async () => {
mockDocker.listNetworks.mockResolvedValue([...systemNetworks, userNetwork]);
mockDocker.listContainers.mockResolvedValue([bridgeContainer, userContainer]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData(['app'], true);
expect(result).toHaveLength(4);
const bridge = result.find(n => n.Name === 'bridge')!;
expect(bridge.managedStatus).toBe('system');
expect(bridge.containers).toHaveLength(1);
expect(bridge.containers[0].id).toBe('c-bridge');
});
it('system networks always have managedStatus system and managedBy null', async () => {
mockDocker.listNetworks.mockResolvedValue(systemNetworks);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], true);
expect(result).toHaveLength(3);
for (const net of result) {
expect(net.managedStatus).toBe('system');
expect(net.managedBy).toBeNull();
}
});
});
// ── Stack resolution ──────────────────────────────────────────────────
describe('DockerController.getTopologyData - stack resolution', () => {
it('classifies networks with matching compose project label as managed', async () => {
mockDocker.listNetworks.mockResolvedValue([
{
Id: 'n1',
Name: 'my-stack_default',
Driver: 'bridge',
Scope: 'local',
Labels: { 'com.docker.compose.project': 'my-stack' },
},
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData(['my-stack'], false);
expect(result[0].managedStatus).toBe('managed');
expect(result[0].managedBy).toBe('my-stack');
});
it('classifies unlabeled networks as unmanaged', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'custom-net', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData(['my-stack'], false);
expect(result[0].managedStatus).toBe('unmanaged');
expect(result[0].managedBy).toBeNull();
});
it('classifies networks with unrecognized project label as unmanaged', async () => {
mockDocker.listNetworks.mockResolvedValue([
{
Id: 'n1',
Name: 'other_default',
Driver: 'bridge',
Scope: 'local',
Labels: { 'com.docker.compose.project': 'other-stack' },
},
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData(['my-stack'], false);
expect(result[0].managedStatus).toBe('unmanaged');
expect(result[0].managedBy).toBeNull();
});
it('resolves container stack from compose project label', async () => {
mockDocker.listNetworks.mockResolvedValue([
{
Id: 'n1',
Name: 'my-stack_default',
Driver: 'bridge',
Scope: 'local',
Labels: { 'com.docker.compose.project': 'my-stack' },
},
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/my-svc'],
State: 'running',
Image: 'nginx',
Labels: { 'com.docker.compose.project': 'my-stack' },
NetworkSettings: {
Networks: { my_stack_default: { NetworkID: 'n1', IPAddress: '172.20.0.2' } },
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData(['my-stack'], false);
expect(result[0].containers[0].stack).toBe('my-stack');
});
it('container stack is null when no compose label matches', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'custom', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/standalone'],
State: 'running',
Image: 'busybox',
Labels: {},
NetworkSettings: {
Networks: { custom: { NetworkID: 'n1', IPAddress: '172.25.0.2' } },
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].containers[0].stack).toBeNull();
});
});
// ── Container deduplication ──────────────────────────────────────────
describe('DockerController.getTopologyData - container deduplication', () => {
it('container on multiple networks appears in each network container list', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'net-a', Name: 'frontend', Driver: 'bridge', Scope: 'local' },
{ Id: 'net-b', Name: 'backend', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'multi',
Names: ['/api-gateway'],
State: 'running',
Image: 'gateway:1.0',
Labels: {},
NetworkSettings: {
Networks: {
frontend: { NetworkID: 'net-a', IPAddress: '172.30.0.2' },
backend: { NetworkID: 'net-b', IPAddress: '172.31.0.2' },
},
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result).toHaveLength(2);
expect(result.find(n => n.Id === 'net-a')!.containers).toHaveLength(1);
expect(result.find(n => n.Id === 'net-b')!.containers).toHaveLength(1);
});
it('container IP is network-specific', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'net-a', Name: 'frontend', Driver: 'bridge', Scope: 'local' },
{ Id: 'net-b', Name: 'backend', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'multi',
Names: ['/api-gateway'],
State: 'running',
Image: 'gateway:1.0',
Labels: {},
NetworkSettings: {
Networks: {
frontend: { NetworkID: 'net-a', IPAddress: '172.30.0.2' },
backend: { NetworkID: 'net-b', IPAddress: '172.31.0.2' },
},
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
const frontendEntry = result.find(n => n.Id === 'net-a')!.containers[0];
const backendEntry = result.find(n => n.Id === 'net-b')!.containers[0];
expect(frontendEntry.ip).toBe('172.30.0.2');
expect(backendEntry.ip).toBe('172.31.0.2');
});
});
// ── Edge cases ────────────────────────────────────────────────────────
describe('DockerController.getTopologyData - edge cases', () => {
it('handles containers with missing NetworkSettings', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{ Id: 'c1', Names: ['/orphan'], State: 'running', Image: 'busybox', Labels: {} },
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].containers).toEqual([]);
});
it('handles containers with empty Networks object', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/orphan'],
State: 'running',
Image: 'busybox',
Labels: {},
NetworkSettings: { Networks: {} },
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].containers).toEqual([]);
});
it('skips network entries without NetworkID', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/svc'],
State: 'running',
Image: 'busybox',
Labels: {},
NetworkSettings: {
Networks: {
'broken-net': { NetworkID: undefined, IPAddress: '10.0.0.1' },
'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' },
},
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].containers).toHaveLength(1);
expect(result[0].containers[0].ip).toBe('172.20.0.2');
});
it('falls back to truncated container ID when Names is empty', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'abc123def4567890abcdef1234567890',
Names: [],
State: 'running',
Image: 'busybox',
Labels: {},
NetworkSettings: {
Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } },
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].containers[0].name).toBe('abc123def456');
});
it('falls back to truncated container ID when Names is undefined', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'deadbeef00112233445566778899aabb',
State: 'running',
Image: 'busybox',
Labels: {},
NetworkSettings: {
Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } },
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].containers[0].name).toBe('deadbeef0011');
});
it('defaults Driver to bridge when absent from network data', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'user-net', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].Driver).toBe('bridge');
});
it('defaults State to unknown when absent from container data', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'user-net', Driver: 'bridge', Scope: 'local' },
]);
mockDocker.listContainers.mockResolvedValue([
{
Id: 'c1',
Names: ['/svc'],
Image: 'busybox',
Labels: {},
NetworkSettings: {
Networks: { 'user-net': { NetworkID: 'n1', IPAddress: '172.20.0.2' } },
},
},
]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].containers[0].state).toBe('unknown');
});
it('defaults Scope to local when absent from network data', async () => {
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'n1', Name: 'user-net', Driver: 'bridge' },
]);
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
const result = await dc.getTopologyData([], false);
expect(result[0].Scope).toBe('local');
});
});
// ── Error handling ────────────────────────────────────────────────────
describe('DockerController.getTopologyData - error handling', () => {
it('propagates Docker API errors from listNetworks', async () => {
mockDocker.listNetworks.mockRejectedValue(new Error('Cannot connect to Docker daemon'));
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
await expect(dc.getTopologyData([], false)).rejects.toThrow('Cannot connect to Docker daemon');
});
it('propagates Docker API errors from listContainers', async () => {
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listContainers.mockRejectedValue(new Error('Docker daemon unavailable'));
const dc = DockerController.getInstance(1);
await expect(dc.getTopologyData([], false)).rejects.toThrow('Docker daemon unavailable');
});
it('throws when Docker returns HTML string for networks (wrong port)', async () => {
mockDocker.listNetworks.mockResolvedValue('<html>Not Docker</html>');
mockDocker.listContainers.mockResolvedValue([]);
const dc = DockerController.getInstance(1);
await expect(dc.getTopologyData([], false)).rejects.toThrow('Invalid response from Docker API');
});
it('throws when Docker returns HTML string for containers (wrong port)', async () => {
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listContainers.mockResolvedValue('<html>Not Docker</html>');
const dc = DockerController.getInstance(1);
await expect(dc.getTopologyData([], false)).rejects.toThrow('Invalid response from Docker API');
});
});