Files
GearBox/tests/services/logto.service.test.ts
Jean-Luc Makiola e8207a33f9 feat(28-01): add account management routes for password, email, and deletion
Creates /api/account routes with password change (verifies current first),
email update, has-password check, and account deletion with public setup
anonymization. Adds Zod validation schemas and registers routes in index.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:47:17 +02:00

263 lines
7.9 KiB
TypeScript

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<typeof mock>;
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<string, string>)
.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");
});
});