mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
f7f3afe05a
* feat(stacks): move discovered import candidates into place The guided import flow previewed loose and nested compose files but could not act on them, so it only told the user where to move files by hand. Add an opt-in "Move into place" action: relocate a loose-root file into its own <name>/ subfolder, or promote a nested stack directory one level up, so Sencho's filesystem discovery lists it as a stack. The file stays a plain compose file on disk; nothing is captured into a store. The move re-derives the candidate from a fresh scan and matches by location, validates the destination name and containment, resolves symlinks before the rename, and never overwrites an existing stack. Backend and frontend both gate the action on stack:create. Also fix the rescan flicker: scan results now stay on screen while a rescan runs (only the Rescan button shows progress) instead of the whole panel collapsing to a spinner, and an empty rescan surfaces a toast. * fix(stacks): make import-move destination creation atomic The loose-root branch created the destination directory with mkdir recursive after an access() existence precheck. If the destination appeared between the check and the create, recursive accepted the existing directory and the following rename could overwrite a same-named compose file inside it, so the intended conflict response never fired. Use a non-recursive mkdir so a destination that already exists raises a conflict instead of being merged into. Add a regression test that forces the precheck to miss and asserts the existing file is left intact. * fix(stacks): only offer not-yet-imported compose files in the import tab The import tab listed every compose file in the compose directory, including ones that are already stacks (a top-level subfolder with a compose file), which just duplicated the sidebar. The scan now skips those and surfaces only files that still need importing: a compose file loose at the compose-dir root, or one nested a folder too deep. Also harden the move-into-place write path that turns a stray file into a stack: a failed rename after the destination folder is created now rolls back the empty folder, so a retry is not blocked by a false "already exists" conflict, and the move switches on an exhaustive set of placements so a new one cannot silently take the wrong branch. The sidebar refreshes after a move so the imported stack appears right away, and the docs describe import as relocating a file, not capturing running containers. * fix(stacks): reject a nested import whose compose file escapes the base The move-into-place path for a nested compose file validated only the parent directory's real path, not the compose file itself. A directory that is real and inside the compose base but holds a compose file symlinked outside the base would survive the directory move and become a stack whose compose file still points outside the base, which the editor read path would then follow. The move now resolves the compose file too and refuses it unless it stays inside the resolved source directory, matching the loose-root check and the scan's preview reader. * fix(stacks): satisfy CodeQL path and log analysis in import-move The import-move write path built its destination directory from the user-provided stack name through resolveStackDir, whose containment barrier is wrapped in a helper that static analysis does not credit, so every filesystem sink on the destination was flagged as path injection. Re-establish the resolve-against-the-safe-base plus startsWith barrier inline at the sinks, matching the read and backup paths in the same file, and route the relocated file path through the same check. The name is already restricted to an alphanumeric, hyphen, and underscore allowlist, so the containment can never actually fail; this only makes the existing safety visible to the analyzer. Also log the move route's error as a sanitized message rather than the raw error object, so a name embedded in an error message cannot forge log lines.
166 lines
7.3 KiB
TypeScript
166 lines
7.3 KiB
TypeScript
/**
|
|
* Tests for FileSystemService.findImportCandidates: the read-only compose-dir
|
|
* walk behind the guided import scan. Uses a real temp directory so the nesting
|
|
* and placement-status logic is exercised against the actual filesystem.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
const { tmpRoot } = vi.hoisted(() => {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const nodeOs = require('os');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const nodePath = require('path');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const nodeFs = require('fs');
|
|
const tmpRoot: string = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'sencho-import-'));
|
|
return { tmpRoot };
|
|
});
|
|
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getComposeDir: () => tmpRoot,
|
|
getDefaultNodeId: () => 1,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
import { FileSystemService } from '../services/FileSystemService';
|
|
|
|
const COMPOSE = 'services:\n app:\n image: nginx:1.27\n';
|
|
|
|
describe('FileSystemService.findImportCandidates', () => {
|
|
beforeAll(() => {
|
|
// Already a stack: top-level subdir with a compose file.
|
|
fs.mkdirSync(path.join(tmpRoot, 'immich'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpRoot, 'immich', 'compose.yaml'), COMPOSE);
|
|
// Loose at the root: will not auto-register.
|
|
fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE);
|
|
// One directory too deep: apps/ has no compose, apps/vault/ does.
|
|
fs.mkdirSync(path.join(tmpRoot, 'apps', 'vault'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpRoot, 'apps', 'vault', 'compose.yaml'), COMPOSE);
|
|
// A directory with no compose file at all: ignored.
|
|
fs.mkdirSync(path.join(tmpRoot, 'notes'), { recursive: true });
|
|
fs.writeFileSync(path.join(tmpRoot, 'notes', 'README.md'), '# notes');
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
it('surfaces loose-root and nested files and skips directories already a stack', async () => {
|
|
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
|
|
|
// immich is a top-level subdir with a compose file, so it is already a stack
|
|
// (it shows in the sidebar) and is not offered as an import candidate.
|
|
expect(candidates.some((c) => c.name === 'immich')).toBe(false);
|
|
|
|
const loose = candidates.find((c) => c.status === 'loose-root');
|
|
expect(loose).toMatchObject({ name: '', composeFile: 'docker-compose.yml', location: 'docker-compose.yml' });
|
|
expect(loose?.content).toContain('services:');
|
|
|
|
const nested = candidates.find((c) => c.status === 'nested');
|
|
expect(nested).toMatchObject({ name: 'vault', composeFile: 'compose.yaml', location: 'apps/vault/compose.yaml' });
|
|
|
|
// The directory with only a README produced no candidate.
|
|
expect(candidates.some((c) => c.name === 'notes')).toBe(false);
|
|
expect(candidates).toHaveLength(2);
|
|
});
|
|
|
|
it('flags oversized compose files instead of reading them', async () => {
|
|
// Nested under a wrapper with no top-level compose, so the scan descends and
|
|
// surfaces the inner file (a top-level dir with a compose file is a stack and
|
|
// would be skipped).
|
|
const bigDir = path.join(tmpRoot, 'oversized-wrap', 'big');
|
|
fs.mkdirSync(bigDir, { recursive: true });
|
|
fs.writeFileSync(path.join(bigDir, 'compose.yaml'), 'x'.repeat(1_048_577));
|
|
try {
|
|
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
|
const big = candidates.find((c) => c.name === 'big');
|
|
expect(big?.status).toBe('nested');
|
|
expect(big?.oversized).toBe(true);
|
|
expect(big?.content).toBeNull();
|
|
} finally {
|
|
fs.rmSync(path.join(tmpRoot, 'oversized-wrap'), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('skips a non-regular file (a directory named compose.yaml) without reading it', async () => {
|
|
// A directory named compose.yaml passes the access() probe; the isFile()
|
|
// guard means it is reported as unreadable rather than read as content.
|
|
// Nested under a wrapper so it surfaces as a candidate at all.
|
|
const weirdDir = path.join(tmpRoot, 'weird-wrap', 'weird');
|
|
fs.mkdirSync(path.join(weirdDir, 'compose.yaml'), { recursive: true });
|
|
try {
|
|
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
|
const weird = candidates.find((c) => c.name === 'weird');
|
|
expect(weird).toBeDefined();
|
|
expect(weird?.content).toBeNull();
|
|
expect(weird?.oversized).toBe(false);
|
|
} finally {
|
|
fs.rmSync(path.join(tmpRoot, 'weird-wrap'), { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('does not read a compose file that symlinks outside the compose directory', async () => {
|
|
// Sibling of the temp compose root, i.e. outside the base dir.
|
|
const outside = path.join(path.dirname(tmpRoot), `sencho-outside-${Date.now()}.yaml`);
|
|
fs.writeFileSync(outside, COMPOSE);
|
|
// Nested under a wrapper so the symlinked compose file surfaces as a candidate.
|
|
const escDir = path.join(tmpRoot, 'escape-wrap', 'escape');
|
|
fs.mkdirSync(escDir, { recursive: true });
|
|
let linked = true;
|
|
try {
|
|
fs.symlinkSync(outside, path.join(escDir, 'compose.yaml'));
|
|
} catch {
|
|
// Creating symlinks needs privilege on some platforms; the assertion below
|
|
// runs for real on the Linux CI runners.
|
|
linked = false;
|
|
}
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
try {
|
|
if (!linked) return;
|
|
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
|
const esc = candidates.find((c) => c.name === 'escape');
|
|
expect(esc).toBeDefined();
|
|
expect(esc?.content).toBeNull();
|
|
expect(warnSpy).toHaveBeenCalled();
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
fs.rmSync(path.join(tmpRoot, 'escape-wrap'), { recursive: true, force: true });
|
|
fs.rmSync(outside, { force: true });
|
|
}
|
|
});
|
|
|
|
it('skips a directory that is already a stack and does not descend into it', async () => {
|
|
// A directory that is already a stack (top-level compose) and also has a
|
|
// compose file one level deeper yields no candidates: it is skipped as an
|
|
// existing stack, and the scan does not descend into it to surface the child.
|
|
const dir = path.join(tmpRoot, 'both');
|
|
fs.mkdirSync(path.join(dir, 'sub'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE);
|
|
fs.writeFileSync(path.join(dir, 'sub', 'compose.yaml'), COMPOSE);
|
|
try {
|
|
const candidates = await FileSystemService.getInstance().findImportCandidates();
|
|
const fromBoth = candidates.filter((c) => c.location.startsWith('both/'));
|
|
expect(fromBoth).toHaveLength(0);
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('truncates at maxCandidates', async () => {
|
|
// Base fixtures yield 2 candidates (loose-root + nested); add a third loose
|
|
// file so a cap of 2 actually truncates rather than coincidentally matching.
|
|
fs.writeFileSync(path.join(tmpRoot, 'compose.yaml'), COMPOSE);
|
|
try {
|
|
const candidates = await FileSystemService.getInstance().findImportCandidates(2);
|
|
expect(candidates).toHaveLength(2);
|
|
} finally {
|
|
fs.rmSync(path.join(tmpRoot, 'compose.yaml'), { force: true });
|
|
}
|
|
});
|
|
});
|