fix(connections): persist SSL settings and honor a URL's sslmode (#36)
Connecting to a managed provider (Neon in the report) failed with "connection is insecure (try using `sslmode=require`)" because the TLS mode never reached the backend: - ConnectionFormData carried no ssl_* fields and both submit handlers (NewConnectionScreen.buildPayload, EditConnectionModal save + test) omitted them, so the SSH/SSL tab's mode was silently dropped before IPC. - SslFields wrote ssl_ca_cert / ssl_client_cert / ssl_client_key — names the Rust ConnectionInput ignores (it expects ssl_ca_path / ssl_cert_path / ssl_key_path). - parseConnectionString ignored query params, so a pasted Neon/Supabase URL's ?sslmode=require was discarded. Pasting a URI now fills every setting it can (sslmode / ssl-mode, including MySQL's REQUIRED and VERIFY_IDENTITY spellings) and offers a derived label (database name, else host/file stem) as a placeholder that Test/Save uses when the field is left blank. Managed presets pre-select their TLS mode (Supabase / Neon → Require, PlanetScale → Verify Full). Backend safety net: db::tls::resolve_ssl_mode defaults *.neon.tech / *.supabase.co / *.psdb.cloud to `require` when a connection has no ssl_mode, so connections saved before this fix keep working; an explicit mode (including disable) always wins. Tests: +15 frontend (parser, form payload, SSL field names, edit modal), +4 Rust (resolve_ssl_mode / tls_decision_for).
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl, suggestConnectionLabel } from "./connectionString";
|
||||
import type { Connection } from "./types";
|
||||
|
||||
function makeConn(overrides: Partial<Connection> = {}): Connection {
|
||||
@@ -30,6 +30,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +43,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: null,
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +56,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "0",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +69,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +82,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +95,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,12 +108,51 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: "require",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(parseConnectionString("")).toBeNull();
|
||||
});
|
||||
it("lifts sslmode from a Neon-style URL (extra params ignored)", () => {
|
||||
const result = parseConnectionString(
|
||||
"postgresql://neondb_owner:pw@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
db_type: "postgresql",
|
||||
host: "ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
username: "neondb_owner",
|
||||
password: "pw",
|
||||
database: "neondb",
|
||||
ssl_mode: "require",
|
||||
});
|
||||
});
|
||||
|
||||
it("lifts every supported sslmode value", () => {
|
||||
const mode = (q: string) => parseConnectionString(`postgresql://u@h/db?sslmode=${q}`)?.ssl_mode;
|
||||
expect(mode("disable")).toBe("disable");
|
||||
expect(mode("require")).toBe("require");
|
||||
expect(mode("verify-ca")).toBe("verify-ca");
|
||||
expect(mode("verify-full")).toBe("verify-full");
|
||||
expect(mode("VERIFY-FULL")).toBe("verify-full");
|
||||
expect(mode("verify_full")).toBe("verify-full");
|
||||
});
|
||||
|
||||
it("ignores unsupported sslmode values instead of downgrading to plaintext", () => {
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=allow")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=prefer")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=bogus")?.ssl_mode).toBeNull();
|
||||
});
|
||||
|
||||
it("lifts MySQL's ssl-mode vocabulary and skips TLS for non-SQL db types", () => {
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=REQUIRED")?.ssl_mode).toBe("require");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=verify-full")?.ssl_mode).toBe("verify-full");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=VERIFY_IDENTITY")?.ssl_mode).toBe("verify-full");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=PREFERRED")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("redis://u@h?sslmode=require")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("sqlite:///tmp/a.db?sslmode=require")?.ssl_mode).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a non-URL string", () => {
|
||||
expect(parseConnectionString("hello world")).toBeNull();
|
||||
@@ -122,6 +167,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +180,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: "pw",
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +193,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: "pw",
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +206,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -180,6 +229,33 @@ describe("looksLikeConnectionString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestConnectionLabel", () => {
|
||||
const label = (url: string) => {
|
||||
const parsed = parseConnectionString(url)!;
|
||||
return suggestConnectionLabel(parsed);
|
||||
};
|
||||
|
||||
it("prefers the database name", () => {
|
||||
expect(
|
||||
label("postgresql://neondb_owner:pw@ep-x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require"),
|
||||
).toBe("neondb");
|
||||
});
|
||||
|
||||
it("falls back to the host when there is no database", () => {
|
||||
expect(label("postgresql://u@db.example.com:5432")).toBe("db.example.com");
|
||||
});
|
||||
|
||||
it("uses the file stem for SQLite paths", () => {
|
||||
expect(label("sqlite:///Users/me/data/app-demo.sqlite")).toBe("app-demo");
|
||||
expect(label("file:///tmp/notes.db")).toBe("notes");
|
||||
});
|
||||
|
||||
it("caps the label at the 100-char validation limit", () => {
|
||||
const host = `${"a".repeat(120)}.example.com`;
|
||||
expect(label(`postgresql://u@${host}/`).length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProviderFromHost", () => {
|
||||
it("detects Supabase by host suffix", () => {
|
||||
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
|
||||
@@ -291,6 +367,7 @@ describe("buildConnectionUrl", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user