|
| 1 | +import { describe, test, expect } from "bun:test"; |
| 2 | +import { resolve } from "path"; |
| 3 | + |
| 4 | +import * as util from "../lib/util"; |
| 5 | +import * as pkce from "../lib/pkce"; |
| 6 | +import * as authServer from "../lib/auth-server"; |
| 7 | + |
| 8 | +function runCli(args: string[], env: Record<string, string> = {}) { |
| 9 | + const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts"); |
| 10 | + const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun"; |
| 11 | + const result = Bun.spawnSync([bunBin, cliPath, ...args], { |
| 12 | + env: { ...process.env, ...env }, |
| 13 | + }); |
| 14 | + return { |
| 15 | + status: result.exitCode, |
| 16 | + stdout: new TextDecoder().decode(result.stdout), |
| 17 | + stderr: new TextDecoder().decode(result.stderr), |
| 18 | + }; |
| 19 | +} |
| 20 | + |
| 21 | +describe("URL resolution", () => { |
| 22 | + test("resolveBaseUrls returns correct production defaults", () => { |
| 23 | + const result = util.resolveBaseUrls(); |
| 24 | + expect(result.apiBaseUrl).toBe("https://console.postgres.ai/api/general"); |
| 25 | + expect(result.uiBaseUrl).toBe("https://console.postgres.ai"); |
| 26 | + }); |
| 27 | + |
| 28 | + test("resolveBaseUrls strips trailing slashes", () => { |
| 29 | + const result = util.resolveBaseUrls({ |
| 30 | + apiBaseUrl: "https://example.com/api/", |
| 31 | + uiBaseUrl: "https://example.com/", |
| 32 | + }); |
| 33 | + expect(result.apiBaseUrl).toBe("https://example.com/api"); |
| 34 | + expect(result.uiBaseUrl).toBe("https://example.com"); |
| 35 | + }); |
| 36 | + |
| 37 | + test("resolveBaseUrls respects environment variables", () => { |
| 38 | + const originalApiUrl = process.env.PGAI_API_BASE_URL; |
| 39 | + const originalUiUrl = process.env.PGAI_UI_BASE_URL; |
| 40 | + |
| 41 | + try { |
| 42 | + process.env.PGAI_API_BASE_URL = "https://custom-api.example.com/api/"; |
| 43 | + process.env.PGAI_UI_BASE_URL = "https://custom-ui.example.com/"; |
| 44 | + |
| 45 | + const result = util.resolveBaseUrls(); |
| 46 | + expect(result.apiBaseUrl).toBe("https://custom-api.example.com/api"); |
| 47 | + expect(result.uiBaseUrl).toBe("https://custom-ui.example.com"); |
| 48 | + } finally { |
| 49 | + if (originalApiUrl === undefined) { |
| 50 | + delete process.env.PGAI_API_BASE_URL; |
| 51 | + } else { |
| 52 | + process.env.PGAI_API_BASE_URL = originalApiUrl; |
| 53 | + } |
| 54 | + if (originalUiUrl === undefined) { |
| 55 | + delete process.env.PGAI_UI_BASE_URL; |
| 56 | + } else { |
| 57 | + process.env.PGAI_UI_BASE_URL = originalUiUrl; |
| 58 | + } |
| 59 | + } |
| 60 | + }); |
| 61 | + |
| 62 | + test("resolveBaseUrls prefers CLI options over env vars", () => { |
| 63 | + const originalApiUrl = process.env.PGAI_API_BASE_URL; |
| 64 | + |
| 65 | + try { |
| 66 | + process.env.PGAI_API_BASE_URL = "https://env.example.com/api/"; |
| 67 | + |
| 68 | + const result = util.resolveBaseUrls({ |
| 69 | + apiBaseUrl: "https://cli-option.example.com/api/", |
| 70 | + }); |
| 71 | + expect(result.apiBaseUrl).toBe("https://cli-option.example.com/api"); |
| 72 | + } finally { |
| 73 | + if (originalApiUrl === undefined) { |
| 74 | + delete process.env.PGAI_API_BASE_URL; |
| 75 | + } else { |
| 76 | + process.env.PGAI_API_BASE_URL = originalApiUrl; |
| 77 | + } |
| 78 | + } |
| 79 | + }); |
| 80 | + |
| 81 | + test("resolveBaseUrls uses config baseUrl for API", () => { |
| 82 | + const result = util.resolveBaseUrls({}, { baseUrl: "https://config.example.com/api/" }); |
| 83 | + expect(result.apiBaseUrl).toBe("https://config.example.com/api"); |
| 84 | + // UI should still use default since config doesn't have uiBaseUrl |
| 85 | + expect(result.uiBaseUrl).toBe("https://console.postgres.ai"); |
| 86 | + }); |
| 87 | + |
| 88 | + test("normalizeBaseUrl throws on invalid URL", () => { |
| 89 | + expect(() => util.normalizeBaseUrl("not-a-url")).toThrow(/Invalid base URL/); |
| 90 | + }); |
| 91 | + |
| 92 | + test("normalizeBaseUrl accepts valid URLs", () => { |
| 93 | + expect(util.normalizeBaseUrl("https://example.com")).toBe("https://example.com"); |
| 94 | + expect(util.normalizeBaseUrl("https://example.com/")).toBe("https://example.com"); |
| 95 | + expect(util.normalizeBaseUrl("https://example.com/api/")).toBe("https://example.com/api"); |
| 96 | + }); |
| 97 | +}); |
| 98 | + |
| 99 | +describe("PKCE module", () => { |
| 100 | + test("generateCodeVerifier returns correct length string", () => { |
| 101 | + const verifier = pkce.generateCodeVerifier(); |
| 102 | + expect(typeof verifier).toBe("string"); |
| 103 | + expect(verifier.length).toBeGreaterThanOrEqual(43); |
| 104 | + expect(verifier.length).toBeLessThanOrEqual(128); |
| 105 | + }); |
| 106 | + |
| 107 | + test("generateCodeChallenge returns base64url encoded SHA256", () => { |
| 108 | + const verifier = pkce.generateCodeVerifier(); |
| 109 | + const challenge = pkce.generateCodeChallenge(verifier); |
| 110 | + expect(typeof challenge).toBe("string"); |
| 111 | + expect(challenge.length).toBeGreaterThan(0); |
| 112 | + // Base64url encoding should not contain + or / characters |
| 113 | + expect(challenge).not.toMatch(/[+/]/); |
| 114 | + }); |
| 115 | + |
| 116 | + test("generateState returns random string", () => { |
| 117 | + const state1 = pkce.generateState(); |
| 118 | + const state2 = pkce.generateState(); |
| 119 | + expect(typeof state1).toBe("string"); |
| 120 | + expect(state1.length).toBeGreaterThan(0); |
| 121 | + expect(state1).not.toBe(state2); // Should be random |
| 122 | + }); |
| 123 | + |
| 124 | + test("generatePKCEParams returns all required parameters", () => { |
| 125 | + const params = pkce.generatePKCEParams(); |
| 126 | + expect(params.codeVerifier).toBeTruthy(); |
| 127 | + expect(params.codeChallenge).toBeTruthy(); |
| 128 | + expect(params.codeChallengeMethod).toBe("S256"); |
| 129 | + expect(params.state).toBeTruthy(); |
| 130 | + }); |
| 131 | +}); |
| 132 | + |
| 133 | +describe("Auth callback server", () => { |
| 134 | + test("createCallbackServer returns correct interface", () => { |
| 135 | + const server = authServer.createCallbackServer(0, "test-state", 1000); |
| 136 | + expect(server.server).toBeTruthy(); |
| 137 | + expect(server.server.stop).toBeInstanceOf(Function); |
| 138 | + expect(server.promise).toBeInstanceOf(Promise); |
| 139 | + expect(server.ready).toBeInstanceOf(Promise); |
| 140 | + expect(server.getPort).toBeInstanceOf(Function); |
| 141 | + |
| 142 | + // Clean up |
| 143 | + server.server.stop(); |
| 144 | + }); |
| 145 | + |
| 146 | + test("createCallbackServer binds to a port", async () => { |
| 147 | + const server = authServer.createCallbackServer(0, "test-state", 5000); |
| 148 | + const port = await server.ready; |
| 149 | + expect(typeof port).toBe("number"); |
| 150 | + expect(port).toBeGreaterThan(0); |
| 151 | + |
| 152 | + // Clean up |
| 153 | + server.server.stop(); |
| 154 | + }); |
| 155 | + |
| 156 | + test("createCallbackServer responds to callback requests", async () => { |
| 157 | + const testState = "test-state-" + Math.random().toString(36).substring(7); |
| 158 | + const server = authServer.createCallbackServer(0, testState, 5000); |
| 159 | + const port = await server.ready; |
| 160 | + |
| 161 | + // Simulate OAuth callback |
| 162 | + const testCode = "test-auth-code"; |
| 163 | + const callbackUrl = `http://127.0.0.1:${port}/callback?code=${testCode}&state=${testState}`; |
| 164 | + |
| 165 | + const fetchPromise = fetch(callbackUrl); |
| 166 | + const result = await server.promise; |
| 167 | + |
| 168 | + expect(result.code).toBe(testCode); |
| 169 | + expect(result.state).toBe(testState); |
| 170 | + |
| 171 | + // Check response |
| 172 | + const response = await fetchPromise; |
| 173 | + expect(response.status).toBe(200); |
| 174 | + const text = await response.text(); |
| 175 | + expect(text).toMatch(/Authentication successful/); |
| 176 | + }); |
| 177 | + |
| 178 | + test("createCallbackServer rejects on state mismatch", async () => { |
| 179 | + const server = authServer.createCallbackServer(0, "expected-state", 5000); |
| 180 | + const port = await server.ready; |
| 181 | + |
| 182 | + const callbackUrl = `http://127.0.0.1:${port}/callback?code=test-code&state=wrong-state`; |
| 183 | + |
| 184 | + const fetchPromise = fetch(callbackUrl); |
| 185 | + |
| 186 | + await expect(server.promise).rejects.toThrow(/State mismatch/); |
| 187 | + |
| 188 | + const response = await fetchPromise; |
| 189 | + expect(response.status).toBe(400); |
| 190 | + }); |
| 191 | + |
| 192 | + test("createCallbackServer handles OAuth errors", async () => { |
| 193 | + const server = authServer.createCallbackServer(0, "test-state", 5000); |
| 194 | + const port = await server.ready; |
| 195 | + |
| 196 | + const callbackUrl = `http://127.0.0.1:${port}/callback?error=access_denied&error_description=User%20denied%20access`; |
| 197 | + |
| 198 | + const fetchPromise = fetch(callbackUrl); |
| 199 | + |
| 200 | + await expect(server.promise).rejects.toThrow(/OAuth error: access_denied/); |
| 201 | + |
| 202 | + const response = await fetchPromise; |
| 203 | + expect(response.status).toBe(400); |
| 204 | + }); |
| 205 | + |
| 206 | + test("createCallbackServer times out", async () => { |
| 207 | + const server = authServer.createCallbackServer(0, "test-state", 100); // 100ms timeout |
| 208 | + await server.ready; |
| 209 | + |
| 210 | + await expect(server.promise).rejects.toThrow(/timeout/i); |
| 211 | + }); |
| 212 | +}); |
| 213 | + |
| 214 | +describe("CLI auth commands", () => { |
| 215 | + test("cli: auth login --help shows all options", () => { |
| 216 | + const r = runCli(["auth", "login", "--help"]); |
| 217 | + expect(r.status).toBe(0); |
| 218 | + expect(r.stdout).toMatch(/--set-key/); |
| 219 | + expect(r.stdout).toMatch(/--debug/); |
| 220 | + }); |
| 221 | + |
| 222 | + test("cli: auth show-key --help works", () => { |
| 223 | + const r = runCli(["auth", "show-key", "--help"]); |
| 224 | + expect(r.status).toBe(0); |
| 225 | + expect(r.stdout).toMatch(/show.*key/i); |
| 226 | + }); |
| 227 | + |
| 228 | + test("cli: auth remove-key --help works", () => { |
| 229 | + const r = runCli(["auth", "remove-key", "--help"]); |
| 230 | + expect(r.status).toBe(0); |
| 231 | + expect(r.stdout).toMatch(/remove.*key/i); |
| 232 | + }); |
| 233 | +}); |
| 234 | + |
| 235 | +describe("maskSecret utility", () => { |
| 236 | + test("masks short secrets completely", () => { |
| 237 | + expect(util.maskSecret("abc")).toBe("****"); |
| 238 | + expect(util.maskSecret("12345678")).toBe("****"); |
| 239 | + }); |
| 240 | + |
| 241 | + test("masks medium secrets with visible ends", () => { |
| 242 | + const masked = util.maskSecret("1234567890123456"); |
| 243 | + // maskSecret shows first 4 chars, middle masked, last 4 chars for 16-char strings |
| 244 | + expect(masked).toMatch(/^1234\*+3456$/); |
| 245 | + }); |
| 246 | + |
| 247 | + test("masks long secrets appropriately", () => { |
| 248 | + const secret = "abcdefghij1234567890klmnopqrstuvwxyz"; |
| 249 | + const masked = util.maskSecret(secret); |
| 250 | + expect(masked.startsWith("abcdefghij12")).toBe(true); |
| 251 | + expect(masked.endsWith("wxyz")).toBe(true); |
| 252 | + expect(masked).toMatch(/\*+/); |
| 253 | + }); |
| 254 | + |
| 255 | + test("handles empty string", () => { |
| 256 | + expect(util.maskSecret("")).toBe(""); |
| 257 | + }); |
| 258 | +}); |
0 commit comments