mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
* fix(tests): repair 6 CI failures introduced by recent PR merges
ObsidianRegistry.test.ts + ShadowVaultBootstrap.test.ts:
Wrap `import * as fs from "fs"` in `vi.mock("fs", () => ({ ...actual }))`
so vitest creates a configurable namespace. The raw ESM `fs` namespace
is non-configurable in Node + Vitest 4, which made the existing
`vi.spyOn(fs, "writeFileSync"|"renameSync"|"symlinkSync")` calls throw
`Cannot redefine property` at test setup time. The mock re-exports
`actual` so all non-spied fs calls keep their real behaviour.
ShadowVaultBootstrap.test.ts (collectPendingPluginSuggestions):
Two tests asserted `data.pendingPluginSuggestions` equal `[]` when
source `community-plugins.json` is invalid/non-array. The implementation
(ShadowVaultBootstrap.ts:121-126) deliberately omits the key entirely
when `pending.length === 0` -- the absent key is the "no suggestions"
signal that ShadowStartupCoordinator relies on for its no-op fast path.
Aligned the test with the implementation contract.
ConflictResolver.test.ts:58:
`swapClient` test asserted `writeBinary` was called with a third
`undefined` arg. Other tests in the file (e.g. line 91) and the
source (ConflictResolver.ts:88,92,129) confirm the call passes only
two args. Removed the spurious trailing `undefined`.
* chore: bump version to 1.0.36
* ci: bump Node heap to 6 GiB to stop intermittent test-job OOMs
Vitest 4's default `forks` pool plus jsdom on every test file pushed
peak RSS just past Node's default ~4 GiB old-space ceiling once the
suite grew to ~70 files / ~1000 tests. The CI logs showed `FATAL ERROR:
Reached heap limit Allocation failed - JavaScript heap out of memory`
during ubuntu-latest, ubuntu-node22, macos-latest, and windows-latest
test jobs on the last several main-branch runs (it predates this PR;
the earlier 6 unit-test failures were just the first symptom).
Apply the same env to release.yml's test gate so a release tag push
does not flake on the same wall.
GitHub-hosted runners advertise 7+ GiB, so 6 GiB is safe headroom
without crowding the kernel/page-cache budget.
* fix(tests): SftpClient unit tests caused worker OOM via infinite BFS
Root cause of the CI test-job crashes:
tests/SftpClient.unit.test.ts > rmdir() > recursive: removes all
files and subdirs before root
mocked sftp.readdir with a path-agnostic `mockImplementation` that
returned `[file.md, sub (dir)]` for every call. SftpClient.rmdir's
recursive branch calls listRecursive, whose BFS then re-discovered
`sub` inside `/vault/sub`, then `/vault/sub/sub`, then
`/vault/sub/sub/sub`, ... -- growing the visited Set, the queue, and
the result array without bound until the worker hit
`FATAL ERROR: Reached heap limit`. Replaced with a path-aware mock so
only `/vault` has children; the BFS terminates after one level.
Also fixed `listRecursive() returns files from nested subdirectories
via BFS`: the assertion expected `['deep.md', ...]` but
`SftpClient.listRecursive` deliberately returns the full path under
root as relativePath (`sub/deep.md`), not the basename -- every
downstream consumer needs the unambiguous form.
Plus pass `--max-old-space-size=6144` to vitest workers via
`test.execArgv` so future legitimately-large suites have headroom
without needing per-job NODE_OPTIONS overrides. (In Vitest 4 the
top-level `poolOptions` was removed; both `pool` and `execArgv` are
now top-level keys inside `test:`.)
817 lines
31 KiB
TypeScript
817 lines
31 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { SFTPWrapper, Stats } from 'ssh2';
|
|
import { SftpClient } from '../src/ssh/SftpClient';
|
|
import type { AuthResolver } from '../src/ssh/AuthResolver';
|
|
import type { HostKeyStore } from '../src/ssh/HostKeyStore';
|
|
|
|
// ─── fake helpers ────────────────────────────────────────────────────────────
|
|
|
|
/** Minimal fake ssh2 Stats that satisfies SftpClient's toRemoteStat() helper. */
|
|
function fakeStats(opts: {
|
|
dir?: boolean;
|
|
symlink?: boolean;
|
|
mtime?: number;
|
|
size?: number;
|
|
mode?: number;
|
|
} = {}): Stats {
|
|
return {
|
|
isDirectory: () => opts.dir ?? false,
|
|
isFile: () => !(opts.dir ?? false) && !(opts.symlink ?? false),
|
|
isSymbolicLink: () => opts.symlink ?? false,
|
|
mtime: opts.mtime ?? 1000,
|
|
size: opts.size ?? 100,
|
|
mode: opts.mode ?? 0o644,
|
|
} as unknown as Stats;
|
|
}
|
|
|
|
/** Create a vi.fn()-backed fake SFTPWrapper. */
|
|
function makeSftp() {
|
|
return {
|
|
stat: vi.fn(),
|
|
readdir: vi.fn(),
|
|
readFile: vi.fn(),
|
|
writeFile: vi.fn(),
|
|
rename: vi.fn(),
|
|
unlink: vi.fn(),
|
|
mkdir: vi.fn(),
|
|
rmdir: vi.fn(),
|
|
fastPut: vi.fn(),
|
|
// posix-rename extension fields — absent by default
|
|
_extensions: undefined as Record<string, unknown> | undefined,
|
|
ext_openssh_rename: undefined as ((...a: unknown[]) => void) | undefined,
|
|
} as unknown as SFTPWrapper & {
|
|
stat: ReturnType<typeof vi.fn>;
|
|
readdir: ReturnType<typeof vi.fn>;
|
|
readFile: ReturnType<typeof vi.fn>;
|
|
writeFile: ReturnType<typeof vi.fn>;
|
|
rename: ReturnType<typeof vi.fn>;
|
|
unlink: ReturnType<typeof vi.fn>;
|
|
mkdir: ReturnType<typeof vi.fn>;
|
|
rmdir: ReturnType<typeof vi.fn>;
|
|
fastPut: ReturnType<typeof vi.fn>;
|
|
_extensions: Record<string, unknown> | undefined;
|
|
ext_openssh_rename: ((...a: unknown[]) => void) | undefined;
|
|
};
|
|
}
|
|
|
|
/** Minimal fake ssh2 Client for testing exec / openUnixStream etc. */
|
|
function makeFakeClient(opts: {
|
|
execResult?: { stdout: string; stderr: string; exitCode: number };
|
|
execErr?: Error;
|
|
streamErr?: Error;
|
|
} = {}) {
|
|
const endSpy = vi.fn();
|
|
|
|
const client = {
|
|
exec: vi.fn((
|
|
_cmd: string,
|
|
cb: (err: Error | null, stream: any) => void,
|
|
) => {
|
|
if (opts.execErr) { cb(opts.execErr, null!); return; }
|
|
|
|
const listeners: Record<string, Array<(...a: unknown[]) => void>> = {};
|
|
const stderrListeners: Record<string, Array<(...a: unknown[]) => void>> = {};
|
|
|
|
const stream = {
|
|
on(event: string, fn: (...a: unknown[]) => void) {
|
|
(listeners[event] ??= []).push(fn);
|
|
return stream;
|
|
},
|
|
stderr: {
|
|
on(event: string, fn: (...a: unknown[]) => void) {
|
|
(stderrListeners[event] ??= []).push(fn);
|
|
return this;
|
|
},
|
|
},
|
|
};
|
|
|
|
cb(null, stream);
|
|
|
|
// Fire events after all .on() registrations have completed.
|
|
Promise.resolve().then(() => {
|
|
if (opts.streamErr) {
|
|
for (const fn of listeners['error'] ?? []) fn(opts.streamErr);
|
|
return;
|
|
}
|
|
const { stdout = '', stderr = '', exitCode = 0 } = opts.execResult ?? {};
|
|
if (stdout) for (const fn of listeners['data'] ?? []) fn(Buffer.from(stdout));
|
|
if (stderr) for (const fn of stderrListeners['data'] ?? []) fn(Buffer.from(stderr));
|
|
for (const fn of listeners['exit'] ?? []) fn(exitCode);
|
|
for (const fn of listeners['close'] ?? []) fn();
|
|
});
|
|
}),
|
|
openssh_forwardOutStreamLocal: vi.fn(),
|
|
shell: vi.fn(),
|
|
end: endSpy,
|
|
};
|
|
|
|
return { client, endSpy };
|
|
}
|
|
|
|
/** Build a fresh SftpClient with no-op auth/hostKey stubs. */
|
|
function makeSut() {
|
|
const authResolver = {} as unknown as AuthResolver;
|
|
const hostKeyStore = {} as unknown as HostKeyStore;
|
|
return new SftpClient(authResolver, hostKeyStore);
|
|
}
|
|
|
|
/** Inject fake sftp + client so tests can exercise post-connect methods. */
|
|
function wire(
|
|
sut: SftpClient,
|
|
sftp: unknown,
|
|
client: unknown = { end: vi.fn() },
|
|
) {
|
|
(sut as any).sftp = sftp;
|
|
(sut as any).client = client;
|
|
}
|
|
|
|
// ─── lifecycle ────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient — lifecycle (no connection needed)', () => {
|
|
it('isAlive() returns false on a fresh instance', () => {
|
|
expect(makeSut().isAlive()).toBe(false);
|
|
});
|
|
|
|
it('isAlive() returns true after client and sftp are wired', () => {
|
|
const sut = makeSut();
|
|
wire(sut, makeSftp());
|
|
expect(sut.isAlive()).toBe(true);
|
|
});
|
|
|
|
it('getProfile() returns null on a fresh instance', () => {
|
|
expect(makeSut().getProfile()).toBeNull();
|
|
});
|
|
|
|
it('getProfile() returns the injected profile object', () => {
|
|
const sut = makeSut();
|
|
const profile = { host: 'srv' } as any;
|
|
(sut as any).profile = profile;
|
|
expect(sut.getProfile()).toBe(profile);
|
|
});
|
|
|
|
it('disconnect() on a fresh instance resolves without throwing', async () => {
|
|
await expect(makeSut().disconnect()).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('disconnect() calls client.end() when a client is wired', async () => {
|
|
const sut = makeSut();
|
|
const { client, endSpy } = makeFakeClient();
|
|
wire(sut, makeSftp(), client);
|
|
await sut.disconnect();
|
|
expect(endSpy).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('disconnect() does not trigger onClose listeners (intentional disconnect)', async () => {
|
|
const sut = makeSut();
|
|
const closeSpy = vi.fn();
|
|
sut.onClose(closeSpy);
|
|
wire(sut, makeSftp());
|
|
await sut.disconnect();
|
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('disconnect() survives client.end() throwing', async () => {
|
|
const sut = makeSut();
|
|
const badClient = { end: () => { throw new Error('socket closed'); } };
|
|
wire(sut, makeSftp(), badClient);
|
|
await expect(sut.disconnect()).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── onClose ─────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient — onClose listener management', () => {
|
|
it('registers a listener and returns a disposer', () => {
|
|
const sut = makeSut();
|
|
const spy = vi.fn();
|
|
const off = sut.onClose(spy);
|
|
expect(typeof off).toBe('function');
|
|
expect((sut as any).closeListeners).toContain(spy);
|
|
});
|
|
|
|
it('disposer removes only the targeted listener', () => {
|
|
const sut = makeSut();
|
|
const spy1 = vi.fn();
|
|
const spy2 = vi.fn();
|
|
const off1 = sut.onClose(spy1);
|
|
sut.onClose(spy2);
|
|
off1();
|
|
expect((sut as any).closeListeners).not.toContain(spy1);
|
|
expect((sut as any).closeListeners).toContain(spy2);
|
|
});
|
|
});
|
|
|
|
// ─── requireSftp / requireClient guards ──────────────────────────────────────
|
|
|
|
describe('SftpClient — not-connected guards', () => {
|
|
it('stat() throws "not connected" when sftp is null', async () => {
|
|
await expect(makeSut().stat('any')).rejects.toThrow('not connected');
|
|
});
|
|
|
|
it('exec() throws "not connected" when client is null', async () => {
|
|
const sut = makeSut();
|
|
(sut as any).sftp = makeSftp(); // sftp present but client absent
|
|
await expect(sut.exec('ls')).rejects.toThrow('not connected');
|
|
});
|
|
|
|
it('openUnixStream() throws "not connected" when client is null', async () => {
|
|
await expect(makeSut().openUnixStream('/run/x.sock')).rejects.toThrow('not connected');
|
|
});
|
|
});
|
|
|
|
// ─── stat ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.stat()', () => {
|
|
it('resolves with a correct RemoteStat on success (mtime is multiplied by 1000)', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: null, s: Stats) => void) =>
|
|
cb(null, fakeStats({ dir: false, mtime: 2000, size: 42, mode: 0o600 })),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
const result = await sut.stat('remote/file.md');
|
|
expect(result).toEqual({
|
|
isFile: true,
|
|
isDirectory: false,
|
|
isSymbolicLink: false,
|
|
mtime: 2_000_000,
|
|
size: 42,
|
|
mode: 0o600,
|
|
});
|
|
});
|
|
|
|
it('rejects when sftp.stat calls back with an error', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('ENOENT')),
|
|
);
|
|
wire(sut, sftp);
|
|
await expect(sut.stat('missing')).rejects.toThrow('ENOENT');
|
|
});
|
|
});
|
|
|
|
// ─── exists ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.exists()', () => {
|
|
it('returns true when stat succeeds', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: null, s: Stats) => void) =>
|
|
cb(null, fakeStats()),
|
|
);
|
|
wire(sut, sftp);
|
|
expect(await sut.exists('file')).toBe(true);
|
|
});
|
|
|
|
it('returns false when stat throws', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('ENOENT')),
|
|
);
|
|
wire(sut, sftp);
|
|
expect(await sut.exists('missing')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── list ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.list()', () => {
|
|
it('returns mapped entries, skipping . and ..', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.readdir.mockImplementation((_p: string, cb: (e: null, list: any[]) => void) =>
|
|
cb(null, [
|
|
{ filename: '.', attrs: fakeStats({ dir: true }) },
|
|
{ filename: '..', attrs: fakeStats({ dir: true }) },
|
|
{ filename: 'notes', attrs: fakeStats({ dir: true, mtime: 5000, size: 0 }) },
|
|
{ filename: 'file.md', attrs: fakeStats({ mtime: 3000, size: 88 }) },
|
|
]),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
const list = await sut.list('/vault');
|
|
expect(list.map(e => e.name)).toEqual(['notes', 'file.md']);
|
|
expect(list[0].isDirectory).toBe(true);
|
|
expect(list[1].isFile).toBe(true);
|
|
expect(list[1].mtime).toBe(3_000_000);
|
|
});
|
|
|
|
it('rejects when readdir calls back with an error', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.readdir.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('EACCES')),
|
|
);
|
|
wire(sut, sftp);
|
|
await expect(sut.list('/protected')).rejects.toThrow('EACCES');
|
|
});
|
|
});
|
|
|
|
// ─── readBinary / readText ───────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.readBinary() / readText()', () => {
|
|
it('readBinary resolves with the buffer', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
const data = Buffer.from('hello');
|
|
sftp.readFile.mockImplementation((_p: string, cb: (e: null, b: Buffer) => void) =>
|
|
cb(null, data),
|
|
);
|
|
wire(sut, sftp);
|
|
expect(await sut.readBinary('note.md')).toBe(data);
|
|
});
|
|
|
|
it('readBinary rejects on error', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.readFile.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('SFTP: permission denied')),
|
|
);
|
|
wire(sut, sftp);
|
|
await expect(sut.readBinary('secret')).rejects.toThrow('SFTP: permission denied');
|
|
});
|
|
|
|
it('readText decodes the buffer with the given encoding', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.readFile.mockImplementation((_p: string, cb: (e: null, b: Buffer) => void) =>
|
|
cb(null, Buffer.from('café', 'utf8')),
|
|
);
|
|
wire(sut, sftp);
|
|
expect(await sut.readText('note.md', 'utf8')).toBe('café');
|
|
});
|
|
});
|
|
|
|
// ─── writeBinary (atomicWrite) ────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.writeBinary() — atomic write', () => {
|
|
it('success: calls writeFile on tmp path then rename to final path', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.writeFile.mockImplementation((_p: string, _d: Buffer, cb: (e: null) => void) =>
|
|
cb(null),
|
|
);
|
|
sftp.rename.mockImplementation((_s: string, _d: string, cb: (e: null) => void) =>
|
|
cb(null),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
const data = Buffer.from('content');
|
|
await sut.writeBinary('vault/note.md', data);
|
|
|
|
expect(sftp.writeFile).toHaveBeenCalledWith(
|
|
'vault/note.md.rsh_tmp', data, expect.any(Function),
|
|
);
|
|
expect(sftp.rename).toHaveBeenCalledWith(
|
|
'vault/note.md.rsh_tmp', 'vault/note.md', expect.any(Function),
|
|
);
|
|
});
|
|
|
|
it('writeFile failure: attempts cleanup and rethrows', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
const writeErr = new Error('ENOSPC');
|
|
sftp.writeFile.mockImplementation((_p: string, _d: Buffer, cb: (e: Error) => void) =>
|
|
cb(writeErr),
|
|
);
|
|
sftp.unlink.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
wire(sut, sftp);
|
|
|
|
await expect(sut.writeBinary('note.md', Buffer.from('x'))).rejects.toThrow('ENOSPC');
|
|
expect(sftp.unlink).toHaveBeenCalledWith('note.md.rsh_tmp', expect.any(Function));
|
|
});
|
|
|
|
it('rename failure: cleanup is attempted and original error rethrows', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
const renameErr = new Error('EXDEV');
|
|
sftp.writeFile.mockImplementation((_p: string, _d: Buffer, cb: (e: null) => void) =>
|
|
cb(null),
|
|
);
|
|
sftp.rename.mockImplementation((_s: string, _d: string, cb: (e: Error) => void) =>
|
|
cb(renameErr),
|
|
);
|
|
sftp.unlink.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
wire(sut, sftp);
|
|
|
|
await expect(sut.writeBinary('note.md', Buffer.from('x'))).rejects.toThrow('EXDEV');
|
|
expect(sftp.unlink).toHaveBeenCalled();
|
|
});
|
|
|
|
it('writeText encodes string and delegates to atomicWrite', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.writeFile.mockImplementation((_p: string, _d: Buffer, cb: (e: null) => void) =>
|
|
cb(null),
|
|
);
|
|
sftp.rename.mockImplementation((_s: string, _d: string, cb: (e: null) => void) =>
|
|
cb(null),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
await sut.writeText('note.md', 'hello', 'utf8');
|
|
const [, writtenBuf] = (sftp.writeFile as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Buffer];
|
|
expect(writtenBuf.toString('utf8')).toBe('hello');
|
|
});
|
|
});
|
|
|
|
// ─── rename ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.rename()', () => {
|
|
it('uses standard sftp.rename when posix-rename extension is absent', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.rename.mockImplementation((_s: string, _d: string, cb: (e: null) => void) =>
|
|
cb(null),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
await sut.rename('old.md', 'new.md');
|
|
expect(sftp.rename).toHaveBeenCalledWith('old.md', 'new.md', expect.any(Function));
|
|
});
|
|
|
|
it('uses ext_openssh_rename when posix-rename extension is present', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
const extRen = vi.fn((_s: string, _d: string, cb: (e: null) => void) => cb(null));
|
|
(sftp as any)._extensions = { 'posix-rename@openssh.com': '1' };
|
|
(sftp as any).ext_openssh_rename = extRen;
|
|
wire(sut, sftp);
|
|
|
|
await sut.rename('old.md', 'new.md');
|
|
expect(extRen).toHaveBeenCalledWith('old.md', 'new.md', expect.any(Function));
|
|
expect(sftp.rename).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects when rename fails', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.rename.mockImplementation((_s: string, _d: string, cb: (e: Error) => void) =>
|
|
cb(new Error('EPERM')),
|
|
);
|
|
wire(sut, sftp);
|
|
await expect(sut.rename('a', 'b')).rejects.toThrow('EPERM');
|
|
});
|
|
});
|
|
|
|
// ─── remove ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.remove()', () => {
|
|
it('calls sftp.unlink and resolves on success', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.unlink.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
wire(sut, sftp);
|
|
|
|
await expect(sut.remove('note.md')).resolves.toBeUndefined();
|
|
expect(sftp.unlink).toHaveBeenCalledWith('note.md', expect.any(Function));
|
|
});
|
|
|
|
it('rejects on unlink error', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.unlink.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('ENOENT')),
|
|
);
|
|
wire(sut, sftp);
|
|
await expect(sut.remove('gone')).rejects.toThrow('ENOENT');
|
|
});
|
|
});
|
|
|
|
// ─── mkdir ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.mkdir()', () => {
|
|
it('skips mkdir call when the directory already exists', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: null, s: Stats) => void) =>
|
|
cb(null, fakeStats({ dir: true })),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
await sut.mkdir('/vault/notes');
|
|
expect(sftp.mkdir).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws when the path exists but is a file', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: null, s: Stats) => void) =>
|
|
cb(null, fakeStats({ dir: false })),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
await expect(sut.mkdir('/vault/conflict')).rejects.toThrow('exists and is not a directory');
|
|
});
|
|
|
|
it('calls sftp.mkdir when stat throws (path absent)', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('ENOENT')),
|
|
);
|
|
sftp.mkdir.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
wire(sut, sftp);
|
|
|
|
await sut.mkdir('/vault/new');
|
|
expect(sftp.mkdir).toHaveBeenCalledWith('/vault/new', expect.any(Function));
|
|
});
|
|
|
|
it('swallows "already exist" error from sftp.mkdir (idempotent)', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('ENOENT')),
|
|
);
|
|
sftp.mkdir.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('already exists')),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
await expect(sut.mkdir('/vault/already')).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ─── mkdirp ──────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.mkdirp()', () => {
|
|
it('creates each segment of an absolute path', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('ENOENT')),
|
|
);
|
|
sftp.mkdir.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
wire(sut, sftp);
|
|
|
|
await sut.mkdirp('/a/b/c');
|
|
|
|
const created = (sftp.mkdir as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
(c: unknown[]) => c[0] as string,
|
|
);
|
|
expect(created).toEqual(['/a', '/a/b', '/a/b/c']);
|
|
});
|
|
|
|
it('creates each segment of a relative path', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.stat.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('ENOENT')),
|
|
);
|
|
sftp.mkdir.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
wire(sut, sftp);
|
|
|
|
await sut.mkdirp('a/b');
|
|
|
|
const created = (sftp.mkdir as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
(c: unknown[]) => c[0] as string,
|
|
);
|
|
expect(created).toEqual(['a', 'a/b']);
|
|
});
|
|
});
|
|
|
|
// ─── copy ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.copy()', () => {
|
|
it('reads source then writes to destination', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
const srcBuf = Buffer.from('source data');
|
|
sftp.readFile.mockImplementation((_p: string, cb: (e: null, b: Buffer) => void) =>
|
|
cb(null, srcBuf),
|
|
);
|
|
sftp.writeFile.mockImplementation((_p: string, _d: Buffer, cb: (e: null) => void) =>
|
|
cb(null),
|
|
);
|
|
sftp.rename.mockImplementation((_s: string, _d: string, cb: (e: null) => void) =>
|
|
cb(null),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
await sut.copy('src.md', 'dst.md');
|
|
const [, writtenBuf] = (sftp.writeFile as ReturnType<typeof vi.fn>).mock.calls[0] as [string, Buffer];
|
|
expect(writtenBuf).toEqual(srcBuf);
|
|
});
|
|
});
|
|
|
|
// ─── listRecursive ───────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.listRecursive()', () => {
|
|
it('returns files from nested subdirectories via BFS', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
|
|
sftp.readdir
|
|
.mockImplementationOnce((_p: string, cb: (e: null, l: any[]) => void) =>
|
|
cb(null, [
|
|
{ filename: 'sub', attrs: fakeStats({ dir: true }) },
|
|
{ filename: 'root.md', attrs: fakeStats({ mtime: 1000 }) },
|
|
]),
|
|
)
|
|
.mockImplementationOnce((_p: string, cb: (e: null, l: any[]) => void) =>
|
|
cb(null, [{ filename: 'deep.md', attrs: fakeStats({ mtime: 2000 }) }]),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
// relativePath is the full path under root, not just the basename:
|
|
// entries from `/vault/sub` come back as `sub/deep.md`, not `deep.md`
|
|
// (see SftpClient.listRecursive: `rel = full.slice(rootPath.length + 1)`).
|
|
const entries = await sut.listRecursive('/vault');
|
|
expect(entries.map(e => e.relativePath).sort()).toEqual(['root.md', 'sub', 'sub/deep.md']);
|
|
});
|
|
|
|
it('filter predicate excludes non-matching entries', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.readdir.mockImplementation((_p: string, cb: (e: null, l: any[]) => void) =>
|
|
cb(null, [
|
|
{ filename: 'keep.md', attrs: fakeStats() },
|
|
{ filename: 'ignore.txt', attrs: fakeStats() },
|
|
]),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
const entries = await sut.listRecursive('/vault', rel => rel.endsWith('.md'));
|
|
expect(entries.map(e => e.name)).toEqual(['keep.md']);
|
|
});
|
|
|
|
it('logs a warning and continues when a subdirectory readdir fails', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.readdir
|
|
.mockImplementationOnce((_p: string, cb: (e: null, l: any[]) => void) =>
|
|
cb(null, [{ filename: 'bad', attrs: fakeStats({ dir: true }) }]),
|
|
)
|
|
.mockImplementationOnce((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('EACCES')),
|
|
);
|
|
wire(sut, sftp);
|
|
|
|
const entries = await sut.listRecursive('/vault');
|
|
expect(entries.map(e => e.name)).toEqual(['bad']);
|
|
});
|
|
});
|
|
|
|
// ─── rmdir ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.rmdir()', () => {
|
|
it('non-recursive: calls sftp.rmdir directly', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.rmdir.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
wire(sut, sftp);
|
|
|
|
await sut.rmdir('/vault/dir');
|
|
expect(sftp.rmdir).toHaveBeenCalledWith('/vault/dir', expect.any(Function));
|
|
});
|
|
|
|
it('recursive: removes all files and subdirs before root', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
|
|
// Path-aware mock: only `/vault` has children. Without this,
|
|
// `listRecursive`'s BFS would re-discover `sub` inside every
|
|
// descendant and grow the visited Set forever (heap-OOM after a
|
|
// few seconds — see issue diagnosed when this file was added).
|
|
sftp.readdir.mockImplementation((p: string, cb: (e: null, l: any[]) => void) => {
|
|
if (p === '/vault') {
|
|
return cb(null, [
|
|
{ filename: 'file.md', attrs: fakeStats({ mtime: 1000 }) },
|
|
{ filename: 'sub', attrs: fakeStats({ dir: true }) },
|
|
]);
|
|
}
|
|
return cb(null, []);
|
|
});
|
|
sftp.unlink.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
sftp.rmdir.mockImplementation((_p: string, cb: (e: null) => void) => cb(null));
|
|
wire(sut, sftp);
|
|
|
|
await sut.rmdir('/vault', true);
|
|
|
|
expect(sftp.unlink).toHaveBeenCalledWith('/vault/file.md', expect.any(Function));
|
|
const rmdirCalls = (sftp.rmdir as ReturnType<typeof vi.fn>).mock.calls.map(
|
|
(c: unknown[]) => c[0] as string,
|
|
);
|
|
expect(rmdirCalls).toContain('/vault/sub');
|
|
expect(rmdirCalls[rmdirCalls.length - 1]).toBe('/vault');
|
|
});
|
|
});
|
|
|
|
// ─── exec ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.exec()', () => {
|
|
it('collects stdout, stderr, and exitCode from the stream', async () => {
|
|
const sut = makeSut();
|
|
const { client } = makeFakeClient({
|
|
execResult: { stdout: 'out\n', stderr: 'err\n', exitCode: 0 },
|
|
});
|
|
wire(sut, makeSftp(), client);
|
|
|
|
const result = await sut.exec('echo hi');
|
|
expect(result.stdout).toBe('out\n');
|
|
expect(result.stderr).toBe('err\n');
|
|
expect(result.exitCode).toBe(0);
|
|
});
|
|
|
|
it('rejects when exec calls back with an error', async () => {
|
|
const sut = makeSut();
|
|
const { client } = makeFakeClient({ execErr: new Error('Channel open failure') });
|
|
wire(sut, makeSftp(), client);
|
|
await expect(sut.exec('ls')).rejects.toThrow('Channel open failure');
|
|
});
|
|
|
|
it('rejects when the stream emits an error event', async () => {
|
|
const sut = makeSut();
|
|
const { client } = makeFakeClient({ streamErr: new Error('stream reset') });
|
|
wire(sut, makeSftp(), client);
|
|
await expect(sut.exec('ls')).rejects.toThrow('stream reset');
|
|
});
|
|
});
|
|
|
|
// ─── getRemoteHome ───────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.getRemoteHome()', () => {
|
|
it('returns trimmed stdout of "echo $HOME"', async () => {
|
|
const sut = makeSut();
|
|
const { client } = makeFakeClient({ execResult: { stdout: '/home/alice\n', stderr: '', exitCode: 0 } });
|
|
wire(sut, makeSftp(), client);
|
|
expect(await sut.getRemoteHome()).toBe('/home/alice');
|
|
});
|
|
|
|
it('caches the result so exec is only called once', async () => {
|
|
const sut = makeSut();
|
|
const { client } = makeFakeClient({ execResult: { stdout: '/home/alice\n', stderr: '', exitCode: 0 } });
|
|
wire(sut, makeSftp(), client);
|
|
|
|
await sut.getRemoteHome();
|
|
await sut.getRemoteHome();
|
|
expect(client.exec).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('throws when exit code is non-zero', async () => {
|
|
const sut = makeSut();
|
|
const { client } = makeFakeClient({ execResult: { stdout: '', stderr: 'no home', exitCode: 1 } });
|
|
wire(sut, makeSftp(), client);
|
|
await expect(sut.getRemoteHome()).rejects.toThrow('exited 1');
|
|
});
|
|
|
|
it('throws when $HOME is empty', async () => {
|
|
const sut = makeSut();
|
|
const { client } = makeFakeClient({ execResult: { stdout: ' \n', stderr: '', exitCode: 0 } });
|
|
wire(sut, makeSftp(), client);
|
|
await expect(sut.getRemoteHome()).rejects.toThrow('$HOME is empty');
|
|
});
|
|
});
|
|
|
|
// ─── openUnixStream ──────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.openUnixStream()', () => {
|
|
it('resolves with the stream returned by openssh_forwardOutStreamLocal', async () => {
|
|
const sut = makeSut();
|
|
const stream = { pipe: vi.fn() } as any;
|
|
const { client } = makeFakeClient();
|
|
client.openssh_forwardOutStreamLocal.mockImplementation(
|
|
(_path: string, cb: (e: null, s: any) => void) => cb(null, stream),
|
|
);
|
|
wire(sut, makeSftp(), client);
|
|
expect(await sut.openUnixStream('/run/x.sock')).toBe(stream);
|
|
});
|
|
|
|
it('rejects when openssh_forwardOutStreamLocal calls back with an error', async () => {
|
|
const sut = makeSut();
|
|
const { client } = makeFakeClient();
|
|
client.openssh_forwardOutStreamLocal.mockImplementation(
|
|
(_path: string, cb: (e: Error) => void) => cb(new Error('administratively prohibited')),
|
|
);
|
|
wire(sut, makeSftp(), client);
|
|
await expect(sut.openUnixStream('/run/x.sock')).rejects.toThrow('administratively prohibited');
|
|
});
|
|
});
|
|
|
|
// ─── readRemoteFile ──────────────────────────────────────────────────────────
|
|
|
|
describe('SftpClient.readRemoteFile()', () => {
|
|
it('resolves with the buffer on success', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
const buf = Buffer.from('token-data');
|
|
sftp.readFile.mockImplementation((_p: string, cb: (e: null, b: Buffer) => void) =>
|
|
cb(null, buf),
|
|
);
|
|
wire(sut, sftp);
|
|
expect(await sut.readRemoteFile('/remote/token')).toBe(buf);
|
|
});
|
|
|
|
it('rejects on error', async () => {
|
|
const sut = makeSut();
|
|
const sftp = makeSftp();
|
|
sftp.readFile.mockImplementation((_p: string, cb: (e: Error) => void) =>
|
|
cb(new Error('SFTP: no such file')),
|
|
);
|
|
wire(sut, sftp);
|
|
await expect(sut.readRemoteFile('/missing')).rejects.toThrow('SFTP: no such file');
|
|
});
|
|
});
|