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