Merge branch 'feature-frontend-auth-session-integration-contract' into develop
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Creates the skeleton-owned side of an external session integration.
|
||||
* Credential acquisition and storage stay inside the supplied external owner.
|
||||
*
|
||||
* @param {{
|
||||
* readState(): import("../../application/ports/auth-session-port.js").SessionState,
|
||||
* attachCredential(request: Request): Promise<Request>,
|
||||
* recoverSession(): Promise<"restored" | "no-session">,
|
||||
* notifyUnauthenticated(): void
|
||||
* }} owner
|
||||
* @returns {import("../../application/ports/auth-session-port.js").AuthSessionPort}
|
||||
*/
|
||||
export function createExternalAuthSessionAdapter(owner) {
|
||||
return Object.freeze({
|
||||
getState() {
|
||||
return owner.readState();
|
||||
},
|
||||
async attach(request) {
|
||||
const attached = await owner.attachCredential(request);
|
||||
if (!(attached instanceof Request)) {
|
||||
throw new TypeError("Auth owner returned an invalid request");
|
||||
}
|
||||
return attached;
|
||||
},
|
||||
async recover() {
|
||||
const result = await owner.recoverSession();
|
||||
if (result !== "restored" && result !== "no-session") {
|
||||
throw new TypeError("Auth owner returned an invalid recovery state");
|
||||
}
|
||||
return result;
|
||||
},
|
||||
onUnauthenticated() {
|
||||
owner.notifyUnauthenticated();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createAnonymousSessionAdapter() {
|
||||
return createExternalAuthSessionAdapter({
|
||||
readState: () => "unauthenticated",
|
||||
attachCredential: async (request) => request,
|
||||
recoverSession: async () => "no-session",
|
||||
notifyUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
@@ -109,6 +109,11 @@ export function createHttpClient(dependencies) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (outcome.error.httpStatus === 401 && recoveryUsed) {
|
||||
authSession.onUnauthenticated();
|
||||
return outcome;
|
||||
}
|
||||
|
||||
if (!shouldRetry(operation, outcome.error, retryCount)) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||
import { createHttpClient } from "../../src/adapters/http/client.js";
|
||||
|
||||
let responseStatuses = [];
|
||||
const server = setupServer(
|
||||
http.get("https://api.test/api/sample/resources", () => {
|
||||
const status = responseStatuses.shift() ?? 200;
|
||||
if (status === 401) {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: { code: "UNAUTHENTICATED" },
|
||||
meta: { requestId: "request-1", traceId: "trace-1" },
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: [{ id: "resource-1", name: "Example" }],
|
||||
meta: { requestId: "request-2", traceId: "trace-1" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(() => {
|
||||
responseStatuses = [];
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
const clock = { now: () => 0, sleep: async () => {} };
|
||||
|
||||
describe("bounded 401 session recovery", () => {
|
||||
it("calls recovery once and replays a safe request once", async () => {
|
||||
responseStatuses = [401, 200];
|
||||
const recoverSession = vi.fn(async () => "restored");
|
||||
const authSession = createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated",
|
||||
attachCredential: async (request) => request,
|
||||
recoverSession,
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
});
|
||||
const client = createHttpClient({
|
||||
baseUrl: "https://api.test",
|
||||
authSession,
|
||||
clock,
|
||||
});
|
||||
|
||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(recoverSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops after a second 401 and notifies unauthenticated once", async () => {
|
||||
responseStatuses = [401, 401];
|
||||
const notifyUnauthenticated = vi.fn();
|
||||
const authSession = createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated",
|
||||
attachCredential: async (request) => request,
|
||||
recoverSession: async () => "restored",
|
||||
notifyUnauthenticated,
|
||||
});
|
||||
const client = createHttpClient({
|
||||
baseUrl: "https://api.test",
|
||||
authSession,
|
||||
clock,
|
||||
});
|
||||
|
||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "AUTH_REQUIRED" },
|
||||
});
|
||||
expect(notifyUnauthenticated).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("normalizes attach and invalid recovery failures", async () => {
|
||||
const attachFailure = createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated",
|
||||
attachCredential: async () => {
|
||||
throw new Error("credential detail");
|
||||
},
|
||||
recoverSession: async () => "restored",
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
});
|
||||
const client = createHttpClient({
|
||||
baseUrl: "https://api.test",
|
||||
authSession: attachFailure,
|
||||
clock,
|
||||
});
|
||||
|
||||
await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "AUTH_INTEGRATION_FAILURE" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createAnonymousSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
} from "../../src/adapters/auth/external-session-adapter.js";
|
||||
|
||||
describe("external AuthSessionPort adapter", () => {
|
||||
it("attaches opaque credentials without exposing a token-shaped session", async () => {
|
||||
const adapter = createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated",
|
||||
attachCredential: async (request) => {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("X-Session-Attached", "true");
|
||||
return new Request(request, { headers });
|
||||
},
|
||||
recoverSession: async () => "restored",
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
});
|
||||
|
||||
const request = await adapter.attach(new Request("https://api.test/resource"));
|
||||
expect(request.headers.get("X-Session-Attached")).toBe("true");
|
||||
expect(adapter.getState()).toBe("authenticated");
|
||||
expect(adapter).not.toHaveProperty("accessToken");
|
||||
expect(adapter).not.toHaveProperty("refreshToken");
|
||||
});
|
||||
|
||||
it("fails invalid recovery states closed", async () => {
|
||||
const adapter = createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated",
|
||||
attachCredential: async (request) => request,
|
||||
recoverSession: async () => "unexpected",
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(adapter.recover()).rejects.toThrow("invalid recovery state");
|
||||
});
|
||||
|
||||
it("provides a safe anonymous adapter", async () => {
|
||||
const adapter = createAnonymousSessionAdapter();
|
||||
expect(adapter.getState()).toBe("unauthenticated");
|
||||
await expect(adapter.recover()).resolves.toBe("no-session");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user