Files
sencho/backend/src/__tests__/self-identity-service.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

302 lines
12 KiB
TypeScript

/**
* Unit tests for SelfIdentityService. Covers happy-path inspect, missing
* HOSTNAME (dev mode), Dockerode 404 (also dev mode), and the isOwn* matchers.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Hoisted mocks ──────────────────────────────────────────────────────
const { mockContainer, mockDocker } = vi.hoisted(() => {
const mockContainer = {
inspect: vi.fn(),
};
const mockDocker = {
getContainer: vi.fn(() => mockContainer),
listImages: vi.fn().mockResolvedValue([]),
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
listNetworks: vi.fn().mockResolvedValue([]),
listContainers: vi.fn().mockResolvedValue([]),
};
return { mockContainer, mockDocker };
});
vi.mock('../services/NodeRegistry', () => ({
NodeRegistry: {
getInstance: () => ({
getDocker: () => mockDocker,
getDefaultNodeId: () => 1,
}),
},
}));
vi.mock('child_process', () => ({ exec: vi.fn(), execFile: vi.fn() }));
vi.mock('util', () => ({ promisify: () => vi.fn() }));
import SelfIdentityService from '../services/SelfIdentityService';
const FULL_CONTAINER_ID = 'a'.repeat(64);
const FULL_IMAGE_ID_HEX = 'b'.repeat(64);
const FULL_NETWORK_ID = 'c'.repeat(64);
const originalHostname = process.env.HOSTNAME;
beforeEach(() => {
vi.clearAllMocks();
// mockReset (not clearAllMocks) drops implementations set by prior
// mockResolvedValue/mockResolvedValueOnce calls, otherwise resolutions
// leak across tests.
mockContainer.inspect.mockReset();
SelfIdentityService.getInstance().resetForTesting();
});
afterEach(() => {
// restoreAllMocks() puts vi.spyOn'd statics (readContainerIdFromCgroup,
// console.warn) back to their real implementations so the next test
// exercises real code paths.
vi.restoreAllMocks();
if (originalHostname === undefined) delete process.env.HOSTNAME;
else process.env.HOSTNAME = originalHostname;
});
describe('SelfIdentityService.initialize', () => {
it('populates identity when HOSTNAME is set and inspect resolves', async () => {
process.env.HOSTNAME = 'sencho-1';
mockContainer.inspect.mockResolvedValue({
Id: FULL_CONTAINER_ID,
Name: '/sencho',
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
Config: { Labels: { 'com.docker.compose.project': 'sencho' } },
NetworkSettings: {
Networks: {
sencho_mesh: { NetworkID: FULL_NETWORK_ID },
},
},
Mounts: [
{ Type: 'volume', Name: 'sencho_data' },
{ Type: 'bind', Source: '/host/path', Destination: '/app/compose' },
],
});
const svc = SelfIdentityService.getInstance();
await svc.initialize();
const id = svc.getIdentity();
expect(id.containerId).toBe(FULL_CONTAINER_ID);
expect(id.containerName).toBe('sencho');
expect(id.composeProjectName).toBe('sencho');
expect(id.imageId).toBe(FULL_IMAGE_ID_HEX);
expect(id.networkNames).toEqual(['sencho_mesh']);
expect(id.volumeNames).toEqual(['sencho_data']);
});
it('shares an in-flight initialization across concurrent callers', async () => {
process.env.HOSTNAME = 'sencho-1';
let resolveInspect: (value: unknown) => void = () => {};
mockContainer.inspect.mockReturnValue(new Promise(resolve => {
resolveInspect = resolve;
}));
const svc = SelfIdentityService.getInstance();
const first = svc.initialize();
const second = svc.initialize();
expect(mockContainer.inspect).toHaveBeenCalledTimes(1);
resolveInspect({
Id: FULL_CONTAINER_ID,
Name: '/sencho',
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
Config: { Labels: { 'com.docker.compose.project': 'sencho' } },
NetworkSettings: { Networks: { sencho_mesh: { NetworkID: FULL_NETWORK_ID } } },
Mounts: [{ Type: 'volume', Name: 'sencho_data' }],
});
await Promise.all([first, second]);
expect(svc.getIdentity().containerId).toBe(FULL_CONTAINER_ID);
expect(svc.getIdentity().composeProjectName).toBe('sencho');
});
it('stays empty when HOSTNAME is unset (dev mode)', async () => {
delete process.env.HOSTNAME;
const svc = SelfIdentityService.getInstance();
await svc.initialize();
expect(svc.getIdentity().containerId).toBeNull();
expect(svc.getIdentity().imageId).toBeNull();
expect(mockContainer.inspect).not.toHaveBeenCalled();
});
it('stays empty when HOSTNAME inspect 404s and cgroup probe has no container ID (dev mode)', async () => {
process.env.HOSTNAME = 'my-laptop';
const err: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 });
mockContainer.inspect.mockRejectedValue(err);
vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(null);
const svc = SelfIdentityService.getInstance();
await svc.initialize();
expect(svc.getIdentity().containerId).toBeNull();
});
it('falls back to /proc/self/cgroup when HOSTNAME inspect 404s (custom --hostname)', async () => {
process.env.HOSTNAME = 'my-custom-name';
const err404: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 });
// First call (HOSTNAME) 404s; second call (cgroup-resolved ID) succeeds.
mockContainer.inspect
.mockRejectedValueOnce(err404)
.mockResolvedValueOnce({
Id: FULL_CONTAINER_ID,
Name: '/sencho',
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
NetworkSettings: { Networks: { sencho_mesh: { NetworkID: FULL_NETWORK_ID } } },
Mounts: [{ Type: 'volume', Name: 'sencho_data' }],
});
vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(FULL_CONTAINER_ID);
const svc = SelfIdentityService.getInstance();
await svc.initialize();
expect(svc.getIdentity().containerId).toBe(FULL_CONTAINER_ID);
expect(mockDocker.getContainer).toHaveBeenNthCalledWith(1, 'my-custom-name');
expect(mockDocker.getContainer).toHaveBeenNthCalledWith(2, FULL_CONTAINER_ID);
});
it('stays empty when HOSTNAME 404s and cgroup probe resolves but inspect 404s on that ID too', async () => {
process.env.HOSTNAME = 'sencho-1';
const err404: Error & { statusCode?: number } = Object.assign(new Error('no such container'), { statusCode: 404 });
mockContainer.inspect.mockRejectedValue(err404);
vi.spyOn(SelfIdentityService, 'readContainerIdFromCgroup').mockResolvedValue(FULL_CONTAINER_ID);
const svc = SelfIdentityService.getInstance();
await svc.initialize();
expect(svc.getIdentity().containerId).toBeNull();
});
it('stays empty and logs on non-404 inspect failure', async () => {
process.env.HOSTNAME = 'sencho-1';
mockContainer.inspect.mockRejectedValue(new Error('docker daemon unreachable'));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const svc = SelfIdentityService.getInstance();
await svc.initialize();
expect(svc.getIdentity().containerId).toBeNull();
expect(warnSpy).toHaveBeenCalled();
});
it('parses /proc/self/cgroup formats (cgroupv1 docker, cgroupv2 docker, podman libpod)', async () => {
const tmp = await import('os');
const fsp = await import('fs/promises');
const path = await import('path');
const dir = await fsp.mkdtemp(path.join(tmp.tmpdir(), 'sencho-cgroup-'));
const cgroupV1 = path.join(dir, 'cgv1');
await fsp.writeFile(cgroupV1, `12:cpuset:/docker/${FULL_CONTAINER_ID}\n11:memory:/docker/${FULL_CONTAINER_ID}\n`);
expect(await SelfIdentityService.readContainerIdFromCgroup(cgroupV1)).toBe(FULL_CONTAINER_ID);
const cgroupV2 = path.join(dir, 'cgv2');
await fsp.writeFile(cgroupV2, `0::/system.slice/docker-${FULL_CONTAINER_ID}.scope\n`);
expect(await SelfIdentityService.readContainerIdFromCgroup(cgroupV2)).toBe(FULL_CONTAINER_ID);
const podman = path.join(dir, 'podman');
await fsp.writeFile(podman, `0::/user.slice/user-1000.slice/libpod-${FULL_CONTAINER_ID}.scope\n`);
expect(await SelfIdentityService.readContainerIdFromCgroup(podman)).toBe(FULL_CONTAINER_ID);
const noMatch = path.join(dir, 'empty');
await fsp.writeFile(noMatch, '0::/system.slice/sshd.service\n');
expect(await SelfIdentityService.readContainerIdFromCgroup(noMatch)).toBeNull();
expect(await SelfIdentityService.readContainerIdFromCgroup(path.join(dir, 'does-not-exist'))).toBeNull();
await fsp.rm(dir, { recursive: true, force: true });
});
it('is idempotent: re-initialize is a no-op', async () => {
process.env.HOSTNAME = 'sencho-1';
mockContainer.inspect.mockResolvedValue({
Id: FULL_CONTAINER_ID,
Name: '/sencho',
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
NetworkSettings: { Networks: {} },
Mounts: [],
});
const svc = SelfIdentityService.getInstance();
await svc.initialize();
await svc.initialize();
expect(mockContainer.inspect).toHaveBeenCalledTimes(1);
});
});
describe('SelfIdentityService matchers', () => {
beforeEach(async () => {
process.env.HOSTNAME = 'sencho-1';
mockContainer.inspect.mockResolvedValue({
Id: FULL_CONTAINER_ID,
Name: '/sencho',
Image: 'sha256:' + FULL_IMAGE_ID_HEX,
NetworkSettings: {
Networks: { sencho_mesh: { NetworkID: FULL_NETWORK_ID } },
},
Mounts: [{ Type: 'volume', Name: 'sencho_data' }],
});
SelfIdentityService.getInstance().resetForTesting();
await SelfIdentityService.getInstance().initialize();
});
it('isOwnImage matches full sha256 ref, hex-only, and short prefix', () => {
const svc = SelfIdentityService.getInstance();
expect(svc.isOwnImage('sha256:' + FULL_IMAGE_ID_HEX)).toBe(true);
expect(svc.isOwnImage(FULL_IMAGE_ID_HEX)).toBe(true);
expect(svc.isOwnImage(FULL_IMAGE_ID_HEX.slice(0, 12))).toBe(true);
expect(svc.isOwnImage('d'.repeat(64))).toBe(false);
});
it('isOwnImage does not match repo:tag strings (callers should pass IDs)', () => {
const svc = SelfIdentityService.getInstance();
expect(svc.isOwnImage('ghcr.io/studio-saelix/sencho:latest')).toBe(false);
expect(svc.isOwnImage('saelix/sencho:1.0')).toBe(false);
});
it('isOwnNetwork matches by ID and by name', () => {
const svc = SelfIdentityService.getInstance();
expect(svc.isOwnNetwork(FULL_NETWORK_ID)).toBe(true);
expect(svc.isOwnNetwork(FULL_NETWORK_ID.slice(0, 12))).toBe(true);
expect(svc.isOwnNetwork('sencho_mesh')).toBe(true);
expect(svc.isOwnNetwork('bridge')).toBe(false);
});
it('isOwnNetwork does not falsely match a non-hex network name that overlaps a cached ID prefix', () => {
// The cached network ID is 64 chars of 'c'. A network named "ccc" is NOT
// a hex ID input (length below 12), so prefix matching must not fire.
const svc = SelfIdentityService.getInstance();
expect(svc.isOwnNetwork('ccc')).toBe(false);
expect(svc.isOwnNetwork('cc')).toBe(false);
});
it('isOwnVolume matches by name only', () => {
const svc = SelfIdentityService.getInstance();
expect(svc.isOwnVolume('sencho_data')).toBe(true);
expect(svc.isOwnVolume('other_volume')).toBe(false);
});
it('isOwnContainer matches full, short prefix, and name', () => {
const svc = SelfIdentityService.getInstance();
expect(svc.isOwnContainer(FULL_CONTAINER_ID)).toBe(true);
expect(svc.isOwnContainer(FULL_CONTAINER_ID.slice(0, 12))).toBe(true);
expect(svc.isOwnContainer('sencho')).toBe(true);
expect(svc.isOwnContainer('e'.repeat(64))).toBe(false);
});
});
describe('SelfIdentityService matchers when empty (dev mode)', () => {
beforeEach(async () => {
delete process.env.HOSTNAME;
SelfIdentityService.getInstance().resetForTesting();
await SelfIdentityService.getInstance().initialize();
});
it('returns false for every isOwn* call so today\'s behavior is preserved', () => {
const svc = SelfIdentityService.getInstance();
expect(svc.isOwnImage('sha256:' + FULL_IMAGE_ID_HEX)).toBe(false);
expect(svc.isOwnNetwork('sencho_mesh')).toBe(false);
expect(svc.isOwnVolume('sencho_data')).toBe(false);
expect(svc.isOwnContainer(FULL_CONTAINER_ID)).toBe(false);
});
});