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:
@@ -52,6 +52,60 @@ describe("NewConnectionScreen", () => {
|
||||
expect(screen.getByLabelText("User")).toHaveValue("u");
|
||||
});
|
||||
|
||||
it("paste a Neon URL pre-fills every required setting, including SSL mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(
|
||||
screen.getByLabelText(/connection uri/i),
|
||||
"postgresql://neondb_owner:npg_secret@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
|
||||
);
|
||||
// Every field the URL can supply is filled, so no manual cleanup is needed
|
||||
expect(screen.getByLabelText("Host")).toHaveValue(
|
||||
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
);
|
||||
expect(screen.getByLabelText("Port")).toHaveValue(5432);
|
||||
expect(screen.getByLabelText("User")).toHaveValue("neondb_owner");
|
||||
expect(screen.getByLabelText("Password")).toHaveValue("npg_secret");
|
||||
expect(screen.getByLabelText("Database")).toHaveValue("neondb");
|
||||
// The label stays untouched but is suggested from the database name.
|
||||
expect(screen.getByLabelText(/connection label/i)).toHaveValue("");
|
||||
expect(screen.getByLabelText(/connection label/i)).toHaveAttribute(
|
||||
"placeholder",
|
||||
"neondb",
|
||||
);
|
||||
|
||||
// SSL mode is visible on the SSH / SSL tab...
|
||||
await user.click(screen.getByRole("button", { name: /ssh \/ ssl/i }));
|
||||
await user.click(screen.getByRole("button", { name: "SSL" }));
|
||||
expect(screen.getByRole("button", { name: "SSL Mode" })).toHaveTextContent("Require");
|
||||
|
||||
// ...and reaches the save payload with no extra clicks.
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "neondb",
|
||||
db_type: "postgresql",
|
||||
ssl_mode: "require",
|
||||
username: "neondb_owner",
|
||||
password: "npg_secret",
|
||||
database: "neondb",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("a typed label wins over the URL-derived suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
|
||||
await user.type(screen.getByLabelText(/connection label/i), "My DB");
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "My DB" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking a provider tab reveals the form with that db_type", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
@@ -60,6 +114,19 @@ describe("NewConnectionScreen", () => {
|
||||
expect(screen.getByLabelText("Port")).toHaveValue(3306);
|
||||
});
|
||||
|
||||
it("NeonDB card preselects Require SSL in the save payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.click(screen.getByRole("button", { name: /NeonDB/i }));
|
||||
await user.type(screen.getByLabelText(/connection label/i), "Neon prod");
|
||||
await user.type(screen.getByLabelText("Host"), "ep-x-pooler.eu.aws.neon.tech");
|
||||
await user.click(screen.getByText("Save Connection"));
|
||||
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
|
||||
expect(createConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Neon prod", ssl_mode: "require" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("SQLite tab swaps the URI input for a File Path input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
@@ -103,6 +170,20 @@ describe("NewConnectionScreen", () => {
|
||||
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
|
||||
});
|
||||
|
||||
it("tests a pasted URL with its SSL mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<NewConnectionScreen />);
|
||||
await user.type(
|
||||
screen.getByLabelText(/connection uri/i),
|
||||
"postgresql://u:p@ep-x-pooler.eu.aws.neon.tech/db?sslmode=verify-full",
|
||||
);
|
||||
await user.click(screen.getByText("Test Connection"));
|
||||
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
|
||||
expect(testConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ssl_mode: "verify-full" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes onCancel when Back is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user