299 lines
9.3 KiB
TypeScript
299 lines
9.3 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { AuthStorage, type OAuthCredential, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
|
|
import { removeWithRetries } from "../../utils/src/temp";
|
|
|
|
const PROVIDER = "anthropic";
|
|
const PROVIDER_KEY = "anthropic:oauth";
|
|
const FUTURE_BLOCK_MS = 1_899_999_999_000;
|
|
const EXPIRED_BLOCK_MS = 1;
|
|
const LEGACY_TIMESTAMP = 1_700_000_000;
|
|
|
|
function oauthCredential(suffix: string): OAuthCredential {
|
|
return {
|
|
type: "oauth",
|
|
access: `access-${suffix}`,
|
|
refresh: `refresh-${suffix}`,
|
|
expires: Date.now() + 3_600_000,
|
|
accountId: `account-${suffix}`,
|
|
email: `${suffix}@example.com`,
|
|
};
|
|
}
|
|
|
|
function readAuthSchemaVersion(dbPath: string): number | null {
|
|
const db = new Database(dbPath, { readonly: true });
|
|
try {
|
|
const row = db.prepare("SELECT version FROM auth_schema_version WHERE id = 1").get() as
|
|
| { version?: number }
|
|
| undefined;
|
|
return typeof row?.version === "number" ? row.version : null;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function tableExists(dbPath: string, tableName: string): boolean {
|
|
const db = new Database(dbPath, { readonly: true });
|
|
try {
|
|
const row = db
|
|
.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
.get(tableName) as { present?: number } | undefined;
|
|
return row?.present === 1;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
describe("AuthStorage credential block persistence", () => {
|
|
let tempDir = "";
|
|
let dbPath = "";
|
|
|
|
beforeEach(async () => {
|
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-blocks-"));
|
|
dbPath = path.join(tempDir, "agent.db");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
dbPath = "";
|
|
if (tempDir) {
|
|
await removeWithRetries(tempDir);
|
|
tempDir = "";
|
|
}
|
|
});
|
|
|
|
it("honors scoped and unscoped blocks written by a previous AuthStorage instance", async () => {
|
|
const firstStore = await SqliteAuthCredentialStore.open(dbPath);
|
|
firstStore.saveOAuth(PROVIDER, oauthCredential("1"));
|
|
firstStore.saveOAuth(PROVIDER, oauthCredential("2"));
|
|
firstStore.saveOAuth(PROVIDER, oauthCredential("3"));
|
|
const rows = firstStore.listAuthCredentials(PROVIDER);
|
|
const firstStorage = new AuthStorage(firstStore);
|
|
await firstStorage.reload();
|
|
try {
|
|
firstStorage.upsertCredentialBlock({
|
|
credentialId: rows[0]!.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "tier:fable",
|
|
blockedUntilMs: FUTURE_BLOCK_MS,
|
|
});
|
|
firstStorage.upsertCredentialBlock({
|
|
credentialId: rows[1]!.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "",
|
|
blockedUntilMs: FUTURE_BLOCK_MS,
|
|
});
|
|
} finally {
|
|
firstStorage.close();
|
|
}
|
|
|
|
const reopenedStore = await SqliteAuthCredentialStore.open(dbPath);
|
|
const reopenedStorage = new AuthStorage(reopenedStore);
|
|
await reopenedStorage.reload();
|
|
try {
|
|
const fableKey = await reopenedStorage.getApiKey(PROVIDER, "session-3", { modelId: "claude-fable-5" });
|
|
expect(fableKey).toBe("access-3");
|
|
} finally {
|
|
reopenedStorage.close();
|
|
}
|
|
});
|
|
|
|
it("keeps the later expiry when a shorter block is upserted for the same key", async () => {
|
|
const store = await SqliteAuthCredentialStore.open(dbPath);
|
|
store.saveOAuth(PROVIDER, oauthCredential("1"));
|
|
const [row] = store.listAuthCredentials(PROVIDER);
|
|
if (!row) throw new Error("expected credential row");
|
|
const storage = new AuthStorage(store);
|
|
await storage.reload();
|
|
try {
|
|
const longerBlock = FUTURE_BLOCK_MS + 60_000;
|
|
storage.upsertCredentialBlock({
|
|
credentialId: row.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "tier:fable",
|
|
blockedUntilMs: longerBlock,
|
|
});
|
|
storage.upsertCredentialBlock({
|
|
credentialId: row.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "tier:fable",
|
|
blockedUntilMs: FUTURE_BLOCK_MS,
|
|
});
|
|
|
|
// `updatedAtMs` is the row's DB write time (issue #4980: same-deadline
|
|
// refreshes must be observable), so only its presence is asserted.
|
|
expect(storage.listCredentialBlocks([row.id])).toEqual([
|
|
{
|
|
credentialId: row.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "tier:fable",
|
|
blockedUntilMs: longerBlock,
|
|
updatedAtMs: expect.any(Number),
|
|
},
|
|
]);
|
|
} finally {
|
|
storage.close();
|
|
}
|
|
});
|
|
|
|
it("drops expired rows from reads and clears persisted blocks through the public delete wrapper", async () => {
|
|
const store = await SqliteAuthCredentialStore.open(dbPath);
|
|
store.saveOAuth(PROVIDER, oauthCredential("1"));
|
|
const [row] = store.listAuthCredentials(PROVIDER);
|
|
if (!row) throw new Error("expected credential row");
|
|
const storage = new AuthStorage(store);
|
|
await storage.reload();
|
|
try {
|
|
storage.upsertCredentialBlock({
|
|
credentialId: row.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "tier:fable",
|
|
blockedUntilMs: FUTURE_BLOCK_MS,
|
|
});
|
|
storage.upsertCredentialBlock({
|
|
credentialId: row.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "",
|
|
blockedUntilMs: EXPIRED_BLOCK_MS,
|
|
});
|
|
|
|
expect(storage.listCredentialBlocks([row.id])).toEqual([
|
|
{
|
|
credentialId: row.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "tier:fable",
|
|
blockedUntilMs: FUTURE_BLOCK_MS,
|
|
updatedAtMs: expect.any(Number),
|
|
},
|
|
]);
|
|
|
|
const generationBeforeDelete = storage.getGeneration();
|
|
storage.deleteCredentialBlocks(row.id);
|
|
expect(storage.listCredentialBlocks([row.id])).toEqual([]);
|
|
expect(storage.getGeneration()).toBe(generationBeforeDelete + 1);
|
|
} finally {
|
|
storage.close();
|
|
}
|
|
});
|
|
|
|
it("keeps a block attached to the same credential row after a sibling is disabled", async () => {
|
|
const store = await SqliteAuthCredentialStore.open(dbPath);
|
|
store.saveOAuth(PROVIDER, oauthCredential("1"));
|
|
store.saveOAuth(PROVIDER, oauthCredential("2"));
|
|
store.saveOAuth(PROVIDER, oauthCredential("3"));
|
|
const rows = store.listAuthCredentials(PROVIDER);
|
|
const storage = new AuthStorage(store);
|
|
await storage.reload();
|
|
try {
|
|
storage.upsertCredentialBlock({
|
|
credentialId: rows[1]!.id,
|
|
providerKey: PROVIDER_KEY,
|
|
blockScope: "",
|
|
blockedUntilMs: FUTURE_BLOCK_MS,
|
|
});
|
|
} finally {
|
|
storage.close();
|
|
}
|
|
|
|
const disablingStore = await SqliteAuthCredentialStore.open(dbPath);
|
|
disablingStore.deleteAuthCredential(rows[0]!.id, "disabled for test");
|
|
disablingStore.close();
|
|
|
|
const reopenedStore = await SqliteAuthCredentialStore.open(dbPath);
|
|
const reopenedStorage = new AuthStorage(reopenedStore);
|
|
await reopenedStorage.reload();
|
|
try {
|
|
const key = await reopenedStorage.getApiKey(PROVIDER, "a");
|
|
expect(key).toBe("access-3");
|
|
} finally {
|
|
reopenedStorage.close();
|
|
}
|
|
});
|
|
|
|
it("backfills refresh leases for a v5 auth database", async () => {
|
|
const legacyDb = new Database(dbPath);
|
|
legacyDb.run(`
|
|
CREATE TABLE auth_schema_version (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
version INTEGER NOT NULL
|
|
);
|
|
INSERT INTO auth_schema_version(id, version) VALUES (1, 5);
|
|
CREATE TABLE auth_credentials (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider TEXT NOT NULL,
|
|
credential_type TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
disabled_cause TEXT DEFAULT NULL,
|
|
identity_key TEXT DEFAULT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)),
|
|
updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
|
|
);
|
|
`);
|
|
legacyDb.close();
|
|
|
|
const migratedStore = await SqliteAuthCredentialStore.open(dbPath);
|
|
try {
|
|
const expiresAtMs = Date.now() + 3_600_000;
|
|
expect(migratedStore.tryAcquireCredentialRefreshLease(1, "test-owner", expiresAtMs)).toBe(true);
|
|
expect(migratedStore.getCredentialRefreshLeaseExpiresAt(1)).toBe(expiresAtMs);
|
|
expect(readAuthSchemaVersion(dbPath)).toBe(6);
|
|
} finally {
|
|
migratedStore.close();
|
|
}
|
|
});
|
|
|
|
it("migrates a v4 auth database to current version 6 without dropping credential rows", async () => {
|
|
const legacyDb = new Database(dbPath);
|
|
legacyDb.run(`
|
|
CREATE TABLE auth_schema_version (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
version INTEGER NOT NULL
|
|
);
|
|
INSERT INTO auth_schema_version(id, version) VALUES (1, 4);
|
|
CREATE TABLE auth_credentials (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider TEXT NOT NULL,
|
|
credential_type TEXT NOT NULL,
|
|
data TEXT NOT NULL,
|
|
disabled_cause TEXT DEFAULT NULL,
|
|
identity_key TEXT DEFAULT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER)),
|
|
updated_at INTEGER NOT NULL DEFAULT (CAST(strftime('%s','now') AS INTEGER))
|
|
);
|
|
`);
|
|
legacyDb
|
|
.prepare(
|
|
"INSERT INTO auth_credentials (provider, credential_type, data, disabled_cause, identity_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
)
|
|
.run(
|
|
PROVIDER,
|
|
"oauth",
|
|
JSON.stringify({
|
|
access: "legacy-access",
|
|
refresh: "legacy-refresh",
|
|
expires: Date.now() + 3_600_000,
|
|
accountId: "legacy-account",
|
|
email: "legacy@example.com",
|
|
}),
|
|
null,
|
|
"email:legacy@example.com",
|
|
LEGACY_TIMESTAMP,
|
|
LEGACY_TIMESTAMP,
|
|
);
|
|
legacyDb.close();
|
|
|
|
const migratedStore = await SqliteAuthCredentialStore.open(dbPath);
|
|
try {
|
|
const rows = migratedStore.listAuthCredentials(PROVIDER);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]!.credential).toMatchObject({ type: "oauth", access: "legacy-access" });
|
|
expect(readAuthSchemaVersion(dbPath)).toBe(6);
|
|
expect(tableExists(dbPath, "auth_credential_blocks")).toBe(true);
|
|
} finally {
|
|
migratedStore.close();
|
|
}
|
|
});
|
|
});
|