import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { LogtoManagementClient } from "../../src/server/services/logto.service"; // Mock fetch at module level const originalFetch = globalThis.fetch; describe("LogtoManagementClient", () => { let client: LogtoManagementClient; let fetchMock: ReturnType; let fetchCalls: Array<{ url: string; options: RequestInit }>; beforeEach(() => { fetchCalls = []; fetchMock = mock((url: string, options: RequestInit) => { fetchCalls.push({ url: url.toString(), options }); // Default: token endpoint if (url.includes("/oidc/token")) { return Promise.resolve( new Response( JSON.stringify({ access_token: "test-token-123", expires_in: 3600, }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); } // GET user if ( url.includes("/api/users/") && options.method === "GET" && !url.includes("has-password") ) { return Promise.resolve( new Response( JSON.stringify({ id: "logto-sub-123", primaryEmail: "user@example.com", name: "Test User", avatar: null, createdAt: 1700000000000, }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); } // has-password if (url.includes("has-password")) { return Promise.resolve( new Response(JSON.stringify({ hasPassword: true }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); } // verify password if (url.includes("password/verify")) { return Promise.resolve(new Response(null, { status: 204 })); } // PATCH password if (url.includes("/password") && options.method === "PATCH") { return Promise.resolve(new Response(null, { status: 200 })); } // PATCH user (email update) if (options.method === "PATCH" && !url.includes("/password")) { return Promise.resolve(new Response(null, { status: 200 })); } // DELETE user if (options.method === "DELETE") { return Promise.resolve(new Response(null, { status: 204 })); } return Promise.resolve(new Response(null, { status: 404 })); }) as any; globalThis.fetch = fetchMock as any; client = new LogtoManagementClient({ issuer: "https://logto.example.com/oidc", m2mAppId: "test-app-id", m2mAppSecret: "test-app-secret", apiResource: "https://default.logto.app/api", }); }); afterEach(() => { globalThis.fetch = originalFetch; }); test("getAccessToken fetches token via client_credentials grant", async () => { const token = await client.getAccessToken(); expect(token).toBe("test-token-123"); const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token")); expect(tokenCall).toBeDefined(); expect(tokenCall!.options.method).toBe("POST"); expect(tokenCall!.options.headers).toHaveProperty( "Content-Type", "application/x-www-form-urlencoded", ); // Check Authorization header uses Basic auth const authHeader = (tokenCall!.options.headers as Record) .Authorization; expect(authHeader).toStartWith("Basic "); }); test("getAccessToken returns cached token on second call", async () => { await client.getAccessToken(); await client.getAccessToken(); // Only one call to token endpoint — second call uses cache const tokenCalls = fetchCalls.filter((c) => c.url.includes("/oidc/token")); expect(tokenCalls.length).toBe(1); }); test("getAccessToken refreshes token when expired", async () => { // Get initial token await client.getAccessToken(); // Simulate expiry by manipulating internal state // Access private field via any cast (client as any).tokenExpiry = Date.now() - 1000; await client.getAccessToken(); const tokenCalls = fetchCalls.filter((c) => c.url.includes("/oidc/token")); expect(tokenCalls.length).toBe(2); }); test("verifyPassword calls correct endpoint and returns true on 204", async () => { const result = await client.verifyPassword("sub-123", "my-password"); expect(result).toBe(true); const verifyCall = fetchCalls.find((c) => c.url.includes("/api/users/sub-123/password/verify"), ); expect(verifyCall).toBeDefined(); expect(verifyCall!.options.method).toBe("POST"); }); test("verifyPassword returns false on 422", async () => { // Override fetch for this specific test globalThis.fetch = mock((url: string, _options: RequestInit) => { if (url.includes("/oidc/token")) { return Promise.resolve( new Response( JSON.stringify({ access_token: "token", expires_in: 3600 }), { status: 200, headers: { "Content-Type": "application/json" } }, ), ); } if (url.includes("password/verify")) { return Promise.resolve(new Response(null, { status: 422 })); } return Promise.resolve(new Response(null, { status: 404 })); }) as any; // Need fresh client to reset cached token const freshClient = new LogtoManagementClient({ issuer: "https://logto.example.com/oidc", m2mAppId: "test-app-id", m2mAppSecret: "test-app-secret", apiResource: "https://default.logto.app/api", }); const result = await freshClient.verifyPassword("sub-123", "wrong-pass"); expect(result).toBe(false); }); test("updatePassword calls PATCH on password endpoint", async () => { await client.updatePassword("sub-123", "new-secure-password"); const patchCall = fetchCalls.find( (c) => c.url.includes("/api/users/sub-123/password") && c.options.method === "PATCH" && !c.url.includes("verify"), ); expect(patchCall).toBeDefined(); const body = JSON.parse(patchCall!.options.body as string); expect(body.password).toBe("new-secure-password"); }); test("hasPassword calls GET has-password endpoint and returns boolean", async () => { const result = await client.hasPassword("sub-123"); expect(result).toBe(true); const hasPassCall = fetchCalls.find((c) => c.url.includes("/api/users/sub-123/has-password"), ); expect(hasPassCall).toBeDefined(); expect(hasPassCall!.options.method).toBe("GET"); }); test("updateEmail calls PATCH with primaryEmail field", async () => { await client.updateEmail("sub-123", "new@example.com"); const patchCall = fetchCalls.find( (c) => c.url.includes("/api/users/sub-123") && c.options.method === "PATCH" && !c.url.includes("password"), ); expect(patchCall).toBeDefined(); const body = JSON.parse(patchCall!.options.body as string); expect(body.primaryEmail).toBe("new@example.com"); }); test("deleteUser calls DELETE on user endpoint", async () => { await client.deleteUser("sub-123"); const deleteCall = fetchCalls.find( (c) => c.url.includes("/api/users/sub-123") && c.options.method === "DELETE", ); expect(deleteCall).toBeDefined(); }); test("getUser calls GET and returns user object", async () => { const user = await client.getUser("logto-sub-123"); expect(user.id).toBe("logto-sub-123"); expect(user.primaryEmail).toBe("user@example.com"); expect(user.name).toBe("Test User"); const getCall = fetchCalls.find( (c) => c.url.includes("/api/users/logto-sub-123") && c.options.method === "GET" && !c.url.includes("has-password"), ); expect(getCall).toBeDefined(); }); test("throws when M2M not configured", () => { const unconfiguredClient = new LogtoManagementClient(); expect(unconfiguredClient.getAccessToken()).rejects.toThrow( "Logto M2M not configured", ); }); test("strips /oidc suffix from issuer for base URL", async () => { await client.getUser("test-sub"); // Token call should go to https://logto.example.com/oidc/token const tokenCall = fetchCalls.find((c) => c.url.includes("/oidc/token")); expect(tokenCall!.url).toBe("https://logto.example.com/oidc/token"); // API call should go to https://logto.example.com/api/users/test-sub const apiCall = fetchCalls.find( (c) => c.url.includes("/api/users/test-sub") && c.options.method === "GET", ); expect(apiCall!.url).toBe("https://logto.example.com/api/users/test-sub"); }); });