yarm6mesq: Refresh expired tokens and revoke on sign-out
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
endSession,
|
||||
ensureFreshSession,
|
||||
refreshSession,
|
||||
} from "@/lib/session-refresh";
|
||||
import { createSession, getSession, type SessionData } from "@/lib/session-store";
|
||||
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
// Seeds a session whose profile access token expires at `profileExpiresAt`
|
||||
// (and feed at `feedExpiresAt`), with known "old" tokens.
|
||||
function seedSession(
|
||||
profileExpiresAt: number | null,
|
||||
feedExpiresAt: number | null = null,
|
||||
): string {
|
||||
const data: SessionData = {
|
||||
profileAccessToken: "old-access",
|
||||
profileAccessTokenExpiresAt: profileExpiresAt,
|
||||
profileRefreshToken: "old-refresh",
|
||||
feedAccessToken: "old-feed",
|
||||
feedAccessTokenExpiresAt: feedExpiresAt,
|
||||
person: { userId: "u1", name: "Pat Rivera", email: "person@example.com", pictureUrl: null },
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
return createSession(data);
|
||||
}
|
||||
|
||||
// Stub for the refresh path: refresh-token -> new profile tokens, feed -> new feed token.
|
||||
function refreshFetchStub(
|
||||
overrides: Partial<Record<"refresh" | "feed", Response>> = {},
|
||||
) {
|
||||
return vi.fn(async (input: RequestInfo | URL): Promise<Response> => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/v4/auth/refresh-token")) {
|
||||
return (
|
||||
overrides.refresh ??
|
||||
json({ data: { accessToken: "new-access", refreshToken: "new-refresh", expiresIn: 3600 } })
|
||||
);
|
||||
}
|
||||
if (url.endsWith("/api/v4/auth/external-services/feed")) {
|
||||
return overrides.feed ?? json({ accessToken: "new-feed" });
|
||||
}
|
||||
throw new Error(`unexpected fetch to ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ensureFreshSession", () => {
|
||||
it("an access token expiring within 2 min triggers refresh; tokens change, no password", async () => {
|
||||
const id = seedSession(Date.now() + 60_000); // 1 min out: inside the 2-min window
|
||||
const fetchImpl = refreshFetchStub();
|
||||
|
||||
const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
expect(updated).not.toBeNull();
|
||||
expect(updated!.profileAccessToken).toBe("new-access");
|
||||
expect(updated!.profileRefreshToken).toBe("new-refresh");
|
||||
expect(updated!.feedAccessToken).toBe("new-feed");
|
||||
// Persisted under the same id.
|
||||
expect(getSession(id)!.profileAccessToken).toBe("new-access");
|
||||
// Refresh used the stored refresh token, never the login/password route.
|
||||
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/login"))).toBe(false);
|
||||
});
|
||||
|
||||
it("a feed token expiring within 2 min also triggers refresh", async () => {
|
||||
const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 30_000);
|
||||
const fetchImpl = refreshFetchStub();
|
||||
|
||||
const updated = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
expect(updated!.feedAccessToken).toBe("new-feed");
|
||||
expect(updated!.profileAccessToken).toBe("new-access");
|
||||
});
|
||||
|
||||
it("tokens comfortably in the future are not refreshed", async () => {
|
||||
const id = seedSession(Date.now() + 60 * 60_000, Date.now() + 60 * 60_000);
|
||||
const fetchImpl = refreshFetchStub();
|
||||
|
||||
const same = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
expect(same!.profileAccessToken).toBe("old-access");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("no cookie / unknown session is null", async () => {
|
||||
expect(await ensureFreshSession(undefined)).toBeNull();
|
||||
expect(await ensureFreshSession("does-not-exist")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshSession failure", () => {
|
||||
it("a failed refresh deletes the session", async () => {
|
||||
const id = seedSession(Date.now() + 60_000);
|
||||
const fetchImpl = refreshFetchStub({ refresh: json({ data: null }, 401) });
|
||||
|
||||
const result = await refreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getSession(id)).toBeNull();
|
||||
});
|
||||
|
||||
it("a failed feed exchange during refresh also deletes the session", async () => {
|
||||
const id = seedSession(Date.now() + 60_000);
|
||||
const fetchImpl = refreshFetchStub({ feed: json({ data: null }, 500) });
|
||||
|
||||
const result = await ensureFreshSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getSession(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("endSession", () => {
|
||||
it("revokes the refresh token and deletes the session", async () => {
|
||||
const id = seedSession(Date.now() + 60 * 60_000);
|
||||
const fetchImpl = vi.fn(async (_input: RequestInfo | URL) => json({ data: { success: true } }));
|
||||
|
||||
await endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch });
|
||||
|
||||
expect(fetchImpl.mock.calls.some(([u]) => String(u).endsWith("/api/v4/auth/revoke-token"))).toBe(true);
|
||||
expect(getSession(id)).toBeNull();
|
||||
});
|
||||
|
||||
it("still deletes the session when revoke throws", async () => {
|
||||
const id = seedSession(Date.now() + 60 * 60_000);
|
||||
const fetchImpl = vi.fn(async (_input: RequestInfo | URL): Promise<Response> => {
|
||||
throw new Error("revoke down");
|
||||
});
|
||||
|
||||
await expect(
|
||||
endSession(id, { fetchImpl: fetchImpl as unknown as typeof fetch }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(getSession(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user