mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
* test: AuthResolver / ConflictResolver / ReconnectManager branch coverage Add tests for three modules not touched by PR #257: tests/AuthResolver.test.ts (new): - storeSecret / getSecret / persistSecret / clearSecrets session-map API - buildAuthConfig for password, privateKey (+ passphrase / ENOENT / ~/ expand), agent (agentSocket / SSH_AUTH_SOCK fallback / missing socket), unknown method tests/ConflictResolver.test.ts (new): - keep-theirs decision (lines 74-78 previously uncovered) - cancel decision (line 84) - re-read failure fallback (line ~50) - swapClient(), two-choice fallback, three-way merge paths tests/ReconnectManager.test.ts (extended, 2 new tests): - onState throws → loop continues (transition() catch block) - cancel race: timer fires after cancel() sets cancelled flag (lines 133-134) vitest.config.ts: raise thresholds to lines:76 / branches:70 / functions:71 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fixup! test(AuthResolver/ReconnectManager): cross-platform paths + microtask comment AuthResolver.test.ts (review: HIGH — POSIX path convention): * Replace /home/alice/.ssh/id_ed25519 with ~/.ssh/id_ed25519 — also exercises expandHome, and avoids hardcoding a user-home path * Replace /nonexistent/key with nonexistent-key (readFileSync is mocked, path value is irrelevant but should not imply POSIX-only) * Replace /run/user/1000/ssh-agent.socket with test-ssh-agent.socket * Replace /tmp/ssh-auth.sock with test-ssh-auth.sock ReconnectManager.test.ts (review: advisory — fragile assumption): * Document the one-microtask assumption in the race test so future refactors that add an await before sleep() know to update the test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { AuthResolver } from '../src/ssh/AuthResolver';
|
|
import type { SecretStore } from '../src/ssh/SecretStore';
|
|
import type { SshProfile } from '../src/types';
|
|
|
|
vi.mock('fs', () => ({ readFileSync: vi.fn() }));
|
|
|
|
import * as fs from 'fs';
|
|
|
|
function makeStore(initial: Record<string, string> = {}): SecretStore {
|
|
const data = new Map(Object.entries(initial));
|
|
return {
|
|
get: (ref: string) => data.get(ref),
|
|
set: (ref: string, val: string) => { data.set(ref, val); },
|
|
delete: (ref: string) => { data.delete(ref); },
|
|
} as unknown as SecretStore;
|
|
}
|
|
|
|
const baseProfile: SshProfile = {
|
|
id: 'p1',
|
|
name: 'test',
|
|
host: 'host.example',
|
|
port: 22,
|
|
username: 'alice',
|
|
authMethod: 'password',
|
|
remotePath: '~/vault',
|
|
connectTimeoutMs: 10000,
|
|
keepaliveIntervalMs: 5000,
|
|
keepaliveCountMax: 3,
|
|
};
|
|
|
|
describe('AuthResolver — secret management', () => {
|
|
it('storeSecret stores in session map and is retrievable via getSecret', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
r.storeSecret('ref1', 'val1');
|
|
expect(r.getSecret('ref1')).toBe('val1');
|
|
});
|
|
|
|
it('getSecret falls back to the persistent store when not in session map', () => {
|
|
const store = makeStore({ persistedRef: 'fromStore' });
|
|
const r = new AuthResolver(store);
|
|
expect(r.getSecret('persistedRef')).toBe('fromStore');
|
|
});
|
|
|
|
it('session map takes precedence over the persistent store', () => {
|
|
const store = makeStore({ ref: 'stored' });
|
|
const r = new AuthResolver(store);
|
|
r.storeSecret('ref', 'session');
|
|
expect(r.getSecret('ref')).toBe('session');
|
|
});
|
|
|
|
it('persistSecret writes to session map AND the underlying store', () => {
|
|
const setMock = vi.fn();
|
|
const store = { get: vi.fn(), set: setMock, delete: vi.fn() } as unknown as SecretStore;
|
|
const r = new AuthResolver(store);
|
|
r.persistSecret('myRef', 'myVal');
|
|
expect(r.getSecret('myRef')).toBe('myVal');
|
|
expect(setMock).toHaveBeenCalledWith('myRef', 'myVal');
|
|
});
|
|
|
|
it('clearSecrets removes all session entries for a given profile id prefix', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
r.storeSecret('p1:password', 'pw');
|
|
r.storeSecret('p1:passphrase', 'pp');
|
|
r.storeSecret('p2:password', 'other');
|
|
r.clearSecrets('p1');
|
|
expect(r.getSecret('p1:password')).toBeUndefined();
|
|
expect(r.getSecret('p1:passphrase')).toBeUndefined();
|
|
expect(r.getSecret('p2:password')).toBe('other');
|
|
});
|
|
|
|
it('getSecret returns undefined when ref is unknown in both maps', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
expect(r.getSecret('unknown')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('AuthResolver.buildAuthConfig — password', () => {
|
|
it('returns { password } when a password ref is stored', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
r.storeSecret('pw-ref', 'secret123');
|
|
const profile: SshProfile = { ...baseProfile, authMethod: 'password', passwordRef: 'pw-ref' };
|
|
const cfg = r.buildAuthConfig(profile);
|
|
expect(cfg).toEqual({ password: 'secret123' });
|
|
});
|
|
|
|
it('throws when no password is stored for the ref', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = { ...baseProfile, authMethod: 'password', passwordRef: 'missing' };
|
|
expect(() => r.buildAuthConfig(profile)).toThrow(/No password stored/);
|
|
});
|
|
|
|
it('throws when passwordRef is absent', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = { ...baseProfile, authMethod: 'password', passwordRef: undefined };
|
|
expect(() => r.buildAuthConfig(profile)).toThrow(/No password stored/);
|
|
});
|
|
});
|
|
|
|
describe('AuthResolver.buildAuthConfig — privateKey', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('---BEGIN KEY---'));
|
|
});
|
|
|
|
it('returns { privateKey } when key file is readable and no passphrase', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = {
|
|
...baseProfile,
|
|
authMethod: 'privateKey',
|
|
// Use tilde prefix so expandHome is exercised; readFileSync is mocked.
|
|
privateKeyPath: '~/.ssh/id_ed25519',
|
|
};
|
|
const cfg = r.buildAuthConfig(profile);
|
|
expect(cfg).toMatchObject({ privateKey: expect.any(Buffer) });
|
|
expect(cfg.passphrase).toBeUndefined();
|
|
});
|
|
|
|
it('includes passphrase when passphraseRef is stored', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
r.storeSecret('pp-ref', 'myPassphrase');
|
|
const profile: SshProfile = {
|
|
...baseProfile,
|
|
authMethod: 'privateKey',
|
|
privateKeyPath: '~/.ssh/id_ed25519',
|
|
passphraseRef: 'pp-ref',
|
|
};
|
|
const cfg = r.buildAuthConfig(profile);
|
|
expect(cfg.passphrase).toBe('myPassphrase');
|
|
});
|
|
|
|
it('throws when privateKeyPath is absent', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = { ...baseProfile, authMethod: 'privateKey', privateKeyPath: undefined };
|
|
expect(() => r.buildAuthConfig(profile)).toThrow(/No private key path/);
|
|
});
|
|
|
|
it('throws when readFileSync fails', () => {
|
|
vi.mocked(fs.readFileSync).mockImplementation(() => { throw new Error('ENOENT'); });
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = {
|
|
...baseProfile,
|
|
authMethod: 'privateKey',
|
|
// Path value doesn't matter; readFileSync is mocked to throw.
|
|
privateKeyPath: 'nonexistent-key',
|
|
};
|
|
expect(() => r.buildAuthConfig(profile)).toThrow(/Cannot read private key/);
|
|
});
|
|
|
|
it('expands "~/" in privateKeyPath before reading the file', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = {
|
|
...baseProfile,
|
|
authMethod: 'privateKey',
|
|
privateKeyPath: '~/.ssh/id_ed25519',
|
|
};
|
|
r.buildAuthConfig(profile);
|
|
const calledPath = vi.mocked(fs.readFileSync).mock.calls[0][0] as string;
|
|
expect(calledPath).not.toMatch(/^~/);
|
|
});
|
|
});
|
|
|
|
describe('AuthResolver.buildAuthConfig — agent', () => {
|
|
it('returns { agent } using profile.agentSocket when set', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = {
|
|
...baseProfile,
|
|
authMethod: 'agent',
|
|
agentSocket: 'test-ssh-agent.socket',
|
|
};
|
|
const cfg = r.buildAuthConfig(profile);
|
|
expect(cfg).toEqual({ agent: 'test-ssh-agent.socket' });
|
|
});
|
|
|
|
it('falls back to SSH_AUTH_SOCK env var when agentSocket is not set', () => {
|
|
const orig = process.env.SSH_AUTH_SOCK;
|
|
process.env.SSH_AUTH_SOCK = 'test-ssh-auth.sock';
|
|
try {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = { ...baseProfile, authMethod: 'agent', agentSocket: undefined };
|
|
const cfg = r.buildAuthConfig(profile);
|
|
expect(cfg).toEqual({ agent: 'test-ssh-auth.sock' });
|
|
} finally {
|
|
if (orig === undefined) delete process.env.SSH_AUTH_SOCK;
|
|
else process.env.SSH_AUTH_SOCK = orig;
|
|
}
|
|
});
|
|
|
|
it('throws when neither agentSocket nor SSH_AUTH_SOCK is set', () => {
|
|
const orig = process.env.SSH_AUTH_SOCK;
|
|
delete process.env.SSH_AUTH_SOCK;
|
|
try {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile: SshProfile = { ...baseProfile, authMethod: 'agent', agentSocket: undefined };
|
|
expect(() => r.buildAuthConfig(profile)).toThrow(/SSH_AUTH_SOCK/);
|
|
} finally {
|
|
if (orig !== undefined) process.env.SSH_AUTH_SOCK = orig;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('AuthResolver.buildAuthConfig — unknown method', () => {
|
|
it('throws for an unrecognised authMethod value', () => {
|
|
const r = new AuthResolver(makeStore());
|
|
const profile = { ...baseProfile, authMethod: 'magic' as SshProfile['authMethod'] };
|
|
expect(() => r.buildAuthConfig(profile)).toThrow(/Unknown auth method/);
|
|
});
|
|
});
|