Files
template-app/test/start-routes-account.test.ts
2026-11-14 09:56:45 +00:00

330 lines
13 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The loaders of the pages outside an organization (M6b): each answers a signed-out visitor, a refused read
// and a redirect with its real status, and returns data that survives the trip to the browser.
import { AppError } from "@conusai-kit/core"
import { isNotFound, isRedirect } from "@tanstack/react-router"
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
pageContext: vi.fn(),
call: vi.fn(),
config: vi.fn(),
setResponseHeader: vi.fn<(name: string, value: string) => void>(),
cookie: "",
}))
vi.mock("@tanstack/react-start/server", () => ({
getRequest: () => new Request("http://app.test/", { headers: { cookie: mocks.cookie } }),
setResponseStatus: vi.fn(),
setResponseHeader: mocks.setResponseHeader,
}))
vi.mock("../apps/web/src/server/page", () => ({ pageContext: mocks.pageContext }))
vi.mock("@conusai-kit/auth", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
loadAuthConfig: mocks.config,
}))
vi.mock("../apps/web/src/server/better-auth", () => ({
MIN_PASSWORD_LENGTH: 12,
betterAuthFormLabels: () => ({ submit: "Go" }),
}))
vi.mock("@app/modules/account/contract", async (importOriginal) => ({
// The module list behind the app's catalogues reads the account module's entities.
...(await importOriginal<Record<string, unknown>>()),
ACCOUNT_PATH: "/settings/account",
accountDeletionStatus: {},
}))
vi.mock("@app/modules/account/ui", () => ({
analyticsLabels: () => ({}),
deletionLabels: () => ({}),
exportLabels: () => ({}),
pageLabels: () => ({ title: "Account" }),
}))
vi.mock("@conusai-kit/tenancy", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
meGet: "me",
invitationRead: "invitation.read",
}))
vi.mock("@conusai-kit/tenancy/ui", () => ({ builtInRoleName: (_i18n: unknown, role: string) => `kit:${role}` }))
const { signInSearch } = await import("../apps/web/src/ui/sign-in-search")
const { accountData, homeData, invitationData, membersEntryData, onboardingData, signInData } = await import(
"../apps/web/src/server/account-pages"
)
const { signUpData } = await import("../apps/web/src/server/account-pages") // kit:convex-better-auth
const i18n = { message: (key: string) => key, locale: "en" }
const context = (extra: object) => ({ i18n, caller: { call: mocks.call }, path: "/here", ...extra })
const person = (extra: object = {}) => mocks.pageContext.mockResolvedValue(context({ signedIn: true, ...extra }))
const visitor = () => mocks.pageContext.mockResolvedValue(context({ signedIn: false }))
const refusal = (status: number): AppError =>
new AppError({ type: "/problems/refused", code: "refused", title: "Refused", status })
const organizations = (...slugs: string[]) => ({
organizations: slugs.map((slug) => ({ slug, name: slug, role: "owner" })),
})
const thrown = (run: Promise<unknown>): Promise<unknown> =>
run.then(
() => {
throw new Error("expected a refusal")
},
(error: unknown) => error,
)
const hrefOf = (error: unknown): string | undefined => (error as { options?: { href?: string } }).options?.href
beforeEach(() => {
vi.clearAllMocks()
mocks.cookie = ""
mocks.config.mockReturnValue({ provider: "oidc", devLogin: true })
})
describe("the entry page", () => {
it("offers a visitor the way in", async () => {
visitor()
mocks.config.mockReturnValue({ provider: "oidc", oidc: {} })
const data = await homeData()
expect(data).toMatchObject({
signedIn: false,
signIn: {
provider: { href: "/auth/sign-in/start?ui_locales=en", label: "app.signIn" },
signUp: { href: "/auth/sign-in/start?ui_locales=en&intent=sign-up", label: "signin.createAccount" },
},
})
expect(mocks.call).not.toHaveBeenCalled()
})
it("takes a person with one organization straight to it", async () => {
person({ mayCreateOrganization: true })
mocks.call.mockResolvedValue(organizations("acme"))
expect(hrefOf(await thrown(homeData()))).toBe("/acme")
})
it("takes a person with none to onboarding, or says why when they may not found one", async () => {
person({ mayCreateOrganization: true })
mocks.call.mockResolvedValue(organizations())
expect(hrefOf(await thrown(homeData()))).toBe("/onboarding")
person({ mayCreateOrganization: false })
const data = await homeData()
expect(data).toMatchObject({ signedIn: true, emptyHint: "app.askForInvitation" })
expect(data).not.toHaveProperty("create")
})
it("lists several organizations with their role translated", async () => {
person({ mayCreateOrganization: false })
mocks.call.mockResolvedValue(organizations("acme", "north"))
expect(await homeData()).toEqual(
expect.objectContaining({
organizations: [
{ slug: "acme", name: "acme", role: "kit:owner" },
{ slug: "north", name: "north", role: "kit:owner" },
],
}),
)
})
})
describe("onboarding", () => {
it("sends a visitor to sign in and back", async () => {
visitor()
expect(hrefOf(await thrown(onboardingData()))).toBe("/auth/sign-in?return_to=%2Fhere")
})
it("is the forbidden page, 403, for a person who may not found an organization", async () => {
person({ mayCreateOrganization: false })
const error = (await thrown(onboardingData())) as { data?: unknown }
expect(isNotFound(error)).toBe(true)
expect(error.data).toEqual({ forbidden: true })
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "403")
})
it("names the first workspace in the deployment's language, whatever the founder's is", async () => {
person({ mayCreateOrganization: true, deploymentI18n: { message: (key: string) => `lt:${key}` } })
expect((await onboardingData()).workspace).toEqual({
name: "lt:onboarding.workspaceName",
slug: "lt:onboarding.workspaceSlug",
})
})
})
describe("/members", () => {
it.each([
[[], "/"],
[["acme"], "/acme/members"],
])("sends a person with organizations %j to %s", async (slugs, href) => {
person()
mocks.call.mockResolvedValue(organizations(...slugs))
const error = await thrown(membersEntryData())
expect(isRedirect(error)).toBe(true)
expect(hrefOf(error)).toBe(href)
})
it("lists several organizations", async () => {
person()
mocks.call.mockResolvedValue(organizations("a", "b"))
expect((await membersEntryData()).organizations.map(({ slug }) => slug)).toEqual(["a", "b"])
})
})
describe("the account page", () => {
it("uses only a remembered organization the person belongs to", async () => {
person()
mocks.call.mockImplementation(async (procedure: unknown) =>
procedure === "me"
? { displayName: "Ada", email: "ada@example.test", ...organizations("a", "b") }
: { interactive: true, recentSignIn: false, deletion: null },
)
mocks.cookie = "app_workspace=b%2Fgeneral"
expect(hrefOf(await thrown(accountData(true)))).toBe("/b/settings/account")
mocks.cookie = "app_workspace=other%2Fgeneral"
expect((await accountData(true)).page.title).toBe("Account")
mocks.cookie = "app_workspace=b%2Fgeneral%2Fextra"
expect((await accountData(true)).page.title).toBe("Account")
})
it("uses the only organization when there is no workspace preference", async () => {
person()
mocks.call.mockImplementation(async (procedure: unknown) =>
procedure === "me"
? { displayName: "Ada", email: "ada@example.test", ...organizations("a") }
: { interactive: true, recentSignIn: false, deletion: null },
)
expect(hrefOf(await thrown(accountData(true)))).toBe("/a/settings/account")
})
it("sends a person back through the dev login to sign in again, when that is the deployment's way in", async () => {
person()
mocks.call.mockImplementation(async (procedure: unknown) =>
procedure === "me"
? { displayName: "Ada", email: "ada@example.test" }
: { interactive: true, recentSignIn: false, deletion: null },
)
const data = await accountData()
expect(data.deletion.reauthHref).toBe("/auth/dev-login?email=ada%40example.test&return_to=%2Fsettings%2Faccount")
expect(() => JSON.stringify(data)).not.toThrow()
})
})
describe("the sign-in pages", () => {
it("take a string from the query or nothing: the router parses `?return_to=123` as a number", () => {
expect(signInSearch({ return_to: 123, error: true })).toEqual({})
expect(signInSearch({ return_to: "/members", error: "denied" })).toEqual({ return_to: "/members", error: "denied" })
})
it("sends somebody already in where they were going, and only inside this app", async () => {
person()
expect(hrefOf(await thrown(signInData({ return_to: "/members" })))).toBe("/members")
expect(hrefOf(await thrown(signInData({ return_to: "https://evil.example/" })))).toBe("/")
})
it("keeps somebody who was sent back with an error, and says what went wrong", async () => {
person()
expect(await signInData({ error: "denied" })).toMatchObject({ error: "signin.error.denied" })
})
it("offers the identity provider when there is one, with where to come back to", async () => {
visitor()
mocks.config.mockReturnValue({ provider: "oidc", oidc: {} })
const data = await signInData({ return_to: "/members" })
expect(data.provider?.href).toBe("/auth/sign-in/start?ui_locales=en&return_to=%2Fmembers")
expect(data).not.toHaveProperty("dev")
})
it("offers the development login with no provider, and says so when there is neither", async () => {
visitor()
expect((await signInData({ return_to: "/members" })).dev?.returnTo).toBe("/members")
mocks.config.mockReturnValue({ provider: "oidc", devLogin: false })
expect(await signInData({})).toMatchObject({ notConfigured: "signin.notConfigured" })
})
// kit:convex-better-auth:begin
it("has no sign-up page in an OIDC deployment", async () => {
visitor()
expect(isNotFound(await thrown(signUpData({})))).toBe(true)
})
it("offers the account form with the Convex Better Auth adapter", async () => {
visitor()
mocks.config.mockReturnValue({ provider: "convex-better-auth" })
expect((await signInData({})).betterAuth?.toSignUp.href).toBe("/auth/sign-up")
expect(await signUpData({ return_to: "/members" })).toMatchObject({
returnTo: "/members",
toSignIn: { href: "/auth/sign-in?return_to=%2Fmembers" },
})
})
// kit:convex-better-auth:end
})
describe("an invitation", () => {
const open = {
invitationId: "inv-1",
state: "open",
email: "guest@example.test",
organization: { slug: "acme", name: "Acme" },
role: "member",
roleKey: "editor",
expiresAt: "2026-11-10T09:00:00.000Z",
}
it("tells a visitor who it is for and until when, and offers Create your account and Sign in", async () => {
mocks.pageContext.mockResolvedValue(context({ signedIn: false, path: "/invitations/acme/token", theme: "dark" }))
mocks.call.mockResolvedValue(open)
const data = await invitationData({ org: "acme", token: "token" })
expect(mocks.call).toHaveBeenCalledWith("invitation.read", { org: "acme", token: "token" })
expect(data).toMatchObject({ state: "open", lead: "invite.lead", expires: "invite.expires" })
expect(data.start).toEqual({
org: "acme",
token: "token",
locale: "en",
theme: "dark",
createAccount: "invite.createAccount",
signIn: "invite.haveAccount",
})
expect(data.signedIn).toBeUndefined()
})
it("says plainly that a link was used, revoked or has expired, and that an unknown one is not valid", async () => {
visitor()
for (const state of ["used", "revoked", "expired"]) {
mocks.call.mockResolvedValueOnce({ ...open, state })
const data = await invitationData({ org: "acme", token: "token" })
expect(data).toMatchObject({ state, lead: `invite.error.${state}` })
expect(data.start).toBeUndefined()
}
mocks.call.mockRejectedValueOnce(refusal(404))
expect(await invitationData({ org: "acme", token: "token" })).toMatchObject({
state: "invalid",
lead: "invite.error.unknown",
})
})
it("offers the invited address the accept button, and anybody else only who it is for", async () => {
person({ user: { email: "Guest@Example.test" } })
mocks.call.mockResolvedValue(open)
const mine = await invitationData({ org: "acme", token: "token" })
expect(mine.signedIn?.labels?.accept).toBe("invite.accept")
expect(mine.signedIn?.otherAccount).toBeUndefined()
person({ user: { email: "ada@example.test" } })
const theirs = await invitationData({ org: "acme", token: "token" })
expect(theirs.signedIn?.labels).toBeUndefined()
expect(theirs.signedIn?.otherAccount).toBe("invite.otherAccount")
})
it("asks a visitor to sign in first where the sign-in takes no invitation", async () => {
mocks.config.mockReturnValue({ provider: "convex-better-auth", devLogin: false })
mocks.pageContext.mockResolvedValue(context({ signedIn: false, path: "/invitations/acme/token" }))
mocks.call.mockResolvedValue(open)
expect((await invitationData({ org: "acme", token: "token" })).signInFirst?.href).toBe(
"/auth/sign-in?return_to=%2Finvitations%2Facme%2Ftoken",
)
})
})
describe("a read refused", () => {
it("is the page that belongs to it", async () => {
person()
mocks.call.mockRejectedValue(refusal(401))
expect(isRedirect(await thrown(homeData()))).toBe(true)
mocks.call.mockRejectedValue(refusal(404))
expect(isNotFound(await thrown(homeData()))).toBe(true)
})
})