@@ -0,0 +1,329 @@
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user