457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
// Import the module to test the actual function behavior
|
|
import * as cliCredentials from "./cli-credentials.js";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
/**
|
|
* Helper to get node:sqlite for creating test databases.
|
|
* Returns null if node:sqlite is not available (e.g., on older Node versions or Windows).
|
|
*/
|
|
function tryGetNodeSqlite(): typeof import("node:sqlite") | null {
|
|
try {
|
|
return require("node:sqlite") as typeof import("node:sqlite");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if node:sqlite is available for tests that require it.
|
|
*/
|
|
const nodeSqlite = tryGetNodeSqlite();
|
|
const hasNodeSqlite = nodeSqlite !== null;
|
|
|
|
describe("cli-credentials", () => {
|
|
describe("findKiroCliDatabase", () => {
|
|
// Note: These tests verify the function's behavior with the actual home directory.
|
|
// The function checks for database files at:
|
|
// - ~/.local/share/kiro-cli/data.sqlite3
|
|
// - ~/.local/share/amazon-q/data.sqlite3
|
|
|
|
it("returns null when no database exists at expected paths", () => {
|
|
// This test verifies the function returns null when the databases don't exist.
|
|
// On most test machines, these paths won't exist, so we expect null.
|
|
const result = cliCredentials.findKiroCliDatabase();
|
|
// The result depends on whether kiro-cli is installed on the test machine
|
|
// We just verify it returns either a string path or null
|
|
expect(result === null || typeof result === "string").toBe(true);
|
|
});
|
|
|
|
it("returns a path string when database exists", () => {
|
|
const result = cliCredentials.findKiroCliDatabase();
|
|
if (result !== null) {
|
|
// If a database was found, verify it's a valid path string
|
|
expect(typeof result).toBe("string");
|
|
expect(result).toContain("data.sqlite3");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("findKiroCliDatabase with temp directory", () => {
|
|
// These tests create temporary directories to verify the path resolution logic
|
|
let tempHome: string;
|
|
|
|
beforeEach(() => {
|
|
tempHome = join(
|
|
tmpdir(),
|
|
`kiro-creds-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
mkdirSync(tempHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
try {
|
|
rmSync(tempHome, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
it("verifies database path structure is correct", () => {
|
|
// Create the expected directory structure
|
|
const kiroDir = join(tempHome, ".local", "share", "kiro-cli");
|
|
mkdirSync(kiroDir, { recursive: true });
|
|
const dbPath = join(kiroDir, "data.sqlite3");
|
|
writeFileSync(dbPath, "");
|
|
|
|
// Verify the path structure matches what we expect
|
|
expect(dbPath).toContain(".local");
|
|
expect(dbPath).toContain("share");
|
|
expect(dbPath).toContain("kiro-cli");
|
|
expect(dbPath).toContain("data.sqlite3");
|
|
});
|
|
|
|
it("verifies fallback path structure is correct", () => {
|
|
// Create the fallback directory structure
|
|
const amazonDir = join(tempHome, ".local", "share", "amazon-q");
|
|
mkdirSync(amazonDir, { recursive: true });
|
|
const dbPath = join(amazonDir, "data.sqlite3");
|
|
writeFileSync(dbPath, "");
|
|
|
|
// Verify the path structure matches what we expect
|
|
expect(dbPath).toContain(".local");
|
|
expect(dbPath).toContain("share");
|
|
expect(dbPath).toContain("amazon-q");
|
|
expect(dbPath).toContain("data.sqlite3");
|
|
});
|
|
});
|
|
|
|
describe("extractKiroCliToken", () => {
|
|
it("returns null when no database exists", () => {
|
|
// On most test machines without kiro-cli installed, this should return null
|
|
const result = cliCredentials.extractKiroCliToken();
|
|
// The result depends on whether kiro-cli is installed on the test machine
|
|
expect(result === null || typeof result === "object").toBe(true);
|
|
});
|
|
|
|
it("returns token with expected structure when database exists", () => {
|
|
const result = cliCredentials.extractKiroCliToken();
|
|
if (result !== null) {
|
|
// If a token was found, verify it has the expected structure
|
|
expect(result).toHaveProperty("access_token");
|
|
expect(result).toHaveProperty("refresh_token");
|
|
expect(result).toHaveProperty("expires_at");
|
|
expect(result).toHaveProperty("region");
|
|
expect(typeof result.access_token).toBe("string");
|
|
expect(typeof result.refresh_token).toBe("string");
|
|
expect(typeof result.expires_at).toBe("string");
|
|
expect(typeof result.region).toBe("string");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("extractKiroCliToken with temp database", () => {
|
|
let tempDir: string;
|
|
let dbPath: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = join(
|
|
tmpdir(),
|
|
`kiro-token-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
dbPath = join(tempDir, "data.sqlite3");
|
|
});
|
|
|
|
afterEach(() => {
|
|
try {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Creates a test SQLite database with the auth_kv table and optional token.
|
|
*/
|
|
function createTestDatabase(tokenKey?: string, tokenValue?: string): void {
|
|
if (!nodeSqlite) throw new Error("node:sqlite not available");
|
|
const { DatabaseSync } = nodeSqlite;
|
|
const db = new DatabaseSync(dbPath);
|
|
try {
|
|
db.exec("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)");
|
|
if (tokenKey && tokenValue) {
|
|
const stmt = db.prepare(
|
|
"INSERT INTO auth_kv (key, value) VALUES (?, ?)",
|
|
);
|
|
stmt.run(tokenKey, tokenValue);
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a valid token JSON string for testing.
|
|
*/
|
|
function createValidTokenJson(): string {
|
|
return JSON.stringify({
|
|
access_token: "test-access-token-12345",
|
|
refresh_token: "test-refresh-token-67890",
|
|
expires_at: "2026-12-31T23:59:59.000Z",
|
|
region: "us-east-1",
|
|
start_url: "https://test.awsapps.com/start",
|
|
oauth_flow: "DeviceCode",
|
|
scopes: ["codewhisperer:completions"],
|
|
});
|
|
}
|
|
|
|
it.skipIf(!hasNodeSqlite)(
|
|
"verifies database schema is correct for auth_kv table",
|
|
() => {
|
|
createTestDatabase();
|
|
|
|
// Verify the database was created with the correct schema
|
|
const { DatabaseSync } = nodeSqlite!;
|
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
try {
|
|
const stmt = db.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='auth_kv'",
|
|
);
|
|
const result = stmt.get() as { name: string } | undefined;
|
|
expect(result?.name).toBe("auth_kv");
|
|
} finally {
|
|
db.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasNodeSqlite)(
|
|
"verifies token can be stored and retrieved from database",
|
|
() => {
|
|
const tokenJson = createValidTokenJson();
|
|
createTestDatabase("kirocli:odic:token", tokenJson);
|
|
|
|
// Verify the token was stored correctly
|
|
const { DatabaseSync } = nodeSqlite!;
|
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
try {
|
|
const stmt = db.prepare("SELECT value FROM auth_kv WHERE key = ?");
|
|
const result = stmt.get("kirocli:odic:token") as
|
|
| { value: string }
|
|
| undefined;
|
|
expect(result?.value).toBe(tokenJson);
|
|
|
|
// Verify the JSON can be parsed back
|
|
const parsed = JSON.parse(result!.value);
|
|
expect(parsed.access_token).toBe("test-access-token-12345");
|
|
expect(parsed.region).toBe("us-east-1");
|
|
} finally {
|
|
db.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasNodeSqlite)(
|
|
"verifies fallback key can be stored and retrieved",
|
|
() => {
|
|
const tokenJson = createValidTokenJson();
|
|
createTestDatabase("codewhisperer:odic:token", tokenJson);
|
|
|
|
// Verify the fallback token was stored correctly
|
|
const { DatabaseSync } = nodeSqlite!;
|
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
try {
|
|
const stmt = db.prepare("SELECT value FROM auth_kv WHERE key = ?");
|
|
const result = stmt.get("codewhisperer:odic:token") as
|
|
| { value: string }
|
|
| undefined;
|
|
expect(result?.value).toBe(tokenJson);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(!hasNodeSqlite)(
|
|
"verifies primary key takes precedence over fallback",
|
|
() => {
|
|
// Create database with both keys
|
|
const { DatabaseSync } = nodeSqlite!;
|
|
const db = new DatabaseSync(dbPath);
|
|
try {
|
|
db.exec("CREATE TABLE auth_kv (key TEXT PRIMARY KEY, value TEXT)");
|
|
|
|
const primaryToken = JSON.stringify({
|
|
access_token: "primary-token",
|
|
refresh_token: "primary-refresh",
|
|
expires_at: "2026-12-31T23:59:59.000Z",
|
|
region: "us-east-1",
|
|
start_url: "https://test.awsapps.com/start",
|
|
oauth_flow: "DeviceCode",
|
|
scopes: [],
|
|
});
|
|
|
|
const fallbackToken = JSON.stringify({
|
|
access_token: "fallback-token",
|
|
refresh_token: "fallback-refresh",
|
|
expires_at: "2026-12-31T23:59:59.000Z",
|
|
region: "eu-west-1",
|
|
start_url: "https://test.awsapps.com/start",
|
|
oauth_flow: "DeviceCode",
|
|
scopes: [],
|
|
});
|
|
|
|
const stmt = db.prepare(
|
|
"INSERT INTO auth_kv (key, value) VALUES (?, ?)",
|
|
);
|
|
stmt.run("kirocli:odic:token", primaryToken);
|
|
stmt.run("codewhisperer:odic:token", fallbackToken);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
|
|
// Verify primary key is returned first when querying in order
|
|
const db2 = new DatabaseSync(dbPath, { readOnly: true });
|
|
try {
|
|
const stmt = db2.prepare("SELECT value FROM auth_kv WHERE key = ?");
|
|
const primary = stmt.get("kirocli:odic:token") as
|
|
| { value: string }
|
|
| undefined;
|
|
const fallback = stmt.get("codewhisperer:odic:token") as
|
|
| { value: string }
|
|
| undefined;
|
|
|
|
expect(primary).toBeDefined();
|
|
expect(fallback).toBeDefined();
|
|
|
|
const primaryParsed = JSON.parse(primary!.value);
|
|
const fallbackParsed = JSON.parse(fallback!.value);
|
|
|
|
expect(primaryParsed.access_token).toBe("primary-token");
|
|
expect(fallbackParsed.access_token).toBe("fallback-token");
|
|
} finally {
|
|
db2.close();
|
|
}
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("isTokenExpired", () => {
|
|
/**
|
|
* Helper to create a token with a specific expiration time.
|
|
*/
|
|
function createTokenWithExpiry(
|
|
expiresAt: string,
|
|
): cliCredentials.KiroCliToken {
|
|
return {
|
|
access_token: "test-access-token",
|
|
refresh_token: "test-refresh-token",
|
|
expires_at: expiresAt,
|
|
region: "us-east-1",
|
|
start_url: "https://test.awsapps.com/start",
|
|
oauth_flow: "DeviceCode",
|
|
scopes: ["codewhisperer:completions"],
|
|
};
|
|
}
|
|
|
|
describe("with default 5-minute buffer", () => {
|
|
it("returns true for token that expired in the past", () => {
|
|
// Token expired 1 hour ago
|
|
const pastDate = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
|
const token = createTokenWithExpiry(pastDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token)).toBe(true);
|
|
});
|
|
|
|
it("returns true for token expiring within 5 minutes", () => {
|
|
// Token expires in 3 minutes (within the 5-minute buffer)
|
|
const soonDate = new Date(Date.now() + 3 * 60 * 1000).toISOString();
|
|
const token = createTokenWithExpiry(soonDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token)).toBe(true);
|
|
});
|
|
|
|
it("returns true for token expiring exactly at buffer boundary", () => {
|
|
// Token expires in exactly 5 minutes (at the buffer boundary)
|
|
const exactDate = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
|
const token = createTokenWithExpiry(exactDate);
|
|
|
|
// At exactly the buffer boundary, currentTime + buffer >= expirationTime
|
|
expect(cliCredentials.isTokenExpired(token)).toBe(true);
|
|
});
|
|
|
|
it("returns false for token expiring after buffer period", () => {
|
|
// Token expires in 10 minutes (beyond the 5-minute buffer)
|
|
const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
const token = createTokenWithExpiry(futureDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token)).toBe(false);
|
|
});
|
|
|
|
it("returns false for token expiring far in the future", () => {
|
|
// Token expires in 1 year
|
|
const farFutureDate = new Date(
|
|
Date.now() + 365 * 24 * 60 * 60 * 1000,
|
|
).toISOString();
|
|
const token = createTokenWithExpiry(farFutureDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("with custom buffer", () => {
|
|
it("returns true when token expires within custom buffer", () => {
|
|
// Token expires in 30 seconds, buffer is 1 minute
|
|
const soonDate = new Date(Date.now() + 30 * 1000).toISOString();
|
|
const token = createTokenWithExpiry(soonDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token, 60 * 1000)).toBe(true);
|
|
});
|
|
|
|
it("returns false when token expires after custom buffer", () => {
|
|
// Token expires in 2 minutes, buffer is 1 minute
|
|
const futureDate = new Date(Date.now() + 2 * 60 * 1000).toISOString();
|
|
const token = createTokenWithExpiry(futureDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token, 60 * 1000)).toBe(false);
|
|
});
|
|
|
|
it("returns true with zero buffer for already expired token", () => {
|
|
// Token expired 1 second ago
|
|
const pastDate = new Date(Date.now() - 1000).toISOString();
|
|
const token = createTokenWithExpiry(pastDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token, 0)).toBe(true);
|
|
});
|
|
|
|
it("returns false with zero buffer for token expiring in future", () => {
|
|
// Token expires in 1 second
|
|
const futureDate = new Date(Date.now() + 1000).toISOString();
|
|
const token = createTokenWithExpiry(futureDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token, 0)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("edge cases", () => {
|
|
it("handles ISO 8601 timestamps with milliseconds", () => {
|
|
// Token expires in 10 minutes with millisecond precision
|
|
const futureDate = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
const token = createTokenWithExpiry(futureDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token)).toBe(false);
|
|
});
|
|
|
|
it("handles ISO 8601 timestamps with high precision", () => {
|
|
// Token format from kiro-cli: "2026-01-26T20:54:18.4194651Z"
|
|
const futureDate = "2099-01-26T20:54:18.4194651Z";
|
|
const token = createTokenWithExpiry(futureDate);
|
|
|
|
expect(cliCredentials.isTokenExpired(token)).toBe(false);
|
|
});
|
|
|
|
it("handles timestamps without timezone (treated as local)", () => {
|
|
// Token expires in 10 minutes, no Z suffix
|
|
const futureMs = Date.now() + 10 * 60 * 1000;
|
|
const futureDate = new Date(futureMs).toISOString().replace("Z", "");
|
|
const token = createTokenWithExpiry(futureDate);
|
|
|
|
// Note: Without Z, Date.parse may interpret as local time
|
|
// The function should still work correctly
|
|
const result = cliCredentials.isTokenExpired(token);
|
|
expect(typeof result).toBe("boolean");
|
|
});
|
|
|
|
it("returns true for invalid date string (NaN comparison)", () => {
|
|
// Invalid date string results in NaN, which makes comparison fail
|
|
const token = createTokenWithExpiry("invalid-date");
|
|
|
|
// NaN comparisons always return false, so currentTime + buffer >= NaN is false
|
|
// But we want to treat invalid dates as expired for safety
|
|
// Actually: Date.now() + buffer >= NaN evaluates to false
|
|
// So this returns false, but that's the JavaScript behavior
|
|
const result = cliCredentials.isTokenExpired(token);
|
|
// Invalid dates result in NaN, and any comparison with NaN returns false
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
});
|