sotashimozono_obsidian-remo.../plugin/tests/RpcConnection.test.ts
sotashimozono fb1b51d109
test(RpcConnection): cover auth ok=false and server.info error branches (#268)
Adds two tests that exercise previously uncovered branches in
establishRpcConnection(): one where auth succeeds but the daemon
returns result.ok=false (→ AuthInvalid), and one where server.info
replies with an error envelope (→ connection cleaned up before rethrow).
Also extends ServerScript/startFakeDaemon to support both scenarios.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:15:28 +09:00

150 lines
5.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { PassThrough } from 'stream';
import type { Duplex } from 'stream';
import { establishRpcConnection } from '../src/transport/RpcConnection';
import { FramedDuplex } from '../src/transport/framing';
import { RpcError } from '../src/transport/RpcError';
import { ErrorCode, PROTOCOL_VERSION } from '../src/proto/types';
/**
* Builds a duplex pair where writes to `clientSide` show up as reads
* on `serverSide`, and vice versa. The "server" mocks the daemon by
* watching incoming framed JSON-RPC requests and responding directly.
*/
function duplexPair(): { clientSide: Duplex; serverSide: Duplex } {
const clientToServer = new PassThrough();
const serverToClient = new PassThrough();
const clientSide = combine(serverToClient, clientToServer);
const serverSide = combine(clientToServer, serverToClient);
return { clientSide, serverSide };
}
function combine(reads: PassThrough, writes: PassThrough): Duplex {
return {
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'data' || event === 'end' || event === 'close' || event === 'error') {
reads.on(event, listener);
}
return this;
},
write: (chunk: Buffer) => writes.write(chunk),
end: () => { writes.end(); reads.end(); },
} as unknown as Duplex;
}
interface ServerScript {
/** Optional override for server.info; defaults to the protocol's version. */
info?: { protocolVersion: number };
/** Make `auth` reject with a custom error envelope. */
authReject?: { code: number; message: string };
/** Make `auth` reply result.ok = false (token accepted but server refused). */
authOkFalse?: boolean;
/** Make `server.info` reply with an error envelope instead of a result. */
infoError?: { code: number; message: string };
}
/**
* Wires a FramedDuplex on the "server" side of a duplex pair and
* answers `auth` + `server.info` according to a tiny script. Returns
* a disposer to release event listeners after the test.
*/
function startFakeDaemon(serverSide: Duplex, script: ServerScript = {}): () => void {
const framed = new FramedDuplex(serverSide);
framed.on('message', (body: Buffer) => {
const req = JSON.parse(body.toString('utf8')) as { id: number; method: string };
if (req.method === 'auth') {
if (script.authReject) {
framed.writeMessage(Buffer.from(JSON.stringify({
jsonrpc: '2.0', id: req.id, error: script.authReject,
}), 'utf8'));
} else if (script.authOkFalse) {
framed.writeMessage(Buffer.from(JSON.stringify({
jsonrpc: '2.0', id: req.id, result: { ok: false },
}), 'utf8'));
} else {
framed.writeMessage(Buffer.from(JSON.stringify({
jsonrpc: '2.0', id: req.id, result: { ok: true },
}), 'utf8'));
}
return;
}
if (req.method === 'server.info') {
if (script.infoError) {
framed.writeMessage(Buffer.from(JSON.stringify({
jsonrpc: '2.0', id: req.id, error: script.infoError,
}), 'utf8'));
return;
}
const info = script.info ?? { protocolVersion: PROTOCOL_VERSION };
framed.writeMessage(Buffer.from(JSON.stringify({
jsonrpc: '2.0', id: req.id,
result: {
version: 'test-1.0', protocolVersion: info.protocolVersion,
capabilities: ['auth', 'server.info'], vaultRoot: '/v',
},
}), 'utf8'));
return;
}
framed.writeMessage(Buffer.from(JSON.stringify({
jsonrpc: '2.0', id: req.id, error: { code: -32601, message: `unknown method ${req.method}` },
}), 'utf8'));
});
return () => framed.close();
}
describe('establishRpcConnection', () => {
it('completes auth → server.info and returns the daemon info', async () => {
const pair = duplexPair();
const stop = startFakeDaemon(pair.serverSide);
const conn = await establishRpcConnection({ stream: pair.clientSide, token: 'good' });
expect(conn.info.version).toBe('test-1.0');
expect(conn.info.protocolVersion).toBe(PROTOCOL_VERSION);
expect(conn.info.capabilities).toContain('auth');
conn.close();
stop();
});
it('rejects with the daemon-supplied error code when auth fails', async () => {
const pair = duplexPair();
const stop = startFakeDaemon(pair.serverSide, {
authReject: { code: ErrorCode.AuthInvalid, message: 'wrong token' },
});
await expect(
establishRpcConnection({ stream: pair.clientSide, token: 'bad' }),
).rejects.toMatchObject({ code: ErrorCode.AuthInvalid });
stop();
});
it('rejects with ProtocolVersionTooOld when versions disagree', async () => {
const pair = duplexPair();
const stop = startFakeDaemon(pair.serverSide, { info: { protocolVersion: 999 } });
try {
await establishRpcConnection({ stream: pair.clientSide, token: 'good' });
expect.fail('expected handshake to reject for protocol mismatch');
} catch (e) {
expect(e).toBeInstanceOf(RpcError);
expect((e as RpcError).code).toBe(ErrorCode.ProtocolVersionTooOld);
}
stop();
});
it('rejects with AuthInvalid when auth returns result.ok = false', async () => {
const pair = duplexPair();
const stop = startFakeDaemon(pair.serverSide, { authOkFalse: true });
await expect(
establishRpcConnection({ stream: pair.clientSide, token: 'any' }),
).rejects.toMatchObject({ code: ErrorCode.AuthInvalid });
stop();
});
it('rejects and closes when server.info returns an error envelope', async () => {
const pair = duplexPair();
const stop = startFakeDaemon(pair.serverSide, {
infoError: { code: -32000, message: 'method unavailable' },
});
await expect(
establishRpcConnection({ stream: pair.clientSide, token: 'good' }),
).rejects.toThrow('method unavailable');
stop();
});
});