test(rde): integration-test access-only session reuse on re-sign-in

Exercises the real _signInToAccountWithTokens path: re-installing a freshly
minted access-only token that shares a refresh_token_id reuses the same
InternalSession object (so session-scoped caches stay warm and the UI doesn't
blank) and adopts the new token in place, while a token for a different session
still swaps to a new session object.
This commit is contained in:
Bilal Godil 2026-06-08 12:12:46 -07:00
parent ef168ba49f
commit b3b67a002a

View File

@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { StackClientApp } from "../interfaces/client-app";
/**
* Builds a decodable (unsigned) access-token JWT. `refreshTokenId` is the session identifier; `iatOffsetSeconds`
* lets two tokens for the same session differ as strings while sharing a `refresh_token_id`.
*/
function createAccessTokenString(refreshTokenId: string, options?: { iatOffsetSeconds?: number, sub?: string }): string {
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
const nowSeconds = Math.floor(Date.now() / 1000) + (options?.iatOffsetSeconds ?? 0);
return [
encode({ alg: "none", typ: "JWT" }),
encode({
sub: options?.sub ?? "user-id",
exp: nowSeconds + 60,
iat: nowSeconds,
iss: "https://api.example.test",
aud: "project-id",
project_id: "project-id",
branch_id: "main",
refresh_token_id: refreshTokenId,
role: "authenticated",
name: null,
email: null,
email_verified: false,
selected_team_id: null,
signed_up_at: nowSeconds,
is_anonymous: false,
is_restricted: false,
restricted_reason: null,
requires_totp_mfa: false,
}),
"",
].join(".");
}
describe("StackClientApp access-only session reuse", () => {
function createApp() {
const app = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId: "00000000-0000-4000-8000-000000000006",
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "none",
noAutomaticPrefetch: true,
});
// The sign-in path pre-warms the current-user cache; stub it so the test stays network-free.
Reflect.set(Reflect.get(app, "_currentUserCache"), "getOrWait", () => Promise.resolve());
return app;
}
async function signInWithAccessOnlyToken(app: StackClientApp, accessToken: string) {
const signInToAccount = Reflect.get(app, "_signInToAccountWithTokens");
if (typeof signInToAccount !== "function") {
throw new Error("Expected StackClientApp to expose _signInToAccountWithTokens in tests.");
}
await signInToAccount.call(app, { accessToken, refreshToken: "" });
}
function currentSession(app: StackClientApp) {
const getSessionFromTokenStore = Reflect.get(app, "_getSessionFromTokenStore");
if (typeof getSessionFromTokenStore !== "function") {
throw new Error("Expected StackClientApp to expose _getSessionFromTokenStore in tests.");
}
return getSessionFromTokenStore.call(app, Reflect.get(app, "_memoryTokenStore"));
}
it("reuses the same InternalSession object when an access-only token is re-minted", async () => {
const app = createApp();
const first = createAccessTokenString("rtid-1", { iatOffsetSeconds: 0 });
const reminted = createAccessTokenString("rtid-1", { iatOffsetSeconds: 1 });
expect(reminted).not.toBe(first);
await signInWithAccessOnlyToken(app, first);
const session1 = currentSession(app);
await signInWithAccessOnlyToken(app, reminted);
const session2 = currentSession(app);
// Same session object => useUser/useConfig/... keep their warm cache entries => no Suspense, no blank.
// (This is the regression: keying access-only sessions by the raw token used to spawn a new object here.)
expect(session2).toBe(session1);
// ...and the reused session adopts the freshly minted token in place.
expect(session2.getAccessTokenIfNotExpiredYet(20_000, null)?.token).toBe(reminted);
});
it("swaps to a new InternalSession when the token belongs to a different session", async () => {
const app = createApp();
await signInWithAccessOnlyToken(app, createAccessTokenString("rtid-1"));
const session1 = currentSession(app);
await signInWithAccessOnlyToken(app, createAccessTokenString("rtid-2", { sub: "other-user" }));
const session2 = currentSession(app);
expect(session2).not.toBe(session1);
});
});