325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The tenant brand end to end (plan item AT2.6): who may change it, what is refused, what is stored,
|
|
// and what the organization layout will read back. Every call goes through the kernel's caller, so
|
|
// the permission check, the validation and the data wall are the real ones.
|
|
|
|
import { type BrandView, tenantStyle } from "@app/modules/branding"
|
|
import { checkLogo } from "@conusai-kit/brand/logo/check"
|
|
import { AppError } from "@conusai-kit/core"
|
|
import { organizations } from "@conusai-kit/tenancy"
|
|
import { afterEach, describe, expect, it } from "vitest"
|
|
import { closeApps, type DocumentsApp, makeDocumentsApp, type SeededProject } from "./support/documents-app.js"
|
|
import { chunk, dimensions, MARK_WEBP, markPng, noisePng } from "./support/images.js"
|
|
|
|
afterEach(closeApps)
|
|
|
|
const document = { engine: { name: "unused", version: "0" }, page_count: 0, pages: [] }
|
|
|
|
async function fresh(): Promise<{ app: DocumentsApp; project: SeededProject }> {
|
|
const app = await makeDocumentsApp({ document })
|
|
return { app, project: await app.seedProject("acme", "owner@acme.test") }
|
|
}
|
|
|
|
// Real images: the kit's checks read a PNG's chunks and decode its pixels.
|
|
const PNG = markPng(1000, 250)
|
|
const png = (width: number) => markPng(width, 60)
|
|
const svgOf = (inner: string) => Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4">${inner}</svg>`)
|
|
|
|
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
|
|
try {
|
|
await promise
|
|
} catch (error) {
|
|
if (error instanceof AppError) return error
|
|
throw error
|
|
}
|
|
throw new Error("expected a refusal")
|
|
}
|
|
|
|
const stored = (app: DocumentsApp, project: SeededProject) =>
|
|
app.data
|
|
.global(organizations)
|
|
.get(project.orgId)
|
|
.then((row) => row?.branding ?? null)
|
|
|
|
describe("reading the brand", () => {
|
|
it("answers nothing set for an organization that has no brand", async () => {
|
|
const { project } = await fresh()
|
|
expect(await project.call.callByName("branding.brand.get", { org: project.orgSlug })).toEqual({
|
|
displayName: null,
|
|
primary: null,
|
|
accent: null,
|
|
logoLight: null,
|
|
logoDark: null,
|
|
})
|
|
})
|
|
|
|
it("lets any member read it", async () => {
|
|
const { app, project } = await fresh()
|
|
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
|
|
expect(await viewer.call.callByName("branding.brand.get", { org: project.orgSlug })).toMatchObject({
|
|
primary: null,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("changing the brand", () => {
|
|
it("stores the name and the colours on the organization and answers with them", async () => {
|
|
const { app, project } = await fresh()
|
|
const view = await project.call.callByName("branding.brand.set", {
|
|
org: project.orgSlug,
|
|
displayName: " Acme Review ",
|
|
primary: "#1a1a8c",
|
|
accent: "#f4c430",
|
|
})
|
|
expect(view).toMatchObject({ displayName: "Acme Review", primary: "#1a1a8c", accent: "#f4c430" })
|
|
expect(await stored(app, project)).toEqual({ displayName: "Acme Review", primary: "#1a1a8c", accent: "#f4c430" })
|
|
})
|
|
|
|
it("leaves what is not sent as it was, and clears what is sent as null", async () => {
|
|
const { app, project } = await fresh()
|
|
await project.call.callByName("branding.brand.set", { org: project.orgSlug, primary: "#1a1a8c", accent: "#f4c430" })
|
|
await project.call.callByName("branding.brand.set", { org: project.orgSlug, accent: null })
|
|
expect(await stored(app, project)).toEqual({ primary: "#1a1a8c" })
|
|
await project.call.callByName("branding.brand.set", { org: project.orgSlug, primary: null })
|
|
// an empty brand is no brand: one representation of "none"
|
|
expect(await stored(app, project)).toBeNull()
|
|
})
|
|
|
|
it("refuses a colour that is not hex, with the field named and the value not repeated", async () => {
|
|
const { app, project } = await fresh()
|
|
for (const bad of ["red", "#12", "#gggggg", "rgb(1,2,3)", "#fff;}body{display:none", "url(//evil)"]) {
|
|
const error = await refusal(project.call.callByName("branding.brand.set", { org: project.orgSlug, primary: bad }))
|
|
expect(error.status, bad).toBe(422)
|
|
// the refusal names the field and does not repeat what was typed in it
|
|
expect(JSON.stringify(error.problem), bad).toContain("primary")
|
|
expect(JSON.stringify(error.problem), bad).not.toContain(bad)
|
|
}
|
|
expect(await stored(app, project)).toBeNull()
|
|
})
|
|
|
|
it("refuses an empty display name, which would be a name nobody sees", async () => {
|
|
const { project } = await fresh()
|
|
const error = await refusal(
|
|
project.call.callByName("branding.brand.set", { org: project.orgSlug, displayName: " " }),
|
|
)
|
|
expect(error.status).toBe(422)
|
|
})
|
|
|
|
it("is an administrator's act: a member and a viewer are answered as strangers, and nothing changes", async () => {
|
|
const { app, project } = await fresh()
|
|
const admin = await app.addMember(project, "admin@acme.test", "admin")
|
|
const member = await app.addMember(project, "member@acme.test", "member")
|
|
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
|
|
for (const who of [member, viewer]) {
|
|
// 404, not 403: the kernel answers a member who lacks the permission as it answers a stranger (spec §3.3)
|
|
expect(
|
|
(await refusal(who.call.callByName("branding.brand.set", { org: project.orgSlug, primary: "#000000" }))).status,
|
|
).toBe(404)
|
|
expect(
|
|
(
|
|
await refusal(
|
|
who.call.callByName("branding.logo.set", {
|
|
org: project.orgSlug,
|
|
variant: "light",
|
|
contentType: "image/png",
|
|
body: PNG.toString("base64"),
|
|
}),
|
|
)
|
|
).status,
|
|
).toBe(404)
|
|
}
|
|
expect(await stored(app, project)).toBeNull()
|
|
await admin.call.callByName("branding.brand.set", { org: project.orgSlug, primary: "#000000" })
|
|
expect(await stored(app, project)).toEqual({ primary: "#000000" })
|
|
})
|
|
|
|
it("is declared to write one audit event per change", async () => {
|
|
const app = await makeDocumentsApp({ document })
|
|
expect(app.registry.procedures.get("branding.brand.set")?.def.audit).toBe("branding.brand_changed")
|
|
expect(app.registry.procedures.get("branding.logo.set")?.def.audit).toBe("branding.brand_changed")
|
|
expect(app.registry.audit["branding.brand_changed"]).toBeDefined()
|
|
})
|
|
})
|
|
|
|
const logoKeys = (app: DocumentsApp, project: SeededProject) =>
|
|
app.store
|
|
.keysOf({ level: "org", actor: project.owner, orgId: project.orgId })
|
|
.filter((key) => key.startsWith("branding/"))
|
|
.sort()
|
|
|
|
const setLogo = (project: SeededProject, variant: "light" | "dark", contentType: string, bytes: Uint8Array) =>
|
|
project.call.callByName("branding.logo.set", {
|
|
org: project.orgSlug,
|
|
variant,
|
|
contentType,
|
|
body: Buffer.from(bytes).toString("base64"),
|
|
}) as Promise<BrandView>
|
|
|
|
const getLogo = async (project: SeededProject, variant: "light" | "dark", size?: "1x" | "2x") => {
|
|
const back = (await project.call.callByName("branding.logo.get", {
|
|
org: project.orgSlug,
|
|
variant,
|
|
...(size === undefined ? {} : { size }),
|
|
})) as { contentType: string; body: string }
|
|
return { contentType: back.contentType, bytes: Buffer.from(back.body, "base64") }
|
|
}
|
|
|
|
describe("the logos", () => {
|
|
it("stores a raster logo as its 1x and 2x renditions, and serves the 2x unless the 1x is asked for", async () => {
|
|
const { app, project } = await fresh()
|
|
const view = await setLogo(project, "light", "image/png", PNG)
|
|
expect(view.logoLight).toMatch(/^\/acme\/brand\/logo\/light\?v=[0-9a-f]{16}$/)
|
|
expect(view.logoDark).toBeNull()
|
|
const two = await getLogo(project, "light")
|
|
expect(two.contentType).toBe("image/png")
|
|
expect(dimensions(two.bytes)).toEqual({ width: 840, height: 210 })
|
|
expect(dimensions((await getLogo(project, "light", "1x")).bytes)).toEqual({ width: 420, height: 105 })
|
|
expect(two.bytes.equals(PNG)).toBe(false)
|
|
// the object lives under the organization's own namespace of the store: two files, never the upload
|
|
expect(logoKeys(app, project)).toHaveLength(2)
|
|
})
|
|
|
|
it("resizes an image larger than a stored logo may be instead of refusing it, and every stored file passes the kit check", async () => {
|
|
const { project } = await fresh()
|
|
const big = noisePng(700, 300)
|
|
expect(big.length).toBeGreaterThan(256 * 1024)
|
|
await setLogo(project, "dark", "image/png", big)
|
|
for (const size of ["1x", "2x"] as const) {
|
|
const { bytes, contentType } = await getLogo(project, "dark", size)
|
|
expect(bytes.length).toBeLessThanOrEqual(256 * 1024)
|
|
expect(checkLogo(contentType, bytes)).toMatchObject({ ok: true })
|
|
}
|
|
})
|
|
|
|
it("takes a WebP and stores it as PNG", async () => {
|
|
const { project } = await fresh()
|
|
await setLogo(project, "dark", "image/webp", MARK_WEBP)
|
|
const { bytes, contentType } = await getLogo(project, "dark")
|
|
expect(contentType).toBe("image/png")
|
|
expect(dimensions(bytes)).toEqual({ width: 400, height: 100 })
|
|
})
|
|
|
|
it("replaces a logo and removes the files it replaced", async () => {
|
|
const { app, project } = await fresh()
|
|
await setLogo(project, "dark", "image/png", png(100))
|
|
const second = await setLogo(project, "dark", "image/png", png(200))
|
|
const keys = logoKeys(app, project)
|
|
expect(keys).toHaveLength(2)
|
|
expect(keys.every((key) => second.logoDark?.includes(key.split("-")[2] ?? "?"))).toBe(true)
|
|
// the same image again is the same logo: nothing is lost
|
|
const third = await setLogo(project, "dark", "image/png", png(200))
|
|
expect(third.logoDark).toBe(second.logoDark)
|
|
expect(logoKeys(app, project)).toEqual(keys)
|
|
})
|
|
|
|
it("keeps an SVG that is shapes alone exactly as uploaded, one file for both sizes", async () => {
|
|
const { app, project } = await fresh()
|
|
const logo = svgOf('<rect width="4" height="4" fill="#123456"/>')
|
|
const view = await setLogo(project, "light", "image/svg+xml", logo)
|
|
expect(view.logoLight).not.toBeNull()
|
|
expect(logoKeys(app, project)).toHaveLength(1)
|
|
for (const size of [undefined, "1x", "2x"] as const) {
|
|
const back = await getLogo(project, "light", size)
|
|
expect(back.contentType).toBe("image/svg+xml")
|
|
expect(back.bytes.equals(logo)).toBe(true)
|
|
}
|
|
})
|
|
|
|
it("refuses an SVG that carries a script, stores nothing and leaves the brand as it was", async () => {
|
|
const { app, project } = await fresh()
|
|
await project.call.callByName("branding.brand.set", { org: project.orgSlug, primary: "#1a1a8c" })
|
|
const before = await stored(app, project)
|
|
for (const inner of [
|
|
"<script>alert(1)</script>",
|
|
'<rect onload="alert(1)" width="1" height="1"/>',
|
|
'<image href="https://evil.example.test/x.png"/>',
|
|
]) {
|
|
const error = await refusal(setLogo(project, "light", "image/svg+xml", svgOf(inner)))
|
|
expect(error.status, inner).toBe(422)
|
|
}
|
|
expect(await stored(app, project)).toEqual(before)
|
|
expect(logoKeys(app, project)).toEqual([])
|
|
})
|
|
|
|
it("refuses a file the kit's checks refuse, naming the rule and not the file", async () => {
|
|
const { app, project } = await fresh()
|
|
const damaged = Buffer.from(PNG)
|
|
damaged[40] = (damaged[40] ?? 0) ^ 0xff
|
|
const lie = Buffer.concat([
|
|
PNG.subarray(0, 8),
|
|
PNG.subarray(8, 33),
|
|
chunk("IDAT", Buffer.from("not zlib data at all")),
|
|
chunk("IEND", new Uint8Array()),
|
|
])
|
|
for (const [name, type, bytes, rule] of [
|
|
["a PNG that is really markup", "image/png", Buffer.from("<svg/>"), /not a PNG/],
|
|
["a WebP that is a PNG", "image/webp", PNG, /not a WebP/],
|
|
["a PNG with a damaged chunk", "image/png", damaged, /damaged/],
|
|
["a PNG cut short", "image/png", PNG.subarray(0, 200), /damaged|PNG/],
|
|
["a PNG whose data does not decode", "image/png", lie, /decoded|visible/],
|
|
["a type that is not an image", "text/html", Buffer.from("<html>"), /PNG, WebP or SVG/],
|
|
[
|
|
"more than the upload limit",
|
|
"image/png",
|
|
Buffer.concat([PNG, Buffer.alloc(2 * 1024 * 1024 + 1 - PNG.length)]),
|
|
/larger than/,
|
|
],
|
|
] as const) {
|
|
const error = await refusal(setLogo(project, "light", type, bytes))
|
|
expect(error.status, name).toBe(422)
|
|
expect(JSON.stringify(error.problem), name).toMatch(rule)
|
|
}
|
|
expect(await stored(app, project)).toBeNull()
|
|
expect(logoKeys(app, project)).toEqual([])
|
|
})
|
|
|
|
it("refuses a body that is not base64", async () => {
|
|
const { project } = await fresh()
|
|
const text = await refusal(
|
|
project.call.callByName("branding.logo.set", {
|
|
org: project.orgSlug,
|
|
variant: "light",
|
|
contentType: "image/png",
|
|
body: "not base64!",
|
|
}),
|
|
)
|
|
expect(text.status).toBe(422)
|
|
})
|
|
|
|
it("is removed from the brand and from the store when it is cleared, and then there is no logo to read", async () => {
|
|
const { app, project } = await fresh()
|
|
await setLogo(project, "light", "image/png", PNG)
|
|
await setLogo(project, "dark", "image/png", png(100))
|
|
expect(logoKeys(app, project)).toHaveLength(4)
|
|
await project.call.callByName("branding.logo.clear", { org: project.orgSlug, variant: "light" })
|
|
expect(logoKeys(app, project)).toHaveLength(2)
|
|
expect(
|
|
(await refusal(project.call.callByName("branding.logo.get", { org: project.orgSlug, variant: "light" }))).status,
|
|
).toBe(404)
|
|
expect(dimensions((await getLogo(project, "dark")).bytes)).toEqual({ width: 100, height: 60 })
|
|
})
|
|
|
|
it("is not readable by another organization's member", async () => {
|
|
const { app, project } = await fresh()
|
|
await setLogo(project, "light", "image/png", PNG)
|
|
const other = await app.seedProject("other", "owner@other.test")
|
|
expect(
|
|
(await refusal(other.call.callByName("branding.logo.get", { org: project.orgSlug, variant: "light" }))).status,
|
|
).toBe(404)
|
|
})
|
|
})
|
|
|
|
describe("what the organization layout writes from a stored brand", () => {
|
|
it("is the brand's own colours, legible in both themes", () => {
|
|
const { style, report } = tenantStyle({ primary: "#1a1a8c", accent: "#f4c430" })
|
|
expect(style["--brand-accent"]).toBe("#1a1a8c")
|
|
expect(style["--brand-accent-foreground"]).toBe("#ffffff")
|
|
expect(report.dark?.inkContrast).toBeGreaterThanOrEqual(4.5)
|
|
expect(report.light?.inkContrast).toBeGreaterThanOrEqual(4.5)
|
|
})
|
|
|
|
it("is nothing for an organization with no colour of its own, so the app's brand shows", () => {
|
|
expect(tenantStyle({ primary: null, accent: null }).style).toEqual({})
|
|
})
|
|
})
|