223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
|
// The brand as a share image and an email show it (spec §10.2, §10.3): the app brand's name and logo, or an
|
|
// organization's, drawn through the kit's logo checks. Every logo here goes the way it goes in production: an
|
|
// organization's through the caller (so a stored, normalised file), the app's from a file of a public folder.
|
|
|
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { join } from "node:path"
|
|
import {
|
|
type BrandView,
|
|
type ComposedEmail,
|
|
composeEmail,
|
|
type MailTransport,
|
|
renderShareImage,
|
|
type ShareBrand,
|
|
sendEmail,
|
|
shareBrand,
|
|
} from "@app/modules/branding"
|
|
import { afterEach, describe, expect, it } from "vitest"
|
|
import { appLogoSource, readersFor } from "../apps/web/src/server/brand-assets"
|
|
import { closeApps, makeDocumentsApp, type SeededProject } from "./support/documents-app.js"
|
|
import { dimensions, markPng, pixelAt } from "./support/images.js"
|
|
|
|
const directories: string[] = []
|
|
afterEach(async () => {
|
|
await closeApps()
|
|
for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
})
|
|
|
|
const TEAL = [0x80, 0xcd, 0xc6, 255]
|
|
const svg = (fill: string) =>
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 80"><rect width="300" height="80" fill="${fill}"/></svg>`
|
|
|
|
/** A public folder holding the app brand's two logos: a blue rectangle for light, a red one for dark. */
|
|
function publicFolder(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), "share-public-"))
|
|
directories.push(dir)
|
|
writeFileSync(join(dir, "app-light.svg"), svg("#3366aa"))
|
|
writeFileSync(join(dir, "app-dark.svg"), svg("#aa3333"))
|
|
return dir
|
|
}
|
|
|
|
const appIdentity = { name: "Example App", logo: { light: "/app-light.svg", dark: "/app-dark.svg" } }
|
|
const empty: BrandView = { displayName: null, primary: null, accent: null, logoLight: null, logoDark: null }
|
|
|
|
async function acme(): Promise<{ project: SeededProject }> {
|
|
const app = await makeDocumentsApp({
|
|
document: { engine: { name: "unused", version: "0" }, page_count: 0, pages: [] },
|
|
})
|
|
return { project: await app.seedProject("acme", "owner@acme.test") }
|
|
}
|
|
|
|
const upload = (project: SeededProject, variant: "light" | "dark", bytes: Uint8Array) =>
|
|
project.call.callByName("branding.logo.set", {
|
|
org: project.orgSlug,
|
|
variant,
|
|
contentType: "image/png",
|
|
body: Buffer.from(bytes).toString("base64"),
|
|
})
|
|
|
|
// The logo box of the share image: 300 x 80 at (80, 80); a wide logo's middle is at (230, 120).
|
|
const logoCentre = (png: Uint8Array) => pixelAt(png, 230, 120)
|
|
|
|
describe("the app brand's logo from the public folder", () => {
|
|
it("is read from a file of the folder and from nowhere else", async () => {
|
|
const dir = publicFolder()
|
|
expect(await appLogoSource("/app-dark.svg", dir)).toMatchObject({ contentType: "image/svg+xml" })
|
|
await expect(appLogoSource("/../secret.svg", dir)).rejects.toThrow(/public folder/)
|
|
await expect(appLogoSource("/app.exe", dir)).rejects.toThrow(/PNG, WebP or SVG/)
|
|
await expect(appLogoSource("/missing.svg", dir)).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe("the share image", () => {
|
|
it("draws the app brand's logo for the dark page when there is no organization", async () => {
|
|
const dir = publicFolder()
|
|
const share = await shareBrand({
|
|
app: appIdentity,
|
|
tenant: null,
|
|
readers: { appLogo: (path) => appLogoSource(path, dir), tenantLogo: () => Promise.reject(new Error("none")) },
|
|
})
|
|
expect(share.name).toBe("Example App")
|
|
const png = await renderShareImage(share, { title: share.name })
|
|
expect(dimensions(png)).toEqual({ width: 1200, height: 630 })
|
|
expect(logoCentre(png)).toEqual([0xaa, 0x33, 0x33, 255])
|
|
})
|
|
|
|
it("draws an organization's own logo and display name, and the logo is the stored one", async () => {
|
|
const { project } = await acme()
|
|
await project.call.callByName("branding.brand.set", { org: project.orgSlug, displayName: "Acme Review" })
|
|
await upload(project, "dark", markPng(2000, 500))
|
|
const brand = (await project.call.callByName("branding.brand.get", { org: project.orgSlug })) as BrandView
|
|
const share = await shareBrand({
|
|
app: appIdentity,
|
|
tenant: { name: "Acme Ltd", brand },
|
|
readers: readersFor(project.call, project.orgSlug, publicFolder()),
|
|
})
|
|
expect(share.name).toBe("Acme Review")
|
|
expect(share.logo.contentType).toBe("image/png")
|
|
expect(logoCentre(await renderShareImage(share, { title: share.name }))).toEqual(TEAL)
|
|
})
|
|
|
|
it("draws the only logo the organization has when it is the light one, because the page is dark", async () => {
|
|
const { project } = await acme()
|
|
await upload(project, "light", markPng(2000, 500))
|
|
const brand = (await project.call.callByName("branding.brand.get", { org: project.orgSlug })) as BrandView
|
|
expect(brand.logoDark).toBeNull()
|
|
const share = await shareBrand({
|
|
app: appIdentity,
|
|
tenant: { name: "Acme Ltd", brand },
|
|
readers: readersFor(project.call, project.orgSlug, publicFolder()),
|
|
})
|
|
expect(share.name).toBe("Acme Ltd")
|
|
expect(logoCentre(await renderShareImage(share, { title: share.name }))).toEqual(TEAL)
|
|
})
|
|
|
|
it("is not stopped by a missing app logo file when the organization has a good logo of its own", async () => {
|
|
const { project } = await acme()
|
|
await upload(project, "dark", markPng(2000, 500))
|
|
const brand = (await project.call.callByName("branding.brand.get", { org: project.orgSlug })) as BrandView
|
|
const share = await shareBrand({
|
|
app: appIdentity,
|
|
tenant: { name: "Acme Ltd", brand },
|
|
readers: readersFor(project.call, project.orgSlug, mkdtempSync(join(tmpdir(), "share-nofile-"))),
|
|
})
|
|
expect(logoCentre(await renderShareImage(share, { title: share.name }))).toEqual(TEAL)
|
|
})
|
|
|
|
it("falls back to the app brand's logo for an organization with none", async () => {
|
|
const { project } = await acme()
|
|
const share = await shareBrand({
|
|
app: appIdentity,
|
|
tenant: { name: "Acme Ltd", brand: empty },
|
|
readers: readersFor(project.call, project.orgSlug, publicFolder()),
|
|
})
|
|
expect(logoCentre(await renderShareImage(share, { title: share.name }))).toEqual([0xaa, 0x33, 0x33, 255])
|
|
})
|
|
|
|
it("draws the app brand's logo instead when the chosen one cannot be drawn, and does not fail", async () => {
|
|
const dir = publicFolder()
|
|
const broken: ShareBrand = {
|
|
name: "Acme Ltd",
|
|
logo: { contentType: "image/png", bytes: new TextEncoder().encode("<svg/>") },
|
|
fallback: await appLogoSource("/app-dark.svg", dir),
|
|
}
|
|
expect(logoCentre(await renderShareImage(broken, { title: broken.name }))).toEqual([0xaa, 0x33, 0x33, 255])
|
|
const refused: ShareBrand = { ...broken, fallback: broken.logo }
|
|
await expect(renderShareImage(refused, { title: "x" })).rejects.toThrow(/logo cannot be used/)
|
|
})
|
|
})
|
|
|
|
describe("the email", () => {
|
|
const recorded = () => {
|
|
const sent: Array<ComposedEmail & { to: string }> = []
|
|
const transport: MailTransport = {
|
|
async send(message) {
|
|
sent.push(message)
|
|
},
|
|
}
|
|
return { sent, transport }
|
|
}
|
|
|
|
async function shareOf(logo: Uint8Array | null, dir = publicFolder()): Promise<ShareBrand> {
|
|
const { project } = await acme()
|
|
if (logo) await upload(project, "dark", logo)
|
|
const brand = (await project.call.callByName("branding.brand.get", { org: project.orgSlug })) as BrandView
|
|
return shareBrand({
|
|
app: appIdentity,
|
|
tenant: { name: "Acme Ltd", brand: { ...brand, displayName: "Acme Review" } },
|
|
readers: readersFor(project.call, project.orgSlug, dir),
|
|
})
|
|
}
|
|
|
|
it("carries the organization's name and its logo as an inline attachment, and names no host", async () => {
|
|
const mail = await composeEmail(await shareOf(markPng(2000, 500)), {
|
|
subject: "You are invited",
|
|
paragraphs: ["Hello,", "Open the app to accept."],
|
|
})
|
|
expect(mail.subject).toBe("You are invited - Acme Review")
|
|
expect(mail.html).toContain('alt="Acme Review"')
|
|
expect(mail.html).toContain('src="cid:brand-logo"')
|
|
expect(mail.html).not.toMatch(/https?:/)
|
|
expect(mail.html.toLowerCase()).not.toContain("conusai")
|
|
expect(mail.text).toBe("Hello,\n\nOpen the app to accept.\n\nAcme Review\n")
|
|
const [logo] = mail.attachments
|
|
expect(logo).toMatchObject({ cid: "brand-logo", contentType: "image/png" })
|
|
expect(dimensions(logo?.content ?? new Uint8Array())).toEqual({ width: 420, height: 105 })
|
|
expect(pixelAt(logo?.content ?? new Uint8Array(), 210, 52)).toEqual(TEAL)
|
|
})
|
|
|
|
it("uses the app brand's logo for an organization with none", async () => {
|
|
const mail = await composeEmail(await shareOf(null), { subject: "Hi", paragraphs: ["x"] })
|
|
const content = mail.attachments[0]?.content ?? new Uint8Array()
|
|
expect(dimensions(content).width).toBe(420)
|
|
expect(pixelAt(content, 210, 52)).toEqual([0xaa, 0x33, 0x33, 255])
|
|
})
|
|
|
|
it("escapes what it is given as text, and refuses a subject that is more than one line", async () => {
|
|
const share = await shareOf(null)
|
|
const mail = await composeEmail(share, { subject: "x", paragraphs: ['<script>alert("1")</script> & more'] })
|
|
expect(mail.html).toContain("<script>alert("1")</script> & more")
|
|
expect(mail.html).not.toContain("<script>")
|
|
await expect(composeEmail(share, { subject: "x\r\nBcc: someone@example.test", paragraphs: ["x"] })).rejects.toThrow(
|
|
/one line/,
|
|
)
|
|
await expect(composeEmail(share, { subject: "x", paragraphs: [] })).rejects.toThrow(/paragraph/)
|
|
})
|
|
|
|
it("is handed to the transport with one address, and refuses an address that carries a header", async () => {
|
|
const share = await shareOf(null)
|
|
const { sent, transport } = recorded()
|
|
await sendEmail(transport, share, "member@acme.test", { subject: "Welcome", paragraphs: ["Hello"] })
|
|
expect(sent).toHaveLength(1)
|
|
expect(sent[0]).toMatchObject({ to: "member@acme.test", subject: "Welcome - Acme Review" })
|
|
for (const to of ["a@b.test\r\nBcc: c@d.test", "a@b.test, c@d.test", "no-address", "a b@c.test"]) {
|
|
await expect(sendEmail(transport, share, to, { subject: "x", paragraphs: ["x"] })).rejects.toThrow(
|
|
/one email address/,
|
|
)
|
|
}
|
|
expect(sent).toHaveLength(1)
|
|
})
|
|
})
|