Refactor daemon connection probe tests (#886)
This commit is contained in:
@@ -1,154 +1,142 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClientConfig } from "@server/client/daemon-client";
|
||||
import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection";
|
||||
|
||||
const daemonClientMock = vi.hoisted(() => {
|
||||
const createdConfigs: Array<{ clientId?: string; url?: string; password?: string }> = [];
|
||||
let nextConnectError: Error | null = null;
|
||||
let nextLastError: string | null = null;
|
||||
class FakeDaemonClient implements DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
|
||||
class MockDaemonClient {
|
||||
public lastError: string | null = nextLastError;
|
||||
private lastServerInfo = {
|
||||
status: "server_info" as const,
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host" as string | null,
|
||||
version: "0.0.0",
|
||||
};
|
||||
constructor(
|
||||
private readonly probe: FakeDaemonProbe,
|
||||
readonly config: DaemonClientConfig,
|
||||
) {
|
||||
this.lastError = probe.nextLastError;
|
||||
}
|
||||
|
||||
constructor(config: { clientId?: string; url?: string; password?: string }) {
|
||||
createdConfigs.push(config);
|
||||
}
|
||||
|
||||
subscribeConnectionStatus(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
on(): () => void {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (nextConnectError) {
|
||||
throw nextConnectError;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
getLastServerInfoMessage() {
|
||||
return this.lastServerInfo;
|
||||
}
|
||||
|
||||
async ping(): Promise<{ rttMs: number }> {
|
||||
return { rttMs: 42 };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return;
|
||||
async connect(): Promise<void> {
|
||||
if (this.probe.nextConnectError) {
|
||||
throw this.probe.nextConnectError;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
MockDaemonClient,
|
||||
createdConfigs,
|
||||
setNextConnectFailure: (error: Error, lastError: string | null) => {
|
||||
nextConnectError = error;
|
||||
nextLastError = lastError;
|
||||
getLastServerInfoMessage() {
|
||||
return {
|
||||
serverId: "srv_probe_test",
|
||||
hostname: "probe-host",
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.probe.closedClients.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDaemonProbe {
|
||||
createdClients: FakeDaemonClient[] = [];
|
||||
closedClients: FakeDaemonClient[] = [];
|
||||
clientIdsRequested = 0;
|
||||
nextConnectError: Error | null = null;
|
||||
nextLastError: string | null = null;
|
||||
|
||||
readonly deps: DaemonConnectionDependencies<FakeDaemonClient> = {
|
||||
getClientId: async () => {
|
||||
this.clientIdsRequested += 1;
|
||||
return "cid_shared_probe_test";
|
||||
},
|
||||
reset: () => {
|
||||
createdConfigs.length = 0;
|
||||
nextConnectError = null;
|
||||
nextLastError = null;
|
||||
resolveAppVersion: () => null,
|
||||
createLocalTransportFactory: () => null,
|
||||
buildLocalTransportUrl: ({ transportType, transportPath }) =>
|
||||
`paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
|
||||
createClient: (config) => {
|
||||
const client = new FakeDaemonClient(this, config);
|
||||
this.createdClients.push(client);
|
||||
return client;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const clientIdMock = vi.hoisted(() => ({
|
||||
getOrCreateClientId: vi.fn(async () => "cid_shared_probe_test"),
|
||||
}));
|
||||
failNextConnection(error: Error, lastError: string | null): void {
|
||||
this.nextConnectError = error;
|
||||
this.nextLastError = lastError;
|
||||
}
|
||||
|
||||
vi.mock("@server/client/daemon-client", () => ({
|
||||
DaemonClient: daemonClientMock.MockDaemonClient,
|
||||
}));
|
||||
|
||||
vi.mock("./client-id", () => ({
|
||||
getOrCreateClientId: clientIdMock.getOrCreateClientId,
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/daemon/desktop-daemon-transport", () => ({
|
||||
createDesktopLocalDaemonTransportFactory: vi.fn(() => null),
|
||||
buildLocalDaemonTransportUrl: vi.fn(
|
||||
({
|
||||
transportType,
|
||||
transportPath,
|
||||
}: {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
}) => `paseo+local://${transportType}?path=${encodeURIComponent(transportPath)}`,
|
||||
),
|
||||
}));
|
||||
createdConfigs(): DaemonClientConfig[] {
|
||||
return this.createdClients.map((client) => client.config);
|
||||
}
|
||||
}
|
||||
|
||||
describe("test-daemon-connection connectToDaemon", () => {
|
||||
let probe: FakeDaemonProbe;
|
||||
|
||||
beforeEach(() => {
|
||||
daemonClientMock.reset();
|
||||
clientIdMock.getOrCreateClientId.mockClear();
|
||||
vi.stubGlobal("__DEV__", false);
|
||||
probe = new FakeDaemonProbe();
|
||||
});
|
||||
|
||||
it("reuses the app clientId for direct connections", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const first = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const first = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await first.client.close();
|
||||
|
||||
const second = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
});
|
||||
const second = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await second.client.close();
|
||||
|
||||
const [firstConfig, secondConfig] = daemonClientMock.createdConfigs;
|
||||
const [firstConfig, secondConfig] = probe.createdConfigs();
|
||||
expect(firstConfig?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(secondConfig?.clientId).toBe("cid_shared_probe_test");
|
||||
expect(clientIdMock.getOrCreateClientId).toHaveBeenCalledTimes(2);
|
||||
expect(probe.clientIdsRequested).toBe(2);
|
||||
});
|
||||
|
||||
it("encodes the local socket target into the client config", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const result = await mod.connectToDaemon({
|
||||
id: "socket:/tmp/paseo.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo.sock",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "socket:/tmp/paseo.sock",
|
||||
type: "directSocket",
|
||||
path: "/tmp/paseo.sock",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toBe(
|
||||
"paseo+local://socket?path=%2Ftmp%2Fpaseo.sock",
|
||||
);
|
||||
expect(probe.createdConfigs()[0]?.url).toBe("paseo+local://socket?path=%2Ftmp%2Fpaseo.sock");
|
||||
});
|
||||
|
||||
it("passes direct TCP connection passwords into the client config", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const result = await mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
});
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const result = await connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
);
|
||||
await result.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.password).toBe("shared-secret");
|
||||
expect(probe.createdConfigs()[0]?.password).toBe("shared-secret");
|
||||
});
|
||||
|
||||
it("uses relay TLS from the stored connection", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
|
||||
const tlsResult = await mod.connectToDaemon(
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
const tlsResult = await connectToDaemon(
|
||||
{
|
||||
id: "relay:wss:[::1]:443",
|
||||
type: "relay",
|
||||
@@ -157,10 +145,11 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
{ serverId: "srv_probe_test" },
|
||||
probe.deps,
|
||||
);
|
||||
await tlsResult.client.close();
|
||||
|
||||
const plainResult = await mod.connectToDaemon(
|
||||
const plainResult = await connectToDaemon(
|
||||
{
|
||||
id: "relay:relay.paseo.sh:443",
|
||||
type: "relay",
|
||||
@@ -169,43 +158,52 @@ describe("test-daemon-connection connectToDaemon", () => {
|
||||
daemonPublicKeyB64: "pubkey",
|
||||
},
|
||||
{ serverId: "srv_probe_test" },
|
||||
probe.deps,
|
||||
);
|
||||
await plainResult.client.close();
|
||||
|
||||
expect(daemonClientMock.createdConfigs[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(daemonClientMock.createdConfigs[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||
expect(probe.createdConfigs()[0]?.url).toMatch(/^wss:\/\/\[::1\]\/ws\?/);
|
||||
expect(probe.createdConfigs()[1]?.url).toMatch(/^ws:\/\/relay\.paseo\.sh:443\/ws\?/);
|
||||
});
|
||||
|
||||
it("surfaces auth rejection as an incorrect password", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
daemonClientMock.setNextConnectFailure(
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(
|
||||
new Error("Transport closed (code 4001)"),
|
||||
"Transport closed (code 4001)",
|
||||
);
|
||||
|
||||
await expect(
|
||||
mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
}),
|
||||
connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "wrong-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Incorrect password",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps generic transport failures generic when a password was supplied", async () => {
|
||||
const mod = await import("./test-daemon-connection");
|
||||
daemonClientMock.setNextConnectFailure(new Error("Transport error"), "Transport error");
|
||||
const { connectToDaemon } = await import("./test-daemon-connection");
|
||||
probe.failNextConnection(new Error("Transport error"), "Transport error");
|
||||
|
||||
await expect(
|
||||
mod.connectToDaemon({
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
}),
|
||||
connectToDaemon(
|
||||
{
|
||||
id: "direct:lan:6767",
|
||||
type: "directTcp",
|
||||
endpoint: "lan:6767",
|
||||
password: "shared-secret",
|
||||
},
|
||||
undefined,
|
||||
probe.deps,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: "Transport error",
|
||||
});
|
||||
|
||||
@@ -13,6 +13,34 @@ import {
|
||||
createDesktopLocalDaemonTransportFactory,
|
||||
} from "@/desktop/daemon/desktop-daemon-transport";
|
||||
|
||||
export interface DaemonProbeClient {
|
||||
readonly lastError: string | null;
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getLastServerInfoMessage(): { serverId: string; hostname: string | null } | null;
|
||||
}
|
||||
|
||||
interface LocalTransportUrlInput {
|
||||
transportType: "socket" | "pipe";
|
||||
transportPath: string;
|
||||
}
|
||||
|
||||
export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient> {
|
||||
getClientId(): Promise<string>;
|
||||
resolveAppVersion(): string | null;
|
||||
createLocalTransportFactory(): DaemonClientConfig["transportFactory"] | null;
|
||||
buildLocalTransportUrl(input: LocalTransportUrlInput): string;
|
||||
createClient(config: DaemonClientConfig): TClient;
|
||||
}
|
||||
|
||||
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
|
||||
getClientId: getOrCreateClientId,
|
||||
resolveAppVersion,
|
||||
createLocalTransportFactory: createDesktopLocalDaemonTransportFactory,
|
||||
buildLocalTransportUrl: buildLocalDaemonTransportUrl,
|
||||
createClient: (config) => new DaemonClient(config),
|
||||
};
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -69,13 +97,17 @@ export class DaemonConnectionTestError extends Error {
|
||||
export async function buildClientConfig(
|
||||
connection: HostConnection,
|
||||
serverId?: string,
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"getClientId" | "resolveAppVersion" | "createLocalTransportFactory" | "buildLocalTransportUrl"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const localTransportFactory = createDesktopLocalDaemonTransportFactory();
|
||||
const clientId = await deps.getClientId();
|
||||
const localTransportFactory = deps.createLocalTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
clientType: "mobile" as const,
|
||||
appVersion: resolveAppVersion() ?? undefined,
|
||||
appVersion: deps.resolveAppVersion() ?? undefined,
|
||||
suppressSendErrors: true,
|
||||
reconnect: { enabled: false },
|
||||
...((connection.type === "directSocket" || connection.type === "directPipe") &&
|
||||
@@ -87,7 +119,7 @@ export async function buildClientConfig(
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
return {
|
||||
...base,
|
||||
url: buildLocalDaemonTransportUrl({
|
||||
url: deps.buildLocalTransportUrl({
|
||||
transportType: connection.type === "directSocket" ? "socket" : "pipe",
|
||||
transportPath: connection.path,
|
||||
}),
|
||||
@@ -120,10 +152,23 @@ export async function buildClientConfig(
|
||||
export function connectAndProbe(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const client = new DaemonClient(config);
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
||||
export function connectAndProbe<TClient extends DaemonProbeClient>(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
deps: Pick<DaemonConnectionDependencies<TClient>, "createClient">,
|
||||
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
|
||||
export function connectAndProbe(
|
||||
config: DaemonClientConfig,
|
||||
timeoutMs: number,
|
||||
deps: Pick<
|
||||
DaemonConnectionDependencies<DaemonProbeClient>,
|
||||
"createClient"
|
||||
> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const client = deps.createClient(config);
|
||||
|
||||
return new Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>(
|
||||
return new Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }>(
|
||||
(resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
void client.close().catch(() => undefined);
|
||||
@@ -183,10 +228,20 @@ function resolveTimeout(connection: HostConnection, options?: ProbeOptions): num
|
||||
return connection.type === "relay" ? 10_000 : 6_000;
|
||||
}
|
||||
|
||||
export function connectToDaemon(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }>;
|
||||
export function connectToDaemon<TClient extends DaemonProbeClient>(
|
||||
connection: HostConnection,
|
||||
options: ProbeOptions | undefined,
|
||||
deps: DaemonConnectionDependencies<TClient>,
|
||||
): Promise<{ client: TClient; serverId: string; hostname: string | null }>;
|
||||
export async function connectToDaemon(
|
||||
connection: HostConnection,
|
||||
options?: ProbeOptions,
|
||||
): Promise<{ client: DaemonClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options));
|
||||
deps: DaemonConnectionDependencies<DaemonProbeClient> = defaultDaemonConnectionDependencies,
|
||||
): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> {
|
||||
const config = await buildClientConfig(connection, options?.serverId, deps);
|
||||
return connectAndProbe(config, resolveTimeout(connection, options), deps);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user