This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
// 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({})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user