408 lines
18 KiB
TypeScript
408 lines
18 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The route files of the Next tree's replacement (M6b, M6c): the table in apps/web/ROUTES.md has a file for
|
|
// every row, what reaches the browser imports no server code, and the loaders under an organization answer
|
|
// every refusal with its status.
|
|
|
|
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
import { AppError } from "@conusai-kit/core"
|
|
import { isNotFound, isRedirect } from "@tanstack/react-router"
|
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
|
|
const web = new URL("../apps/web/", import.meta.url)
|
|
const routes = new URL("src/routes/", web)
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
pageContext: vi.fn(),
|
|
holds: vi.fn(),
|
|
requirePermission: vi.fn(),
|
|
permissionsIn: vi.fn(),
|
|
call: vi.fn(),
|
|
requireSection: vi.fn(),
|
|
membersLoad: vi.fn(),
|
|
invitationsLoad: vi.fn(),
|
|
setResponseHeader: vi.fn<(name: string, value: string) => void>(),
|
|
nav: vi.fn((): unknown[] => []),
|
|
request: vi.fn(() => new Request("http://app.test/")),
|
|
singleOrganization: vi.fn(() => false),
|
|
selected: new Set(["documents", "knowledge"]),
|
|
}))
|
|
|
|
vi.mock("@tanstack/react-start/server", () => ({
|
|
getRequest: () => mocks.request(),
|
|
setResponseStatus: vi.fn(),
|
|
setResponseHeader: mocks.setResponseHeader,
|
|
}))
|
|
vi.mock("../apps/web/src/server/page", () => ({
|
|
holds: mocks.holds,
|
|
pageContext: mocks.pageContext,
|
|
permissionsIn: mocks.permissionsIn,
|
|
requirePermission: mocks.requirePermission,
|
|
}))
|
|
vi.mock("../apps/web/src/server/app", () => ({
|
|
webRuntime: () => ({
|
|
env: { deployment: {} },
|
|
observe: { log: { warn: vi.fn() } },
|
|
registry: { nav: mocks.nav() },
|
|
singleOrganization: mocks.singleOrganization(),
|
|
}),
|
|
}))
|
|
vi.mock("../apps/web/src/server/project-entries", () => ({
|
|
PROJECT_ENTRIES: [{ href: "archive", label: "archive.title", module: "archive" }],
|
|
PROJECT_MODULE_KEYS: ["archive"],
|
|
}))
|
|
vi.mock("@app/config", () => ({ PROFILE: "postgres" }))
|
|
vi.mock("@app/modules", () => ({
|
|
moduleSelected: (id: string) => mocks.selected.has(id),
|
|
moduleAdopted: (id: string) => mocks.selected.has(id),
|
|
appModules: [
|
|
{
|
|
id: "tenancy",
|
|
version: "1.0.0",
|
|
procedures: [{ name: "a function that must not reach the browser" }],
|
|
ui: {
|
|
nav: [
|
|
{ scope: "org", label: "tenancy.nav.members", href: "members" },
|
|
{ scope: "org", label: "tenancy.nav.settings", href: "settings", permission: "tenancy.org.manage" },
|
|
],
|
|
},
|
|
},
|
|
{ id: "worker-only", version: "1.0.0" },
|
|
],
|
|
}))
|
|
vi.mock("@app/modules/branding", () => ({
|
|
brandGet: "brand",
|
|
logoUrl: (path: string, size: string) => `${path}?s=${size}`,
|
|
tenantLogo: (brand: { logoLight: string | null }) => (brand.logoLight ? { path: brand.logoLight } : null),
|
|
tenantStyle: () => ({ style: { "--tenant": "1" } }),
|
|
}))
|
|
vi.mock("@app/modules/branding/ui", () => ({ brandFormLabels: () => ({}) }))
|
|
// kit:documents:begin
|
|
vi.mock("@app/modules/documents", () => ({
|
|
documentGet: "documentGet",
|
|
documentList: "documentList",
|
|
READABLE_CONTENT_TYPES: ["application/pdf"],
|
|
}))
|
|
vi.mock("@app/modules/documents/ui", () => ({
|
|
exportLabels: () => ({}),
|
|
listLabels: () => ({}),
|
|
reviewLabels: () => ({}),
|
|
uploadLabels: () => ({}),
|
|
}))
|
|
// kit:documents:end
|
|
vi.mock("@app/modules/knowledge", () => ({ settingsGet: "settingsGet" })) // kit:knowledge
|
|
vi.mock("@conusai-kit/brand", () => ({ tokens: { color: { dark: { accent: "#fff" } } } }))
|
|
vi.mock("@conusai-kit/host/http", () => ({ isOwnedBy: () => true }))
|
|
vi.mock("@conusai-kit/tenancy", () => ({
|
|
brandingSchema: { safeParse: (value: unknown) => ({ success: true, data: value ?? {} }) },
|
|
meGet: "me",
|
|
ORG_ROLES: ["viewer", "owner"],
|
|
orgGet: "org",
|
|
projectGet: "project",
|
|
projectList: "projects",
|
|
roleList: "roles",
|
|
workspaceList: "workspaces",
|
|
}))
|
|
vi.mock("@conusai-kit/tenancy/ui", () => ({
|
|
accessAuditPage: { load: vi.fn() },
|
|
builtInRoleName: (_i18n: unknown, role: string) => `kit:${role}`,
|
|
collectAccessEvents: vi.fn(),
|
|
invitationsPage: { load: mocks.invitationsLoad },
|
|
membersPage: { load: mocks.membersLoad },
|
|
requireSection: mocks.requireSection,
|
|
roleName: (_i18n: unknown, role: { name: string }) => `kit:${role.name}`,
|
|
rolePage: { load: vi.fn() },
|
|
rolesPage: { load: vi.fn() },
|
|
}))
|
|
vi.mock("@conusai-sdk/node-sdk", () => ({ ConusAIClient: class {} }))
|
|
|
|
const {
|
|
documentsData, // kit:documents
|
|
invitationsData,
|
|
membersData,
|
|
orgHomeData,
|
|
orgLayoutData,
|
|
requireAccessSection,
|
|
requireWorkspace,
|
|
} = await import("../apps/web/src/server/org-pages")
|
|
|
|
const i18n = { message: (key: string) => key, date: () => "2 Nov 2026", locale: "en" }
|
|
const page = (signedIn = true) => ({ i18n, caller: { call: mocks.call }, signedIn, path: "/acme" })
|
|
const refusal = (status: number): AppError =>
|
|
new AppError({ type: "/problems/refused", code: "refused", title: "Refused", status })
|
|
const thrown = (run: Promise<unknown>): Promise<unknown> =>
|
|
run.then(
|
|
() => {
|
|
throw new Error("expected a refusal")
|
|
},
|
|
(error: unknown) => error,
|
|
)
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mocks.selected = new Set(["documents", "knowledge"])
|
|
mocks.pageContext.mockResolvedValue(page())
|
|
mocks.holds.mockResolvedValue(true)
|
|
mocks.requireSection.mockResolvedValue(undefined)
|
|
mocks.permissionsIn.mockResolvedValue({ permissions: ["tenancy.audit.read"] })
|
|
})
|
|
|
|
describe("the route files", () => {
|
|
const table = readFileSync(new URL("ROUTES.md", web), "utf8")
|
|
const files = readdirSync(routes)
|
|
|
|
it("have nothing left of the Next tree", () => {
|
|
expect(existsSync(new URL("app", web))).toBe(false)
|
|
expect(table).not.toContain("to port")
|
|
})
|
|
|
|
it("exist for every row of the table", () => {
|
|
const named = [...table.matchAll(/\| `([^`|]+\.tsx?)`[^|]*\| done/g)].map((row) => row[1] as string)
|
|
expect(named.length).toBeGreaterThan(30)
|
|
// `kit template:prune` removes these routes, and ROUTES.md stays the whole record of the port.
|
|
const pruned = new Set([
|
|
"auth.sign-up.tsx",
|
|
"$org.$workspace.$project.documents.index.tsx",
|
|
"$org.$workspace.$project.documents.$fileId.tsx",
|
|
])
|
|
expect(named.filter((file) => !files.includes(file) && !pruned.has(file))).toEqual([])
|
|
})
|
|
|
|
it.each(files.filter((file) => file !== "__root.tsx"))("%s keeps server code on the server", (file) => {
|
|
const source = readFileSync(new URL(file.replaceAll("$", "%24"), routes), "utf8")
|
|
// Only a type may be imported statically; the handlers import the file on the server.
|
|
expect(source).not.toMatch(/^import \{[^}]*\} from "\.\.\/server\//m)
|
|
expect(source).not.toMatch(/^import (?!type ).*"@app\/(config|modules)"$/m)
|
|
expect(source).not.toMatch(/\bexport const (dynamic|runtime)\b/)
|
|
expect(source).not.toMatch(/from "next/)
|
|
})
|
|
|
|
it("reach the browser with no root of the app's own packages", () => {
|
|
// Every file a route reaches by a static value import: what the browser's bundle starts from.
|
|
const seen = new Set<string>()
|
|
const walk = (url: URL): void => {
|
|
if (seen.has(url.href)) return
|
|
seen.add(url.href)
|
|
const source = readFileSync(url, "utf8")
|
|
for (const [, name] of source.matchAll(/^(?:import|export) (?!type )[^"]*"([^"]+)"/gm)) {
|
|
const label = `${url.pathname.split("/apps/web/")[1]} imports ${name}`
|
|
expect(label).not.toMatch(/imports @app\/(config|modules)$/)
|
|
expect(label).not.toMatch(/imports (\.\.?\/)+server\//)
|
|
if (!name?.startsWith(".") || name.includes("?")) continue
|
|
const target = [".ts", ".tsx"].map((ext) => new URL(`${name}${ext}`, url)).find((file) => existsSync(file))
|
|
if (target) walk(target)
|
|
}
|
|
}
|
|
for (const file of files.filter((name) => name !== "__root.tsx")) walk(new URL(file.replaceAll("$", "%24"), routes))
|
|
expect(seen.size).toBeGreaterThan(files.length)
|
|
})
|
|
|
|
it("leave the guard free of the page loaders", () => {
|
|
const guard = readFileSync(new URL("src/server/guard.ts", web), "utf8")
|
|
expect(guard).not.toMatch(/from "\.\/page"/)
|
|
})
|
|
})
|
|
|
|
describe("the organization route's loader", () => {
|
|
const organization = { name: "Acme", branding: { displayName: "Acme Co", logoLight: "/acme/brand/logo/light" } }
|
|
const answers = (procedure: unknown): unknown =>
|
|
procedure === "org"
|
|
? organization
|
|
: {
|
|
userId: "u1",
|
|
displayName: "Ada",
|
|
email: "ada@example.test",
|
|
organizations: [{ slug: "acme", name: "Acme" }],
|
|
workspaces: [],
|
|
}
|
|
|
|
it("sends a visitor to sign in", async () => {
|
|
mocks.pageContext.mockResolvedValue(page(false))
|
|
expect(isRedirect(await thrown(orgLayoutData({ org: "acme" })))).toBe(true)
|
|
expect(mocks.call).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("answers a stranger and a missing organization with one 404", async () => {
|
|
mocks.call.mockRejectedValue(refusal(404))
|
|
expect(isNotFound(await thrown(orgLayoutData({ org: "acme" })))).toBe(true)
|
|
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "404")
|
|
})
|
|
|
|
it("hands the browser the navigation and nothing else of a manifest, with its labels translated", async () => {
|
|
mocks.call.mockImplementation(async (procedure: unknown) => answers(procedure))
|
|
const data = await orgLayoutData({ org: "acme" })
|
|
// The organization's own tab first, as on stage, whatever order the manifest gives.
|
|
expect(data.settings.sections).toEqual([
|
|
{ group: "organization", label: "shell.general", href: "/acme/settings", permission: "tenancy.org.manage" },
|
|
{ group: "organization", label: "tenancy.nav.members", href: "/acme/members" },
|
|
{ group: "organization", label: "app.audit", href: "/acme/audit", permission: "tenancy.audit.read" },
|
|
{ group: "personal", label: "account.link", href: "/acme/settings/account" },
|
|
])
|
|
expect(JSON.stringify(data)).not.toContain("a function that must not reach the browser")
|
|
})
|
|
|
|
it("shows an organization's own page in the workspace the person was last in, else the default one", async () => {
|
|
const workspaces = [
|
|
{ slug: "general", name: "General", isDefault: true },
|
|
{ slug: "research", name: "Research" },
|
|
]
|
|
mocks.call.mockImplementation(async (procedure: unknown) =>
|
|
procedure === "workspaces" ? { workspaces } : answers(procedure),
|
|
)
|
|
const lastIn = async (cookie?: string) => {
|
|
mocks.request.mockReturnValue(new Request("http://app.test/", cookie ? { headers: { cookie } } : {}))
|
|
return (await orgLayoutData({ org: "acme" })).workspace?.workspace.id
|
|
}
|
|
expect(await lastIn("app_workspace=acme%2Fresearch")).toBe("research")
|
|
expect(await lastIn()).toBe("general")
|
|
// Another organization's workspace, and one the person no longer sees, are not this organization's.
|
|
expect(await lastIn("app_workspace=northwind%2Fresearch")).toBe("general")
|
|
expect(await lastIn("app_workspace=acme%2Fgone")).toBe("general")
|
|
mocks.request.mockReturnValue(new Request("http://app.test/"))
|
|
})
|
|
|
|
it("puts the audit in Settings, and opens Settings on the first tab the person may open", async () => {
|
|
mocks.call.mockImplementation(async (procedure: unknown) => answers(procedure))
|
|
mocks.permissionsIn.mockResolvedValue({ permissions: ["tenancy.audit.read"] })
|
|
expect(await orgLayoutData({ org: "acme" })).toMatchObject({ settingsHref: "/acme/members" })
|
|
expect(await orgLayoutData({ org: "acme" })).not.toHaveProperty("nav")
|
|
})
|
|
|
|
it("offers a new workspace to whoever may make one, on a one-organization deployment too, and a new organization to whoever may found one", async () => {
|
|
mocks.call.mockImplementation(async (procedure: unknown) => answers(procedure))
|
|
mocks.permissionsIn.mockResolvedValue({ permissions: ["tenancy.workspace.manage"] })
|
|
mocks.pageContext.mockResolvedValue({ ...page(), mayCreateOrganization: true })
|
|
mocks.singleOrganization.mockReturnValue(true)
|
|
expect(await orgLayoutData({ org: "acme" })).toMatchObject({
|
|
workspaceCreateHref: "/acme/workspaces",
|
|
organizationCreateHref: "/onboarding",
|
|
})
|
|
mocks.permissionsIn.mockResolvedValue({ permissions: [] })
|
|
mocks.pageContext.mockResolvedValue({ ...page(), mayCreateOrganization: false })
|
|
const member = await orgLayoutData({ org: "acme" })
|
|
expect(member).not.toHaveProperty("workspaceCreateHref")
|
|
expect(member).not.toHaveProperty("organizationCreateHref")
|
|
mocks.pageContext.mockResolvedValue(page())
|
|
mocks.permissionsIn.mockResolvedValue({ permissions: ["tenancy.audit.read"] })
|
|
mocks.singleOrganization.mockReturnValue(false)
|
|
})
|
|
|
|
it("carries the organization's brand and the scope's choices", async () => {
|
|
mocks.call.mockImplementation(async (procedure: unknown) => answers(procedure))
|
|
const data = await orgLayoutData({ org: "acme" })
|
|
expect(data).toMatchObject({
|
|
name: "Acme Co",
|
|
style: { "--tenant": "1" },
|
|
logoLight: { src: "/acme/brand/logo/light?s=1x" },
|
|
organizations: [{ id: "acme", label: "Acme" }],
|
|
client: { org: "acme", user: "u1", locale: "en" },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("a page under an organization", () => {
|
|
it("is 404 for a workspace the person cannot see", async () => {
|
|
mocks.call.mockResolvedValue({ workspaces: [{ slug: "general", name: "General" }] })
|
|
const { workspace, nav } = await requireWorkspace({ org: "acme", workspace: "general" })
|
|
expect(workspace).toEqual({ id: "general", label: "General" })
|
|
expect(nav.map((item) => item.href)).toEqual(["/acme/general"])
|
|
expect(isNotFound(await thrown(requireWorkspace({ org: "acme", workspace: "restricted" })))).toBe(true)
|
|
})
|
|
|
|
it("links the modules' pages of the workspace this person may open, and no other scope's", async () => {
|
|
mocks.call.mockResolvedValue({ workspaces: [{ slug: "general", name: "General" }] })
|
|
mocks.nav.mockReturnValue([
|
|
{
|
|
scope: "workspace",
|
|
label: "reports.title",
|
|
href: "reports",
|
|
permission: "tenancy.audit.read",
|
|
module: "reports",
|
|
},
|
|
{
|
|
scope: "workspace",
|
|
label: "budgets.title",
|
|
href: "budgets",
|
|
permission: "budgets.budget.read",
|
|
module: "budgets",
|
|
},
|
|
{ scope: "project", label: "notes.title", href: "notes", module: "notes" },
|
|
])
|
|
const { nav } = await requireWorkspace({ org: "acme", workspace: "general" })
|
|
expect(nav.map((item) => item.href)).toEqual(["/acme/general", "/acme/general/modules/reports"])
|
|
mocks.nav.mockReturnValue([])
|
|
})
|
|
|
|
it("makes a page the module placed in Settings a tab there, and leaves it out of the sidebar", async () => {
|
|
mocks.call.mockResolvedValue({ workspaces: [{ slug: "general", name: "General" }] })
|
|
mocks.nav.mockReturnValue([
|
|
{ scope: "workspace", label: "reports.title", href: "reports", module: "reports" },
|
|
{
|
|
scope: "workspace",
|
|
label: "reports.ai",
|
|
href: "reports/settings/ai",
|
|
placement: "settings",
|
|
module: "reports",
|
|
},
|
|
])
|
|
const { nav, settings } = await requireWorkspace({ org: "acme", workspace: "general" })
|
|
expect(nav.map((item) => item.href)).toEqual(["/acme/general", "/acme/general/modules/reports"])
|
|
expect(settings).toEqual([
|
|
{ group: "workspace", label: "reports.ai", href: "/acme/general/modules/reports/settings/ai" },
|
|
])
|
|
mocks.nav.mockReturnValue([])
|
|
})
|
|
|
|
it("is 404 in every loader at once, so whichever finishes first gives the same status", async () => {
|
|
mocks.call.mockRejectedValue(refusal(404))
|
|
expect(isNotFound(await thrown(orgHomeData({ org: "other" })))).toBe(true)
|
|
expect(isNotFound(await thrown(documentsData({ org: "other", workspace: "w", project: "p" })))).toBe(true) // kit:documents
|
|
})
|
|
|
|
// kit:documents:begin
|
|
it("refuses a default-off Documents page before any data call", async () => {
|
|
mocks.selected.clear()
|
|
expect(isNotFound(await thrown(documentsData({ org: "acme", workspace: "w", project: "p" })))).toBe(true)
|
|
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "404")
|
|
expect(mocks.call).not.toHaveBeenCalled()
|
|
})
|
|
// kit:documents:end
|
|
|
|
// kit:documents:begin
|
|
it("carries the documents as the live list's seed, with the locale the browser writes their dates in", async () => {
|
|
const documents = [{ uploadedAt: "2026-11-02T00:00:00.000Z" }]
|
|
mocks.call.mockResolvedValue({ documents })
|
|
const data = await documentsData({ org: "acme", workspace: "general", project: "demo" })
|
|
expect(data.documents).toEqual(documents)
|
|
expect(data.locale).toBe(i18n.locale)
|
|
expect(data.accept).toEqual(["application/pdf"])
|
|
})
|
|
// kit:documents:end
|
|
})
|
|
|
|
describe("kit-tenancy's access pages", () => {
|
|
it("are 403 for a member without the section's permission", async () => {
|
|
mocks.requireSection.mockRejectedValue(refusal(403))
|
|
const error = (await thrown(requireAccessSection({ org: "acme", section: "members" }))) as { data?: unknown }
|
|
expect(error.data).toEqual({ forbidden: true })
|
|
expect(mocks.setResponseHeader).toHaveBeenCalledWith("x-conusai-page-status", "403")
|
|
})
|
|
|
|
it("load at the level the path says", async () => {
|
|
mocks.membersLoad.mockResolvedValue({})
|
|
await membersData({ org: "acme" })
|
|
await membersData({ org: "acme", workspace: "w" })
|
|
await membersData({ org: "acme", workspace: "w", project: "p" })
|
|
expect(mocks.membersLoad.mock.calls.map((call) => call[1])).toEqual([
|
|
{ level: "org" },
|
|
{ level: "workspace", workspace: "w" },
|
|
{ level: "project", workspace: "w", project: "p" },
|
|
])
|
|
})
|
|
|
|
it("hand the invitations page its link path", async () => {
|
|
mocks.invitationsLoad.mockResolvedValue({})
|
|
await invitationsData({ org: "acme" })
|
|
expect(mocks.invitationsLoad.mock.calls[0]?.[1]).toEqual({ linkPath: "/invitations/acme/" })
|
|
})
|
|
})
|