mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 10:57:07 +00:00
fix(app): identify Android certificate failures as TLS errors (#7429)
Recognize Android certificate-path validation failures before the generic not-found classification, so Direct connection no longer tells users to debug DNS for an untrusted certificate. Keep the existing TLS copy and technical details. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -11,10 +11,7 @@ import {
|
||||
serializeConnectionUri,
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||
@@ -195,92 +192,6 @@ function draftFromConnectionUri(uri: string): DirectConnectionDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: DirectConnectionLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const allGeneric = unique.every((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "transport error" || lower === "transport closed";
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: DirectConnectionLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else {
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
|
||||
export interface AddHostModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
|
||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||
|
||||
const labels = {
|
||||
hostRequired: "host required",
|
||||
invalidPort: "invalid port",
|
||||
invalidConnection: "invalid connection",
|
||||
failedToConnect: (endpoint: string) => `Failed: ${endpoint}`,
|
||||
noAdditionalDetails: (detail: string) => `No details: ${detail}`,
|
||||
timedOut: "timeout",
|
||||
refused: "refused",
|
||||
hostNotFound: "DNS failure",
|
||||
hostUnreachable: "unreachable",
|
||||
tlsError: "TLS failure",
|
||||
unableToConnect: "connection failure",
|
||||
};
|
||||
|
||||
function failure(message: string) {
|
||||
return buildConnectionFailureCopy({
|
||||
endpoint: "tcp://example.test:443?ssl=true",
|
||||
error: new DaemonConnectionTestError(message, {
|
||||
reason: "transport error",
|
||||
lastError: message,
|
||||
}),
|
||||
labels,
|
||||
});
|
||||
}
|
||||
|
||||
describe("direct connection failure copy", () => {
|
||||
it("identifies Android certificate validation errors without calling them DNS failures", () => {
|
||||
const message =
|
||||
"java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.";
|
||||
expect(failure(message)).toEqual({
|
||||
title: "Failed: tcp://example.test:443?ssl=true",
|
||||
detail: labels.tlsError,
|
||||
raw: `transport error — ${message}`,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["certificate not found", labels.tlsError],
|
||||
["SSL handshake failed", labels.tlsError],
|
||||
["TLS handshake failed", labels.tlsError],
|
||||
["getaddrinfo ENOTFOUND example.test", labels.hostNotFound],
|
||||
["Host not found", labels.hostNotFound],
|
||||
["connect ECONNREFUSED", labels.refused],
|
||||
["connection timed out", labels.timedOut],
|
||||
["EHOSTUNREACH", labels.hostUnreachable],
|
||||
["unexpected transport failure", labels.unableToConnect],
|
||||
])("classifies %s", (message, expected) => {
|
||||
expect(failure(message).detail).toBe(expected);
|
||||
});
|
||||
|
||||
it("preserves authentication failures and their technical details", () => {
|
||||
const message = "Invalid daemon password";
|
||||
expect(
|
||||
buildConnectionFailureCopy({
|
||||
endpoint: "example.test:6767",
|
||||
error: new DaemonConnectionTestError(message, {
|
||||
reason: message,
|
||||
lastError: message,
|
||||
authFailureReason: "incorrect_password",
|
||||
}),
|
||||
labels,
|
||||
}),
|
||||
).toMatchObject({ detail: message, raw: message });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
DaemonConnectionTestError,
|
||||
getConnectionAuthFailureReason,
|
||||
} from "@/utils/test-daemon-connection";
|
||||
|
||||
interface ConnectionFailureLabels {
|
||||
failedToConnect: (endpoint: string) => string;
|
||||
noAdditionalDetails: (detail: string) => string;
|
||||
timedOut: string;
|
||||
refused: string;
|
||||
hostNotFound: string;
|
||||
hostUnreachable: string;
|
||||
tlsError: string;
|
||||
unableToConnect: string;
|
||||
}
|
||||
|
||||
function normalizeTransportMessage(message: string | null | undefined): string | null {
|
||||
if (!message) return null;
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function formatTechnicalTransportDetails(
|
||||
details: (string | null)[],
|
||||
labels: ConnectionFailureLabels,
|
||||
): string | null {
|
||||
const unique = Array.from(
|
||||
new Set(
|
||||
details
|
||||
.map((value) => normalizeTransportMessage(value))
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
);
|
||||
|
||||
if (unique.length === 0) return null;
|
||||
|
||||
const allGeneric = unique.every((value) => {
|
||||
const lower = value.toLowerCase();
|
||||
return lower === "transport error" || lower === "transport closed";
|
||||
});
|
||||
|
||||
if (allGeneric) {
|
||||
return labels.noAdditionalDetails(unique[0] ?? "");
|
||||
}
|
||||
|
||||
return unique.join(" — ");
|
||||
}
|
||||
|
||||
export function buildConnectionFailureCopy(input: {
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
labels: ConnectionFailureLabels;
|
||||
}): { title: string; detail: string | null; raw: string | null } {
|
||||
const { endpoint, error, labels } = input;
|
||||
const title = labels.failedToConnect(endpoint);
|
||||
|
||||
const raw = (() => {
|
||||
if (error instanceof DaemonConnectionTestError) {
|
||||
return (
|
||||
formatTechnicalTransportDetails([error.reason, error.lastError], labels) ??
|
||||
normalizeTransportMessage(error.message)
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return normalizeTransportMessage(error.message);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const rawLower = raw?.toLowerCase() ?? "";
|
||||
let detail: string | null = null;
|
||||
|
||||
if (getConnectionAuthFailureReason(error)) {
|
||||
detail = error instanceof Error ? error.message : raw;
|
||||
} else if (rawLower.includes("timed out")) {
|
||||
detail = labels.timedOut;
|
||||
} else if (
|
||||
rawLower.includes("econnrefused") ||
|
||||
rawLower.includes("connection refused") ||
|
||||
rawLower.includes("err_connection_refused")
|
||||
) {
|
||||
detail = labels.refused;
|
||||
} else if (
|
||||
rawLower.includes("certificate") ||
|
||||
rawLower.includes("certpathvalidator") ||
|
||||
rawLower.includes("trust anchor") ||
|
||||
rawLower.includes("tls") ||
|
||||
rawLower.includes("ssl")
|
||||
) {
|
||||
detail = labels.tlsError;
|
||||
} else if (rawLower.includes("enotfound") || rawLower.includes("not found")) {
|
||||
detail = labels.hostNotFound;
|
||||
} else if (rawLower.includes("ehostunreach") || rawLower.includes("host is unreachable")) {
|
||||
detail = labels.hostUnreachable;
|
||||
} else {
|
||||
detail = labels.unableToConnect;
|
||||
}
|
||||
|
||||
return { title, detail, raw };
|
||||
}
|
||||
Reference in New Issue
Block a user