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:
2026-09-24 03:25:13 +08:00
committed by GitHub
parent f807c22a36
commit 3454ce67fe
16 changed files with 549 additions and 65 deletions
+78 -1
View File
@@ -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,
});
});
});