189 lines
7.8 KiB
TypeScript
189 lines
7.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Params } from "react-router";
|
|
|
|
vi.mock("../../db.server", () => ({
|
|
db: {
|
|
tenant: {
|
|
findFirst: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
vi.mock("../../cache.server", () => ({
|
|
cachified: vi.fn(),
|
|
clearCacheKey: vi.fn(),
|
|
}));
|
|
|
|
import { cachified } from "../../cache.server";
|
|
import { db } from "../../db.server";
|
|
import { getTenantIdFromUrl, getTenantIdOrNull, resolveTenantLookupSource } from "./urlService";
|
|
|
|
const env = {
|
|
APP_BASE_DOMAIN: "localhost:3000",
|
|
WEB_APP_HOST: "app.localhost:3000",
|
|
ADMIN_APP_HOST: "admin.localhost:3000",
|
|
API_HOST: "api.localhost:3000",
|
|
APP_PROTOCOL: "http",
|
|
};
|
|
|
|
function makeRequest(host: string, pathname = "/app/acme") {
|
|
return new Request(`http://localhost:3000${pathname}`, {
|
|
headers: {
|
|
host,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function expectRedirectToApp(promise: Promise<unknown>) {
|
|
try {
|
|
await promise;
|
|
throw new Error("Expected redirect to /app");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Response);
|
|
expect((error as Response).status).toBe(302);
|
|
expect((error as Response).headers.get("Location")).toBe("http://app.localhost:3000/");
|
|
}
|
|
}
|
|
|
|
describe("resolveTenantLookupSource", () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv("APP_BASE_DOMAIN", env.APP_BASE_DOMAIN);
|
|
vi.stubEnv("WEB_APP_HOST", env.WEB_APP_HOST);
|
|
vi.stubEnv("ADMIN_APP_HOST", env.ADMIN_APP_HOST);
|
|
vi.stubEnv("API_HOST", env.API_HOST);
|
|
vi.stubEnv("APP_PROTOCOL", env.APP_PROTOCOL);
|
|
});
|
|
|
|
it("uses explicit params when no request exists to keep route-loader behavior stable", () => {
|
|
expect(resolveTenantLookupSource({ params: { tenant: "acme" } })).toEqual({ source: "app-route-param", tenant: "acme" });
|
|
expect(resolveTenantLookupSource({ params: {} })).toEqual({ source: "none", tenant: null });
|
|
});
|
|
|
|
it("allows tenant lookup only for app-host requests with params or legacy app paths", () => {
|
|
expect(resolveTenantLookupSource({ request: makeRequest(env.WEB_APP_HOST, "/acme/dashboard"), params: { tenant: "acme" } })).toEqual({
|
|
source: "app-route-param",
|
|
tenant: "acme",
|
|
});
|
|
expect(resolveTenantLookupSource({ request: makeRequest(env.WEB_APP_HOST, "/app/acme/dashboard"), params: {} })).toEqual({
|
|
source: "app-route-param",
|
|
tenant: "acme",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["admin", env.ADMIN_APP_HOST],
|
|
["api", env.API_HOST],
|
|
["root", env.APP_BASE_DOMAIN],
|
|
["unknown", "unknown.localhost:3000"],
|
|
])("does not infer tenant context for %s-host /app paths", (_label, host) => {
|
|
expect(resolveTenantLookupSource({ request: makeRequest(host, "/app/acme/dashboard"), params: { tenant: "acme" } })).toEqual({
|
|
source: "none",
|
|
tenant: null,
|
|
});
|
|
});
|
|
|
|
it("keeps public app-host routes out of tenant resolution", () => {
|
|
expect(resolveTenantLookupSource({ request: makeRequest(env.WEB_APP_HOST, "/"), params: {} })).toEqual({ source: "none", tenant: null });
|
|
expect(resolveTenantLookupSource({ request: makeRequest(env.WEB_APP_HOST, "/app"), params: {} })).toEqual({ source: "none", tenant: null });
|
|
expect(resolveTenantLookupSource({ request: makeRequest(env.WEB_APP_HOST, "/login"), params: {} })).toEqual({ source: "none", tenant: null });
|
|
expect(resolveTenantLookupSource({ request: makeRequest(env.WEB_APP_HOST, "/reset?token=abc"), params: {} })).toEqual({ source: "none", tenant: null });
|
|
});
|
|
|
|
it("does not self-redirect when the app host is also the base domain", () => {
|
|
vi.stubEnv("APP_BASE_DOMAIN", "boiler.saas-ctrl.com");
|
|
vi.stubEnv("WEB_APP_HOST", "boiler.saas-ctrl.com");
|
|
vi.stubEnv("ADMIN_APP_HOST", "admin.boiler.saas-ctrl.com");
|
|
vi.stubEnv("API_HOST", "api.boiler.saas-ctrl.com");
|
|
vi.stubEnv("APP_PROTOCOL", "https");
|
|
|
|
expect(resolveTenantLookupSource({ request: makeRequest("boiler.saas-ctrl.com", "/"), params: {} })).toEqual({ source: "none", tenant: null });
|
|
expect(resolveTenantLookupSource({ request: makeRequest("boiler.saas-ctrl.com", "/app"), params: {} })).toEqual({ source: "none", tenant: null });
|
|
expect(resolveTenantLookupSource({ request: makeRequest("boiler.saas-ctrl.com", "/login"), params: {} })).toEqual({ source: "none", tenant: null });
|
|
});
|
|
});
|
|
|
|
describe("getTenantIdOrNull", () => {
|
|
beforeEach(() => {
|
|
vi.stubEnv("APP_BASE_DOMAIN", env.APP_BASE_DOMAIN);
|
|
vi.stubEnv("WEB_APP_HOST", env.WEB_APP_HOST);
|
|
vi.stubEnv("ADMIN_APP_HOST", env.ADMIN_APP_HOST);
|
|
vi.stubEnv("API_HOST", env.API_HOST);
|
|
vi.stubEnv("APP_PROTOCOL", env.APP_PROTOCOL);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it.each([
|
|
["admin", env.ADMIN_APP_HOST],
|
|
["api", env.API_HOST],
|
|
["root", env.APP_BASE_DOMAIN],
|
|
["unknown", "unknown.localhost:3000"],
|
|
])("returns null without DB lookup for %s-host /app paths", async (_label, host) => {
|
|
await expect(getTenantIdOrNull({ request: makeRequest(host, "/app/acme/dashboard"), params: { tenant: "acme" } })).resolves.toBeNull();
|
|
|
|
expect(db.tenant.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows admin-host routes to opt into tenant scope through tenantId query parameters", async () => {
|
|
vi.mocked(db.tenant.findFirst).mockResolvedValueOnce({ id: "tenant_1" });
|
|
|
|
await expect(getTenantIdOrNull({ request: makeRequest(env.ADMIN_APP_HOST, "/forms?tenantId=acme"), params: {} })).resolves.toBe("tenant_1");
|
|
|
|
expect(db.tenant.findFirst).toHaveBeenCalledWith({
|
|
where: { OR: [{ slug: "acme" }, { id: "acme" }] },
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("keeps admin-host routes global when tenantId query parameters are absent", async () => {
|
|
await expect(getTenantIdOrNull({ request: makeRequest(env.ADMIN_APP_HOST, "/forms"), params: {} })).resolves.toBeNull();
|
|
|
|
expect(db.tenant.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("looks up exactly one tenant-bearing app-host request and returns its id", async () => {
|
|
vi.mocked(db.tenant.findFirst).mockResolvedValueOnce({ id: "tenant_1" });
|
|
|
|
await expect(getTenantIdOrNull({ request: makeRequest(env.WEB_APP_HOST, "/acme/dashboard"), params: { tenant: "acme" } })).resolves.toBe("tenant_1");
|
|
|
|
expect(db.tenant.findFirst).toHaveBeenCalledTimes(1);
|
|
expect(db.tenant.findFirst).toHaveBeenCalledWith({
|
|
where: { OR: [{ slug: "acme" }, { id: "acme" }] },
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("returns null for public app-host routes without DB lookup", async () => {
|
|
await expect(getTenantIdOrNull({ request: makeRequest(env.WEB_APP_HOST, "/"), params: {} })).resolves.toBeNull();
|
|
await expect(getTenantIdOrNull({ request: makeRequest(env.WEB_APP_HOST, "/app"), params: {} })).resolves.toBeNull();
|
|
await expect(getTenantIdOrNull({ request: makeRequest(env.WEB_APP_HOST, "/login"), params: {} })).resolves.toBeNull();
|
|
expect(db.tenant.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("redirects app-host requests with unknown tenants to /app", async () => {
|
|
vi.mocked(db.tenant.findFirst).mockResolvedValueOnce(null);
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
|
|
await expectRedirectToApp(getTenantIdOrNull({ request: makeRequest(env.WEB_APP_HOST, "/acme/dashboard"), params: { tenant: "acme" } }));
|
|
|
|
expect(db.tenant.findFirst).toHaveBeenCalledTimes(1);
|
|
consoleLog.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("getTenantIdFromUrl", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(cachified).mockImplementation((async ({ getFreshValue }: { getFreshValue: (context?: unknown) => unknown }) => getFreshValue()) as typeof cachified);
|
|
});
|
|
|
|
it("keeps explicit tenant param lookup behavior stable", async () => {
|
|
vi.mocked(db.tenant.findFirst).mockResolvedValueOnce({ id: "tenant_1" });
|
|
|
|
await expect(getTenantIdFromUrl({ tenant: "acme" } as Params)).resolves.toBe("tenant_1");
|
|
|
|
expect(db.tenant.findFirst).toHaveBeenCalledWith({
|
|
where: { OR: [{ slug: "acme" }, { id: "acme" }] },
|
|
select: { id: true },
|
|
});
|
|
});
|
|
});
|