From cbcc06380ded1849b426f53762c2bb4cc694369f Mon Sep 17 00:00:00 2001 From: admin123 Date: Wed, 22 Apr 2026 10:49:28 +0800 Subject: [PATCH] feat: add protected api client --- src/shared/protected-api-client.ts | 62 +++++++++++++++++++++++++ tests/protected-api-client.test.ts | 73 ++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/shared/protected-api-client.ts create mode 100644 tests/protected-api-client.test.ts diff --git a/src/shared/protected-api-client.ts b/src/shared/protected-api-client.ts new file mode 100644 index 0000000..26239ea --- /dev/null +++ b/src/shared/protected-api-client.ts @@ -0,0 +1,62 @@ +import { isAuthResponseMessage } from "./auth-messages"; + +interface FetchResponseLike { + json(): Promise; + ok: boolean; + status: number; +} + +type FetchLike = ( + input: string, + init?: RequestInit +) => Promise; + +type SendMessageLike = (message: unknown) => Promise; + +export function createProtectedApiClient(options: { + baseUrl: string; + fetchImpl?: FetchLike; + sendMessage: SendMessageLike; +}) { + const fetchImpl = options.fetchImpl ?? fetch; + + return { + async loadProtectedMockData() { + const token = await readAccessToken(options.sendMessage); + const response = await fetchImpl( + new URL("/api/mock/protected", options.baseUrl).toString(), + { + headers: { + Authorization: `Bearer ${token}` + }, + method: "GET" + } + ); + + if (response.status === 401 || response.status === 403) { + throw new Error("protected api unauthorized"); + } + + if (!response.ok) { + throw new Error(`protected api request failed: ${response.status}`); + } + + return response.json(); + } + }; +} + +async function readAccessToken(sendMessage: SendMessageLike): Promise { + const response = await sendMessage({ type: "auth:get-access-token" }); + + if ( + !isAuthResponseMessage(response) || + !response.ok || + response.type !== "auth:token" || + !response.value.accessToken.trim() + ) { + throw new Error("protected api token unavailable"); + } + + return response.value.accessToken; +} diff --git a/tests/protected-api-client.test.ts b/tests/protected-api-client.test.ts new file mode 100644 index 0000000..b24cc4b --- /dev/null +++ b/tests/protected-api-client.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createProtectedApiClient } from "../src/shared/protected-api-client"; + +describe("protected-api-client", () => { + test("requests a token before calling the protected endpoint", async () => { + const sendMessage = vi.fn(async () => ({ + ok: true, + type: "auth:token", + value: { accessToken: "abc123" } + })); + const fetchImpl = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ ok: true }) + })); + + const client = createProtectedApiClient({ + baseUrl: "http://127.0.0.1:4319", + fetchImpl, + sendMessage + }); + + await client.loadProtectedMockData(); + + expect(sendMessage).toHaveBeenCalledWith({ type: "auth:get-access-token" }); + expect(fetchImpl).toHaveBeenCalledWith( + "http://127.0.0.1:4319/api/mock/protected", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer abc123" + }), + method: "GET" + }) + ); + }); + + test("throws before fetch when the token is unavailable", async () => { + const sendMessage = vi.fn(async () => ({ + ok: false, + type: "auth:error", + error: "token missing" + })); + const fetchImpl = vi.fn(); + + const client = createProtectedApiClient({ + baseUrl: "http://127.0.0.1:4319", + fetchImpl, + sendMessage + }); + + await expect(client.loadProtectedMockData()).rejects.toThrow(/token/i); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test("throws an authorization error on 401", async () => { + const client = createProtectedApiClient({ + baseUrl: "http://127.0.0.1:4319", + fetchImpl: vi.fn(async () => ({ + ok: false, + status: 401, + json: async () => ({ ok: false, error: "unauthorized" }) + })), + sendMessage: vi.fn(async () => ({ + ok: true, + type: "auth:token", + value: { accessToken: "abc123" } + })) + }); + + await expect(client.loadProtectedMockData()).rejects.toThrow(/unauthorized/i); + }); +});