// A banner is not a thumbnail, and capping it like one is why banners never worked.
//
// ⛔ THE DEFECT. `avatars.put` took no cap: it always used MAX_BYTES, whose own comment says the limit
// is "a POLICY, not a transport limit — an avatar is shown at thumbnail size and is re-fetched by
// every peer that renders a byline". `banner-put` stores through the SAME machinery, deliberately —
// but a banner is a wide header (Bluesky's spec is 3000×1000) and at any reasonable quality does not
// fit in 512 KiB. So every banner upload threw.
//
// ⚠ AND IT LOOKED LIKE THE AVATAR WORKING. The settings form submits both pictures at once and the
// router writes the avatar FIRST, so the avatar was already saved when the banner threw. Measured on a
// live identity: the fediverse actor had `icon` set and `image` null, and the atproto profile had no
// banner either — one message at upload time, and three surfaces with nothing to show.
//
// ⛔ 1,000,000 EXACTLY, matching favela-bridge's MAX_PROFILE_IMAGE_BYTES — bsky's ceiling for a profile
// image. Accepting a banner this node could not project would move the failure from a message a person
// can act on to a "loss" line in a projection log they never read.
import test from 'brittle'
import fs from 'bare-fs'
import os from 'bare-os'
import { join } from 'bare-path'
import { MAX_BYTES, BANNER_MAX_BYTES } from '../lib/avatars.js'
// A real PNG header, so the magic-byte sniff passes and the size check is what is being tested.
const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex')
const pngOf = (bytes) => Buffer.concat([PNG, Buffer.alloc(Math.max(0, bytes - PNG.length), 0x20)])
const tmpFile = (buf) => {
const p = join(fs.mkdtempSync(join(os.tmpdir(), 'banner-cap-')), 'img.png')
fs.writeFileSync(p, buf)
return p
}
test('★★★ the banner cap is bsky\'s profile-image ceiling, not the avatar\'s thumbnail policy', (t) => {
t.is(MAX_BYTES, 512 * 1024, 'the avatar stays a thumbnail')
t.is(BANNER_MAX_BYTES, 1000000, 'and the banner gets bsky\'s ceiling')
t.ok(BANNER_MAX_BYTES > MAX_BYTES,
'a wide header cannot be held to a limit written for a picture shown beside a byline')
})
test('★★★ put takes the caller\'s cap, and says which kind it refused', async (t) => {
// ⛔ The store is shared on purpose — "a banner needs a verb, not a mechanism" — so the CAP has to
// be the caller's, or the shared machinery silently imposes the wrong policy on one of them.
const { Avatars } = await import('../lib/avatars.js')
t.ok(Avatars, 'the store is exported')
// The size check runs before any I/O or hashing, so a stub overlay is enough to reach it.
const store = new Avatars(fs.mkdtempSync(join(os.tmpdir(), 'avstore-')), { hashFile: async () => ({ contentHash: 'x'.repeat(64) }), registerFile: async () => {} })
const big = tmpFile(pngOf(700 * 1024)) // over the avatar cap, under the banner cap
await t.exception(() => store.put(big), /avatar is 700 KiB and the cap is 512 KiB/,
'refused as an avatar, and the message names the kind')
// ⛔ THE SAME FILE, ACCEPTED AS A BANNER. This is the whole bug: identical bytes, identical store,
// and the only thing that made it impossible was a cap that belonged to the other picture.
const ok = await store.put(big, { maxBytes: BANNER_MAX_BYTES, kind: 'banner' })
t.ok(ok && ok.oid, 'accepted as a banner')
t.is(ok.mimeType, 'image/png')
})
test('★★ a banner over bsky\'s ceiling is still refused, at upload time', async (t) => {
const { Avatars } = await import('../lib/avatars.js')
const store = new Avatars(fs.mkdtempSync(join(os.tmpdir(), 'avstore2-')), { hashFile: async () => ({ contentHash: 'y'.repeat(64) }), registerFile: async () => {} })
const huge = tmpFile(pngOf(1200 * 1024))
await t.exception(() => store.put(huge, { maxBytes: BANNER_MAX_BYTES, kind: 'banner' }), /banner is 1200 KiB/,
'said once, where a person can act on it, rather than as a loss line in a projection log')
})