diff --git a/src/lib/auth.test.ts b/src/lib/auth.test.ts new file mode 100644 index 0000000..d8bdf96 --- /dev/null +++ b/src/lib/auth.test.ts @@ -0,0 +1,22 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { buildAuth } from "./auth.ts" + +test("no password -> no auth (open server)", () => { + assert.equal(buildAuth("opencode", undefined), undefined) + assert.equal(buildAuth("opencode", null), undefined) + assert.equal(buildAuth("opencode", ""), undefined) +}) + +test("password but no username -> defaults username to 'opencode' (Quick Connect fix)", () => { + // Regression guard: Quick Connect has no username field. Before the fix, an empty + // username made auth undefined and the app sent NO Authorization header -> 401. + assert.deepEqual(buildAuth(undefined, "secret"), { username: "opencode", password: "secret" }) + assert.deepEqual(buildAuth("", "secret"), { username: "opencode", password: "secret" }) + assert.deepEqual(buildAuth(" ", "secret"), { username: "opencode", password: "secret" }) +}) + +test("explicit username preserved (Advanced mode), trimmed", () => { + assert.deepEqual(buildAuth("alice", "secret"), { username: "alice", password: "secret" }) + assert.deepEqual(buildAuth(" alice ", "secret"), { username: "alice", password: "secret" }) +}) diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..23da67c --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,22 @@ +// Pure Basic-auth credential construction for opencode connections. +// Extracted from stores/connections.ts so the username-defaulting rule is unit-testable +// without pulling in zustand/expo (which have no resolver outside Metro). + +export interface BasicAuth { + username: string + password: string +} + +// The opencode server's default Basic-auth username is "opencode" +// (OPENCODE_SERVER_USERNAME ?? "opencode"). Quick Connect collects only a password +// (it has no username field), so when a password is set but no username, default the +// username to "opencode". Without this, an empty username made auth undefined entirely — +// no Authorization header was sent — and a password-protected server returned 401, +// i.e. the common "I set a password and now it won't connect" failure. +export function buildAuth( + username: string | null | undefined, + password: string | null | undefined, +): BasicAuth | undefined { + if (!password) return undefined + return { username: username?.trim() || "opencode", password } +} diff --git a/src/stores/connections.ts b/src/stores/connections.ts index a96de05..a2700dd 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -4,6 +4,7 @@ import * as Crypto from "expo-crypto" import type { ServerConnection, ConnectionType } from "../lib/types" import { createClient, type Client, type Project } from "../lib/sdk" import { addBreadcrumb } from "../lib/sentry" +import { buildAuth } from "../lib/auth" const CONNECTIONS_KEY = "opencode_connections" const PASSWORDS_PREFIX = "opencode_password_" @@ -88,7 +89,7 @@ export const useConnections = create((set, get) => ({ let home: string | null = null if (active) { const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`) - const auth = active.username && password ? { username: active.username, password } : undefined + const auth = buildAuth(active.username, password) const built = buildClient(active.url, active.directory, auth) client = built.client base = built.base @@ -147,7 +148,7 @@ export const useConnections = create((set, get) => ({ if (newConnection.active) { activeConnection = newConnection - const auth = newConnection.username && password ? { username: newConnection.username, password } : undefined + const auth = buildAuth(newConnection.username, password) const built = buildClient(newConnection.url, newConnection.directory, auth) client = built.client base = built.base @@ -185,7 +186,7 @@ export const useConnections = create((set, get) => ({ newActive.active = true await SecureStore.setItemAsync(CONNECTIONS_KEY, JSON.stringify(connections)) const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${newActive.id}`) - const auth = newActive.username && password ? { username: newActive.username, password } : undefined + const auth = buildAuth(newActive.username, password) const built = buildClient(newActive.url, newActive.directory, auth) set({ connections, activeConnection: newActive, client: built.client, clientBase: built.base }) } else { @@ -212,7 +213,7 @@ export const useConnections = create((set, get) => ({ if (active) { const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${active.id}`) - const auth = active.username && password ? { username: active.username, password } : undefined + const auth = buildAuth(active.username, password) const built = buildClient(active.url, active.directory, auth) client = built.client base = built.base @@ -246,7 +247,7 @@ export const useConnections = create((set, get) => ({ const client = createClient({ baseUrl: connection.url, directory: connection.directory, - auth: connection.username && password ? { username: connection.username, password } : undefined, + auth: buildAuth(connection.username, password), }) await client.global.health() @@ -265,7 +266,7 @@ export const useConnections = create((set, get) => ({ if (get().activeConnection?.id === id) { const active = connections.find((c) => c.id === id)! const password = await SecureStore.getItemAsync(`${PASSWORDS_PREFIX}${id}`) - const auth = active.username && password ? { username: active.username, password } : undefined + const auth = buildAuth(active.username, password) const built = buildClient(active.url, active.directory, auth) try { const [project, paths] = await Promise.all([