Files
buzz/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzNotificationAvatarLoaderTests.swift
John Matthew Tennant b894fb02ff fix(mobile): show transparent avatars without a black background (#9105)
## Summary

Transparent PNG and GIF avatars render on a black background in mobile
avatars and iOS notification avatars. Both load the relay's small
thumbnail, which was always a JPEG with no alpha channel.

[block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays
write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside
the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP
first.

- `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then
`.thumb.jpg`, then the original. Only a 404 moves on to the next source;
any other failure still shows the error widget. Missing thumbnails are
remembered as before, so later builds skip straight to the source that
exists.
- `BuzzNotificationAvatarLoader` follows the same order for notification
avatars.

Relays without 9003, and images uploaded before it, keep rendering from
the JPEG as they do today.

### Screenshots

A transparent PNG avatar rendered by `MediaImage` on the same relay,
before and after (widget harness on an iOS simulator).

| Before | After |
| --- | --- |
| ![Before: black square behind the
avatar](https://github.com/user-attachments/assets/5da0a704-df15-4917-9d5c-9745bc83224d)
| ![After: transparent
corners](https://github.com/user-attachments/assets/3d31f4d4-cf65-4923-b367-4fd8cf3af679)
|

### Related issue

AIDA-2308 (internal). The desktop counterpart is
[block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477).

### Testing

- `missing thumbnails fall back to the JPEG, then the original`
(`avatar_image_test.dart`): when the relay 404s every thumbnail, the
avatar requests the WebP, then the JPEG, then the original. When the
WebP fails with a 500, the avatar shows its fallback and requests
neither the JPEG nor the original.
- The relay thumbnail journey tests run against a relay that 404s
`.thumb.webp`, so they cover the older-relay path end to end.
- `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG
thumbnail order for relay media, and that non-relay media gets no
thumbnails.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:32:58 -08:00

268 lines
11 KiB
Swift

import Foundation
import ImageIO
import Testing
@testable import BuzzPushKit
@Suite("Native notification avatar decoding")
struct BuzzNotificationAvatarLoaderTests {
@Test("Avatar proofs preserve IPv6 brackets and explicit ports")
func signedServerAuthority() throws {
let key = String(repeating: "0", count: 63) + "1"
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
for authority in ["[::1]:3000", "[2001:db8::1]", "relay.example:443", "relay.example"] {
let url = try #require(URL(string: "https://\(authority)/media/avatar.png"))
let header = try BuzzNotificationAvatarLoader.authorization(url: url, key: key, account: account)
let data = try #require(Data(base64Encoded: String(header.dropFirst(6))))
let event = try JSONDecoder().decode(VerifiedNostrEvent.self, from: data)
#expect(event.tags.contains(["server", authority]))
#expect(event.kind == 24242)
#expect(event.hasValidIDAndSignature())
}
}
@Test("Relay media uses canonical hosts and default ports without trusting other origins")
func mediaOrigins() throws {
let hash = String(repeating: "a", count: 64)
for (relay, media, expected) in [
("wss://relay.example:443", "https://relay.example", true),
("wss://relay.example", "https://RELAY.EXAMPLE:443", true),
("ws://relay.example:80", "http://RELAY.EXAMPLE", true),
("wss://relay.example:8443", "https://relay.example:8443", true),
("wss://relay.example", "https://relay.example:8443", false),
("wss://relay.example", "http://relay.example", false),
("wss://relay.example", "https://other.example", false),
] {
let scope = try #require(
BuzzNotificationCacheScope(
community: PushLeaseCommunity(
id: "community", name: "Test", relayUrl: relay, pubkey: "account", policies: [],
notificationSessionID: "session")))
let url = try #require(URL(string: "\(media)/media/\(hash).png"))
#expect(
BuzzNotificationAvatarLoader.thumbnailURLs(url, scope: scope).map(\.lastPathComponent)
== (expected ? ["\(hash).thumb.webp", "\(hash).thumb.jpg"] : []))
}
}
@Test("Animated avatar selects the poster without fetching the animation")
func animatedPoster() throws {
let source =
"https://images.example/poster.png#buzz-anim=https%3A%2F%2Fimages.example%2Fanimation.apng"
#expect(
BuzzNotificationAvatarLoader.posterURL(source)?.absoluteString
== "https://images.example/poster.png")
#expect(BuzzNotificationAvatarLoader.posterURL("file:///private/avatar.png") == nil)
}
@Test("Raster decoder produces a bounded PNG and rejects non-image data")
func raster() throws {
let bytes = try #require(
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII="
))
let png = try BuzzNotificationAvatarLoader.decode(bytes)
let source = try #require(CGImageSourceCreateWithData(png as CFData, nil))
let properties = try #require(
CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
#expect(properties[kCGImagePropertyPixelWidth] as? Int == 1)
#expect(properties[kCGImagePropertyPixelHeight] as? Int == 1)
#expect(png.count <= BuzzNotificationCache.maximumAvatarBytes)
#expect(throws: BuzzNotificationCacheError.self) {
try BuzzNotificationAvatarLoader.decode(Data("not an image".utf8))
}
}
@Test("A scope revoked while the assertion is issued never starts the signed download")
func scopeRevokedDuringAssertionIssuance() async throws {
let key = String(repeating: "0", count: 63) + "1"
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
let scope = try #require(
BuzzNotificationCacheScope(
community: PushLeaseCommunity(
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
policies: [], notificationSessionID: "session")))
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [AssertionIssuanceStub.self]
let state = AvatarScopeState()
AssertionIssuanceStub.onRequest = { state.revoke() }
defer { AssertionIssuanceStub.onRequest = nil }
let issuer = BuzzRelayAssertionIssuer(
session: URLSession(configuration: configuration),
loadSession: {
BuzzOrganizationSession(
origin: "https://identity.org.example", credential: "session-credential",
relayHosts: ["relay.org.example"])
})
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let loader = BuzzNotificationAvatarLoader(
directory: directory, privateKey: { _ in key }, isCurrent: { _ in state.isCurrent },
assertionIssuer: issuer)
loader.startDownload = { _, _, _, completion in
state.recordDownload()
completion(.failure(URLError(.cancelled)))
}
let result = await withCheckedContinuation { continuation in
loader.load(
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
scope: scope
) { continuation.resume(returning: $0) }
}
guard case .failure(BuzzNotificationCacheError.revoked) = result else {
Issue.record("Expected a revoked failure, got \(result)")
return
}
#expect(state.downloads == 0)
}
@Test("An organization relay avatar is fetched with the issued assertion")
func relayAvatarCarriesAssertion() async throws {
let key = String(repeating: "0", count: 63) + "1"
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
let scope = try #require(
BuzzNotificationCacheScope(
community: PushLeaseCommunity(
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
policies: [], notificationSessionID: "session")))
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [AssertionIssuanceStub.self]
let issuer = BuzzRelayAssertionIssuer(
session: URLSession(configuration: configuration),
loadSession: {
BuzzOrganizationSession(
origin: "https://identity.org.example", credential: "session-credential",
relayHosts: ["relay.org.example"])
})
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let loader = BuzzNotificationAvatarLoader(
directory: directory, privateKey: { _ in key }, isCurrent: { _ in true },
assertionIssuer: issuer)
let requests = AvatarRequests()
loader.startDownload = { request, _, _, completion in
requests.record(request)
completion(.failure(URLError(.cancelled)))
}
_ = await withCheckedContinuation { continuation in
loader.load(
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
scope: scope
) { continuation.resume(returning: $0) }
}
let request = try #require(requests.first)
#expect(
request.value(forHTTPHeaderField: "Nostr-Federated-Identity")
== "Bearer header.payload.signature")
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
}
@Test("An avatar fetched with an assertion from a replaced organization session is refused")
func replacedOrganizationSessionRefusesAvatar() async throws {
let key = String(repeating: "0", count: 63) + "1"
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
let scope = try #require(
BuzzNotificationCacheScope(
community: PushLeaseCommunity(
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
policies: [], notificationSessionID: "session")))
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [AssertionIssuanceStub.self]
let sessions = OrganizationSessions()
let issuer = BuzzRelayAssertionIssuer(
session: URLSession(configuration: configuration), loadSession: { sessions.current })
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let loader = BuzzNotificationAvatarLoader(
directory: directory, privateKey: { _ in key }, isCurrent: { _ in true },
assertionIssuer: issuer)
let png = try #require(
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII="
))
loader.startDownload = { _, _, _, completion in
// The member signs in again while the avatar is in flight; the community is unchanged.
sessions.replace()
completion(.success(png))
}
let result = await withCheckedContinuation { continuation in
loader.load(
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
scope: scope
) { continuation.resume(returning: $0) }
}
guard case .failure(BuzzNotificationCacheError.revoked) = result else {
Issue.record("Expected a revoked failure, got \(result)")
return
}
}
}
private final class AvatarRequests: @unchecked Sendable {
private let lock = NSLock()
private var requests: [URLRequest] = []
var first: URLRequest? { lock.withLock { requests.first } }
func record(_ request: URLRequest) { lock.withLock { requests.append(request) } }
}
private final class OrganizationSessions: @unchecked Sendable {
private let lock = NSLock()
private var credential = "session-credential"
var current: BuzzOrganizationSession {
lock.withLock {
BuzzOrganizationSession(
origin: "https://identity.org.example", credential: credential,
relayHosts: ["relay.org.example"])
}
}
func replace() { lock.withLock { credential = "replacement-credential" } }
}
private final class AvatarScopeState: @unchecked Sendable {
private let lock = NSLock()
private var current = true
private var started = 0
var isCurrent: Bool { lock.withLock { current } }
var downloads: Int { lock.withLock { started } }
func revoke() { lock.withLock { current = false } }
func recordDownload() { lock.withLock { started += 1 } }
}
/// Answers the organization's assertion issuer, running `onRequest` while issuance is in flight.
private final class AssertionIssuanceStub: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var onRequest: (() -> Void)?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.onRequest?()
let body = try! JSONSerialization.data(withJSONObject: [
"assertion": "header.payload.signature",
"nostr_pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"expires_at": Int(Date().timeIntervalSince1970) + 300,
])
let response = HTTPURLResponse(
url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body)
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}