101 lines
3.5 KiB
TypeScript
101 lines
3.5 KiB
TypeScript
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" });
|
|
});
|
|
});
|