Skip to content

Commit 1f413fa

Browse files
committed
Fix tenant-origin RPC requests and combined auth regression coverage
1 parent 8d6132e commit 1f413fa

5 files changed

Lines changed: 136 additions & 66 deletions

File tree

‎lib/api.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import type { AppType } from "@/lib/app";
22
import { hc } from "hono/client";
3-
import { getBaseUrl } from "./clientUtils";
3+
import { getBaseUrl } from "@/lib/clientUtils";
44

5-
const client = hc<AppType>(getBaseUrl(), {
6-
init: {
7-
credentials: "include",
5+
const client = hc<AppType>(
6+
typeof window === "undefined" ? getBaseUrl() : window.location.origin,
7+
{
8+
init: {
9+
credentials: "include",
10+
},
811
},
9-
});
12+
);
1013

1114
export default client.api;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
// One server/build serves both tenant hosts. Do not intercept the config API:
4+
// the browser must be able to read its real 404 from the visited origin.
5+
for (const slug of ["missing-auth-a", "missing-auth-b"]) {
6+
for (const path of [
7+
"/login",
8+
"/signup",
9+
"/forgotpassword",
10+
"/resetpassword?token=test-token",
11+
]) {
12+
test(`shows missing organization at ${slug}${path}`, async ({
13+
page,
14+
baseURL,
15+
}) => {
16+
const url = new URL(baseURL ?? "http://localhost:3000");
17+
url.hostname = `${slug}.lvh.me`;
18+
const responsePromise = page.waitForResponse(
19+
(response) =>
20+
new URL(response.url()).pathname === "/api/organizationConfig",
21+
);
22+
await page.goto(new URL(path, url).href);
23+
const response = await responsePromise;
24+
expect(new URL(response.url()).origin).toBe(url.origin);
25+
expect(response.status()).toBe(404);
26+
await expect(page.getByTestId("organization-not-found")).toBeVisible();
27+
await expect(page.locator("input")).toHaveCount(0);
28+
});
29+
}
30+
}

‎tests/e2e/login.spec.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,11 +78,4 @@ test.describe("Login Page", () => {
7878
expect(normalized).toContain(primary);
7979
expect(normalized).toContain(secondary);
8080
});
81-
82-
// No e2e coverage for the not-found state: `lib/api.ts` builds the RPC client
83-
// against getBaseUrl() (NEXT_PUBLIC_BASE_URL), so every tenant's API calls go to
84-
// one fixed origin. Loading a page on any other host makes that call cross-origin
85-
// and the browser blocks it before the 404 is readable, so the hook sees a network
86-
// error rather than "not-found". Covered by the unit tests on useOrganizationConfig
87-
// until the API client resolves per-tenant origins.
8881
});

‎tests/unit/lib/api.test.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// @vitest-environment node
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
4+
vi.mock("@/lib/clientUtils", () => ({
5+
getBaseUrl: () => "https://configured.servicestart.com",
6+
}));
7+
8+
afterEach(() => {
9+
vi.unstubAllGlobals();
10+
vi.resetModules();
11+
});
12+
13+
describe("typed API client origin", () => {
14+
it.each(["acme", "missing-tenant"])(
15+
"uses the visited %s tenant in a browser",
16+
async (slug) => {
17+
vi.stubGlobal("window", {
18+
location: { origin: `https://${slug}.servicestart.com` },
19+
});
20+
const { default: api } = await import("@/lib/api");
21+
expect(api.organizationConfig.$url().origin).toBe(
22+
`https://${slug}.servicestart.com`,
23+
);
24+
},
25+
);
26+
27+
it("uses the configured absolute origin on the server", async () => {
28+
vi.stubGlobal("window", undefined);
29+
const { default: api } = await import("@/lib/api");
30+
expect(api.organizationConfig.$url().origin).toBe(
31+
"https://configured.servicestart.com",
32+
);
33+
});
34+
});

‎tests/unit/lib/hooks/useOrganizationConfig.test.tsx‎

Lines changed: 64 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -302,67 +302,77 @@ describe("useOrganizationConfig", () => {
302302
"#FB3552",
303303
);
304304
});
305-
});
306-
307-
it("resolves to documented defaults when the hook has not loaded branding yet", () => {
308-
const branding = resolveBranding({});
309305

310-
expect(branding[OrganizationConfigKey.PrimaryColor]).toBe(
311-
DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor],
312-
);
313-
expect(branding[OrganizationConfigKey.SecondaryColor]).toBe(
314-
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
315-
);
316-
});
306+
it("resolves to documented defaults when the hook has not loaded branding yet", () => {
307+
const branding = resolveBranding({});
317308

318-
it("resolves hook results the same way as server defaults for missing branding", async () => {
319-
mockGet.mockImplementation(() =>
320-
Promise.resolve({
321-
json: () => Promise.resolve({}),
322-
}),
323-
);
324-
325-
const keys = [
326-
OrganizationConfigKey.PrimaryColor,
327-
OrganizationConfigKey.SecondaryColor,
328-
] as const;
329-
const { result } = renderHook(() => useOrganizationConfig(keys));
330-
331-
await waitFor(() => {
332-
expect(mockGet).toHaveBeenCalled();
333-
});
334-
335-
expect(resolveBranding(result.current)).toEqual({
336-
[OrganizationConfigKey.PrimaryColor]:
309+
expect(branding[OrganizationConfigKey.PrimaryColor]).toBe(
337310
DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor],
338-
[OrganizationConfigKey.SecondaryColor]:
311+
);
312+
expect(branding[OrganizationConfigKey.SecondaryColor]).toBe(
339313
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
314+
);
340315
});
341-
});
342316

343-
it("keeps a configured primary color from the hook and defaults the secondary", async () => {
344-
mockGet.mockImplementation(() =>
345-
Promise.resolve({
346-
json: () =>
347-
Promise.resolve({
348-
[OrganizationConfigKey.PrimaryColor]: "#000000",
349-
}),
350-
}),
351-
);
352-
353-
const keys = [
354-
OrganizationConfigKey.PrimaryColor,
355-
OrganizationConfigKey.SecondaryColor,
356-
] as const;
357-
const { result } = renderHook(() => useOrganizationConfig(keys));
358-
359-
await waitFor(() => {
360-
expect(result.current[OrganizationConfigKey.PrimaryColor]).toBe("#000000");
317+
it("resolves hook results the same way as server defaults for missing branding", async () => {
318+
mockGet.mockImplementation(() =>
319+
Promise.resolve({
320+
ok: true,
321+
status: 200,
322+
json: () =>
323+
Promise.resolve({ primary_color: null, secondary_color: null }),
324+
}),
325+
);
326+
327+
const keys = [
328+
OrganizationConfigKey.PrimaryColor,
329+
OrganizationConfigKey.SecondaryColor,
330+
] as const;
331+
const { result } = renderHook(() => useOrganizationConfig(keys));
332+
333+
await waitFor(() => {
334+
expect(result.current[OrganizationConfigKey.PrimaryColor]).toBeNull();
335+
expect(result.current[OrganizationConfigKey.SecondaryColor]).toBeNull();
336+
expect(result.current.status).toBe("ok");
337+
});
338+
339+
expect(resolveBranding(result.current)).toEqual({
340+
[OrganizationConfigKey.PrimaryColor]:
341+
DEFAULT_BRANDING[OrganizationConfigKey.PrimaryColor],
342+
[OrganizationConfigKey.SecondaryColor]:
343+
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
344+
});
361345
});
362346

363-
expect(resolveBranding(result.current)).toEqual({
364-
[OrganizationConfigKey.PrimaryColor]: "#000000",
365-
[OrganizationConfigKey.SecondaryColor]:
366-
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
347+
it("keeps a configured primary color from the hook and defaults the secondary", async () => {
348+
mockGet.mockImplementation(() =>
349+
Promise.resolve({
350+
ok: true,
351+
status: 200,
352+
json: () =>
353+
Promise.resolve({
354+
[OrganizationConfigKey.PrimaryColor]: "#000000",
355+
}),
356+
}),
357+
);
358+
359+
const keys = [
360+
OrganizationConfigKey.PrimaryColor,
361+
OrganizationConfigKey.SecondaryColor,
362+
] as const;
363+
const { result } = renderHook(() => useOrganizationConfig(keys));
364+
365+
await waitFor(() => {
366+
expect(result.current[OrganizationConfigKey.PrimaryColor]).toBe(
367+
"#000000",
368+
);
369+
expect(result.current.status).toBe("ok");
370+
});
371+
372+
expect(resolveBranding(result.current)).toEqual({
373+
[OrganizationConfigKey.PrimaryColor]: "#000000",
374+
[OrganizationConfigKey.SecondaryColor]:
375+
DEFAULT_BRANDING[OrganizationConfigKey.SecondaryColor],
376+
});
367377
});
368378
});

0 commit comments

Comments
 (0)