feat(agent): add credential refresh and unify learning tools
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
CredentialRefreshCoordinator,
|
||||
CredentialRefreshError,
|
||||
runWithCredentialRefresh,
|
||||
} from "../../src/auth/credentialRefresh.js";
|
||||
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
const context = (overrides: Partial<RuntimeSessionContext> = {}) => ({
|
||||
accessToken: "old-token",
|
||||
actorKey: "user-1",
|
||||
clientSessionId: "client-1",
|
||||
projectId: "project-1",
|
||||
projectKey: "project-1",
|
||||
sessionId: "session-1",
|
||||
traceId: "trace-1",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("CredentialRefreshCoordinator", () => {
|
||||
test("deduplicates concurrent refreshes for one session", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator();
|
||||
const requestIds: string[] = [];
|
||||
coordinator.subscribe("session-1", (event) => {
|
||||
if (event.type === "credential_refresh_required") {
|
||||
requestIds.push(event.requestId);
|
||||
}
|
||||
});
|
||||
const expired = context({ tokenExpiresAt: new Date(0).toISOString() });
|
||||
const execute = async (active: RuntimeSessionContext) => ({
|
||||
status: 200,
|
||||
token: active.accessToken,
|
||||
});
|
||||
|
||||
const first = runWithCredentialRefresh(coordinator, expired, execute);
|
||||
const second = runWithCredentialRefresh(coordinator, expired, execute);
|
||||
await Promise.resolve();
|
||||
expect(requestIds).toHaveLength(1);
|
||||
expect(coordinator.getPendingEvent("session-1")).toMatchObject({
|
||||
type: "credential_refresh_required",
|
||||
requestId: requestIds[0],
|
||||
reason: "access_token_expired",
|
||||
});
|
||||
coordinator.resolve(
|
||||
"session-1",
|
||||
requestIds[0]!,
|
||||
context({ accessToken: "fresh-token" }),
|
||||
);
|
||||
|
||||
expect(await first).toEqual({ status: 200, token: "fresh-token" });
|
||||
expect(await second).toEqual({ status: 200, token: "fresh-token" });
|
||||
expect(coordinator.getPendingEvent("session-1")).toBeNull();
|
||||
});
|
||||
|
||||
test("retries one time on 401 and never refreshes a 403", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator();
|
||||
let requestId = "";
|
||||
coordinator.subscribe("session-1", (event) => {
|
||||
if (event.type === "credential_refresh_required") {
|
||||
requestId = event.requestId;
|
||||
}
|
||||
});
|
||||
let attempts = 0;
|
||||
const resultPromise = runWithCredentialRefresh(
|
||||
coordinator,
|
||||
context(),
|
||||
async () => ({ status: ++attempts === 1 ? 401 : 401 }),
|
||||
);
|
||||
await Promise.resolve();
|
||||
coordinator.resolve("session-1", requestId, context({ accessToken: "fresh-token" }));
|
||||
expect((await resultPromise).status).toBe(401);
|
||||
expect(attempts).toBe(2);
|
||||
|
||||
requestId = "";
|
||||
expect(
|
||||
(await runWithCredentialRefresh(coordinator, context(), async () => ({ status: 403 })))
|
||||
.status,
|
||||
).toBe(403);
|
||||
expect(requestId).toBe("");
|
||||
});
|
||||
|
||||
test("fails explicitly when no event stream can refresh credentials", async () => {
|
||||
await expect(
|
||||
runWithCredentialRefresh(
|
||||
new CredentialRefreshCoordinator(),
|
||||
context({ tokenExpiresAt: new Date(0).toISOString() }),
|
||||
async () => ({ status: 200 }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(CredentialRefreshError);
|
||||
});
|
||||
|
||||
test("reports run cancellation separately from authentication failure", async () => {
|
||||
const coordinator = new CredentialRefreshCoordinator();
|
||||
coordinator.subscribe("session-1", () => undefined);
|
||||
const pending = coordinator.request("session-1", "access_token_rejected");
|
||||
coordinator.cancelSession("session-1");
|
||||
await expect(pending).rejects.toMatchObject({ code: "cancelled" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user