Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,21 @@ cli:node:smoke:
- node ./cli/dist/bin/postgres-ai.js mon targets list | head -n 1 || true
- node ./cli/dist/bin/postgres-ai.js mon targets add 'postgresql://user:pass@host:5432/db' ci-test || true
- node ./cli/dist/bin/postgres-ai.js mon targets remove ci-test || true
# Verify production OAuth endpoint is reachable (smoke test for auth flow)
- |
echo "Testing OAuth endpoint reachability..."
# Generate random state and code_challenge for smoke test (these are throwaway values)
CI_STATE=$(openssl rand -base64 16 | tr -d '/+=')
CI_CHALLENGE=$(openssl rand -base64 32 | tr -d '/+=')
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "{\"client_type\":\"cli\",\"state\":\"${CI_STATE}\",\"code_challenge\":\"${CI_CHALLENGE}\",\"code_challenge_method\":\"S256\",\"redirect_uri\":\"http://localhost:0/callback\"}" \
"https://postgres.ai/api/general/rpc/oauth_init" || echo "000")
echo "OAuth init endpoint returned HTTP $HTTP_CODE"
if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "201" ]; then
echo "WARNING: OAuth endpoint returned unexpected status (expected 200/201, got $HTTP_CODE)"
echo "This may indicate the OAuth endpoint is misconfigured or unreachable"
fi
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'

Expand Down
16 changes: 10 additions & 6 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,17 +303,24 @@ Normalization:

### Examples

Linux/macOS (bash/zsh):
For production (uses default URLs):

```bash
# Production auth - uses console.postgres.ai by default
postgresai auth --debug
```

For staging/development environments:

```bash
# Linux/macOS (bash/zsh)
export PGAI_API_BASE_URL=https://v2.postgres.ai/api/general/
export PGAI_UI_BASE_URL=https://console-dev.postgres.ai
postgresai auth --debug
```

Windows PowerShell:

```powershell
# Windows PowerShell
$env:PGAI_API_BASE_URL = "https://v2.postgres.ai/api/general/"
$env:PGAI_UI_BASE_URL = "https://console-dev.postgres.ai"
postgresai auth --debug
Expand All @@ -327,9 +334,6 @@ postgresai auth --debug \
--ui-base-url https://console-dev.postgres.ai
```

Notes:
- If `PGAI_UI_BASE_URL` is not set, the default is `https://console.postgres.ai`.

## Requirements

- Node.js 18 or higher
Expand Down
3 changes: 2 additions & 1 deletion cli/bin/postgres-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2102,7 +2102,8 @@ auth
}

// Step 3: Open browser
const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}`;
// Pass api_url so UI calls oauth_approve on the same backend where oauth_init created the session
const authUrl = `${uiBaseUrl}/cli/auth?state=${encodeURIComponent(params.state)}&code_challenge=${encodeURIComponent(params.codeChallenge)}&code_challenge_method=S256&redirect_uri=${encodeURIComponent(redirectUri)}&api_url=${encodeURIComponent(apiBaseUrl)}`;

if (opts.debug) {
console.log(`Debug: Auth URL: ${authUrl}`);
Expand Down
258 changes: 258 additions & 0 deletions cli/test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { describe, test, expect } from "bun:test";
import { resolve } from "path";

import * as util from "../lib/util";
import * as pkce from "../lib/pkce";
import * as authServer from "../lib/auth-server";

function runCli(args: string[], env: Record<string, string> = {}) {
const cliPath = resolve(import.meta.dir, "..", "bin", "postgres-ai.ts");
const bunBin = typeof process.execPath === "string" && process.execPath.length > 0 ? process.execPath : "bun";
const result = Bun.spawnSync([bunBin, cliPath, ...args], {
env: { ...process.env, ...env },
});
return {
status: result.exitCode,
stdout: new TextDecoder().decode(result.stdout),
stderr: new TextDecoder().decode(result.stderr),
};
}

describe("URL resolution", () => {
test("resolveBaseUrls returns correct production defaults", () => {
const result = util.resolveBaseUrls();
expect(result.apiBaseUrl).toBe("https://postgres.ai/api/general");
expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
});

test("resolveBaseUrls strips trailing slashes", () => {
const result = util.resolveBaseUrls({
apiBaseUrl: "https://example.com/api/",
uiBaseUrl: "https://example.com/",
});
expect(result.apiBaseUrl).toBe("https://example.com/api");
expect(result.uiBaseUrl).toBe("https://example.com");
});

test("resolveBaseUrls respects environment variables", () => {
const originalApiUrl = process.env.PGAI_API_BASE_URL;
const originalUiUrl = process.env.PGAI_UI_BASE_URL;

try {
process.env.PGAI_API_BASE_URL = "https://custom-api.example.com/api/";
process.env.PGAI_UI_BASE_URL = "https://custom-ui.example.com/";

const result = util.resolveBaseUrls();
expect(result.apiBaseUrl).toBe("https://custom-api.example.com/api");
expect(result.uiBaseUrl).toBe("https://custom-ui.example.com");
} finally {
if (originalApiUrl === undefined) {
delete process.env.PGAI_API_BASE_URL;
} else {
process.env.PGAI_API_BASE_URL = originalApiUrl;
}
if (originalUiUrl === undefined) {
delete process.env.PGAI_UI_BASE_URL;
} else {
process.env.PGAI_UI_BASE_URL = originalUiUrl;
}
}
});

test("resolveBaseUrls prefers CLI options over env vars", () => {
const originalApiUrl = process.env.PGAI_API_BASE_URL;

try {
process.env.PGAI_API_BASE_URL = "https://env.example.com/api/";

const result = util.resolveBaseUrls({
apiBaseUrl: "https://cli-option.example.com/api/",
});
expect(result.apiBaseUrl).toBe("https://cli-option.example.com/api");
} finally {
if (originalApiUrl === undefined) {
delete process.env.PGAI_API_BASE_URL;
} else {
process.env.PGAI_API_BASE_URL = originalApiUrl;
}
}
});

test("resolveBaseUrls uses config baseUrl for API", () => {
const result = util.resolveBaseUrls({}, { baseUrl: "https://config.example.com/api/" });
expect(result.apiBaseUrl).toBe("https://config.example.com/api");
// UI should still use default since config doesn't have uiBaseUrl
expect(result.uiBaseUrl).toBe("https://console.postgres.ai");
});

test("normalizeBaseUrl throws on invalid URL", () => {
expect(() => util.normalizeBaseUrl("not-a-url")).toThrow(/Invalid base URL/);
});

test("normalizeBaseUrl accepts valid URLs", () => {
expect(util.normalizeBaseUrl("https://example.com")).toBe("https://example.com");
expect(util.normalizeBaseUrl("https://example.com/")).toBe("https://example.com");
expect(util.normalizeBaseUrl("https://example.com/api/")).toBe("https://example.com/api");
});
});

describe("PKCE module", () => {
test("generateCodeVerifier returns correct length string", () => {
const verifier = pkce.generateCodeVerifier();
expect(typeof verifier).toBe("string");
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(verifier.length).toBeLessThanOrEqual(128);
});

test("generateCodeChallenge returns base64url encoded SHA256", () => {
const verifier = pkce.generateCodeVerifier();
const challenge = pkce.generateCodeChallenge(verifier);
expect(typeof challenge).toBe("string");
expect(challenge.length).toBeGreaterThan(0);
// Base64url encoding should not contain + or / characters
expect(challenge).not.toMatch(/[+/]/);
});

test("generateState returns random string", () => {
const state1 = pkce.generateState();
const state2 = pkce.generateState();
expect(typeof state1).toBe("string");
expect(state1.length).toBeGreaterThan(0);
expect(state1).not.toBe(state2); // Should be random
});

test("generatePKCEParams returns all required parameters", () => {
const params = pkce.generatePKCEParams();
expect(params.codeVerifier).toBeTruthy();
expect(params.codeChallenge).toBeTruthy();
expect(params.codeChallengeMethod).toBe("S256");
expect(params.state).toBeTruthy();
});
});

describe("Auth callback server", () => {
test("createCallbackServer returns correct interface", () => {
const server = authServer.createCallbackServer(0, "test-state", 1000);
expect(server.server).toBeTruthy();
expect(server.server.stop).toBeInstanceOf(Function);
expect(server.promise).toBeInstanceOf(Promise);
expect(server.ready).toBeInstanceOf(Promise);
expect(server.getPort).toBeInstanceOf(Function);

// Clean up
server.server.stop();
});

test("createCallbackServer binds to a port", async () => {
const server = authServer.createCallbackServer(0, "test-state", 5000);
const port = await server.ready;
expect(typeof port).toBe("number");
expect(port).toBeGreaterThan(0);

// Clean up
server.server.stop();
});

test("createCallbackServer responds to callback requests", async () => {
const testState = "test-state-" + Math.random().toString(36).substring(7);
const server = authServer.createCallbackServer(0, testState, 5000);
const port = await server.ready;

// Simulate OAuth callback
const testCode = "test-auth-code";
const callbackUrl = `http://127.0.0.1:${port}/callback?code=${testCode}&state=${testState}`;

const fetchPromise = fetch(callbackUrl);
const result = await server.promise;

expect(result.code).toBe(testCode);
expect(result.state).toBe(testState);

// Check response
const response = await fetchPromise;
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toMatch(/Authentication successful/);
});

test("createCallbackServer rejects on state mismatch", async () => {
const server = authServer.createCallbackServer(0, "expected-state", 5000);
const port = await server.ready;

const callbackUrl = `http://127.0.0.1:${port}/callback?code=test-code&state=wrong-state`;

const fetchPromise = fetch(callbackUrl);

await expect(server.promise).rejects.toThrow(/State mismatch/);

const response = await fetchPromise;
expect(response.status).toBe(400);
});

test("createCallbackServer handles OAuth errors", async () => {
const server = authServer.createCallbackServer(0, "test-state", 5000);
const port = await server.ready;

const callbackUrl = `http://127.0.0.1:${port}/callback?error=access_denied&error_description=User%20denied%20access`;

const fetchPromise = fetch(callbackUrl);

await expect(server.promise).rejects.toThrow(/OAuth error: access_denied/);

const response = await fetchPromise;
expect(response.status).toBe(400);
});

test("createCallbackServer times out", async () => {
const server = authServer.createCallbackServer(0, "test-state", 100); // 100ms timeout
await server.ready;

await expect(server.promise).rejects.toThrow(/timeout/i);
});
});

describe("CLI auth commands", () => {
test("cli: auth login --help shows all options", () => {
const r = runCli(["auth", "login", "--help"]);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/--set-key/);
expect(r.stdout).toMatch(/--debug/);
});

test("cli: auth show-key --help works", () => {
const r = runCli(["auth", "show-key", "--help"]);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/show.*key/i);
});

test("cli: auth remove-key --help works", () => {
const r = runCli(["auth", "remove-key", "--help"]);
expect(r.status).toBe(0);
expect(r.stdout).toMatch(/remove.*key/i);
});
});

describe("maskSecret utility", () => {
test("masks short secrets completely", () => {
expect(util.maskSecret("abc")).toBe("****");
expect(util.maskSecret("12345678")).toBe("****");
});

test("masks medium secrets with visible ends", () => {
const masked = util.maskSecret("1234567890123456");
// maskSecret shows first 4 chars, middle masked, last 4 chars for 16-char strings
expect(masked).toMatch(/^1234\*+3456$/);
});

test("masks long secrets appropriately", () => {
const secret = "abcdefghij1234567890klmnopqrstuvwxyz";
const masked = util.maskSecret(secret);
expect(masked.startsWith("abcdefghij12")).toBe(true);
expect(masked.endsWith("wxyz")).toBe(true);
expect(masked).toMatch(/\*+/);
});

test("handles empty string", () => {
expect(util.maskSecret("")).toBe("");
});
});