mirror of
https://github.com/block/buzz.git
synced 2026-11-07 09:52:03 +00:00
fix(mobile): send hosted relay auth with iOS custom emoji downloads (#9056)
On iOS, custom emoji in the native emoji picker often render as the sparkle fallback icon instead of the image. The native picker downloads custom emoji with `URLSession` and asks Flutter for request headers over the `mediaHeaders` channel call. That call returned only the media read `Authorization` header. Hosted relays also require the `Nostr-Federated-Identity` assertion, which the Flutter image path gets from `HostedRelayHttpClient`, so relay-hosted emoji get a 401 and the picker falls back to sparkles. Emoji hosted elsewhere still load, which is why only some are missing. The `mediaHeaders` bridge now also includes the hosted relay assertion via a new `HostedRelayAuth.headersForUrl`, scoped to the relay's own origin like `HostedRelayHttpClient`. The native loader refuses redirects for requests that carry the assertion, matching the Dart client, so the token can't be forwarded to another host. The iOS reactions sheet had the same gap: it passes avatar and custom emoji URLs to native views with only the `Authorization` header. A new `nativeMediaHeadersProvider` combines both headers for any native media consumer, and the reactions sheet uses it. ### Validation - Added tests: the native picker's `mediaHeaders` call returns the relay assertion for relay media and no headers for an external emoji URL; `headersForUrl` returns the assertion only for the relay origin (not another host, subdomain or port). The reactions sheet passes the combined headers for avatars and custom emoji, and `nativeMediaHeadersProvider` merges both headers only for relay URLs. - Focused existing validation: the emoji picker and organization relay auth suites pass, and `flutter analyze` is clean. In the full mobile suite, the only failures are 10 in `notification_toggle_test`, `push_bootstrap_test` and `push_bridge_test`, and they fail the same way on `main`. - Automated full-app simulator run (iPhone 17 Pro, iOS 27, debug build of this branch with hosted auth enabled, signed in to two hosted communities through a cloned simulator keychain, driven with AXe). Covered: launch signed in, channel and DM lists, opening a channel and a DM, the native emoji picker (its Custom section shows the emoji as an image, not the sparkle fallback), sending a custom emoji message and seeing it render, reacting with the custom emoji (shown in the quick-reaction row and as a reaction pill), opening a thread, switching communities, avatars and community icons, background and resume, and a cold relaunch. Result: no crashes or regressions seen. Screenshots are omitted because they show real account and community names. - Limitations: this run does not reproduce the original bug. The hosted community's custom emoji also loaded in a temporary build with the relay assertion removed from the `mediaHeaders` bridge, so its emoji URL evidently doesn't need the assertion, and the run doesn't prove the fix. The reactions detail sheet could not be reached because simulated long presses on the pill opened the message menu, and inline image attachments were not exercised. A device build against a community whose emoji are relay-hosted is still needed to confirm the fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -65,6 +65,7 @@
|
||||
AB1200002F40400100A17E01 /* NativeMessagePresentationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200102F40400100A17E01 /* NativeMessagePresentationCoordinator.swift */; };
|
||||
AB1200022F40400100A17E01 /* NativeReactionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200122F40400100A17E01 /* NativeReactionDetails.swift */; };
|
||||
AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */; };
|
||||
AB12AA012F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB12AA022F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift */; };
|
||||
AB1200032F40400100A17E09 /* IdentityFilePluginTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -176,6 +177,7 @@
|
||||
AB1200102F40400100A17E01 /* NativeMessagePresentationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessagePresentationCoordinator.swift; sourceTree = "<group>"; };
|
||||
AB1200122F40400100A17E01 /* NativeReactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeReactionDetails.swift; sourceTree = "<group>"; };
|
||||
AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageImageLoaderTests.swift; sourceTree = "<group>"; };
|
||||
AB12AA022F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiRedirectPolicyTests.swift; sourceTree = "<group>"; };
|
||||
AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityFilePluginTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -215,6 +217,7 @@
|
||||
BC0010000000000000000004 /* BrowserAuthBridgeTests.swift */,
|
||||
F84380010000000000000002 /* ProfileSetupRecoveryTests.swift */,
|
||||
AB1200132F40400100A17E01 /* NativeMessageImageLoaderTests.swift */,
|
||||
AB12AA022F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift */,
|
||||
AB1200132F40400100A17E09 /* IdentityFilePluginTests.swift */,
|
||||
BZZ0000000000000000002C /* BuzzCommunicationNotificationTests.swift */,
|
||||
331C80A0294A618700263BE5 /* Fixtures */,
|
||||
@@ -627,6 +630,7 @@
|
||||
BC0010000000000000000003 /* BrowserAuthBridgeTests.swift in Sources */,
|
||||
F84380010000000000000001 /* ProfileSetupRecoveryTests.swift in Sources */,
|
||||
AB1200032F40400100A17E01 /* NativeMessageImageLoaderTests.swift in Sources */,
|
||||
AB12AA012F40400100A17E01 /* NativeEmojiRedirectPolicyTests.swift in Sources */,
|
||||
AB1200032F40400100A17E09 /* IdentityFilePluginTests.swift in Sources */,
|
||||
BZZ0000000000000000002B /* BuzzCommunicationNotificationTests.swift in Sources */,
|
||||
);
|
||||
|
||||
@@ -557,7 +557,10 @@ actor NativeEmojiRemoteImageLoader {
|
||||
}
|
||||
|
||||
private static func download(_ request: URLRequest) async throws -> UIImage {
|
||||
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
||||
let (bytes, response) = try await URLSession.shared.bytes(
|
||||
for: request,
|
||||
delegate: NativeEmojiRedirectPolicy.shared
|
||||
)
|
||||
guard
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
@@ -620,6 +623,26 @@ actor NativeEmojiRemoteImageLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors the Dart hosted HTTP client, which disables redirects for requests
|
||||
/// carrying the relay assertion: a redirect must not forward that bearer token
|
||||
/// to another host. The 3xx then fails the load like any non-2xx response.
|
||||
final class NativeEmojiRedirectPolicy: NSObject, URLSessionTaskDelegate {
|
||||
static let shared = NativeEmojiRedirectPolicy()
|
||||
static let relayAssertionHeader = "Nostr-Federated-Identity"
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest,
|
||||
completionHandler: @escaping (URLRequest?) -> Void
|
||||
) {
|
||||
let carriesAssertion =
|
||||
task.originalRequest?.value(forHTTPHeaderField: Self.relayAssertionHeader) != nil
|
||||
completionHandler(carriesAssertion ? nil : request)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TablerNativeIcon: View {
|
||||
let symbol: String
|
||||
var body: some View {
|
||||
|
||||
56
mobile/ios/RunnerTests/NativeEmojiRedirectPolicyTests.swift
Normal file
56
mobile/ios/RunnerTests/NativeEmojiRedirectPolicyTests.swift
Normal file
@@ -0,0 +1,56 @@
|
||||
import XCTest
|
||||
@testable import Buzz
|
||||
|
||||
final class NativeEmojiRedirectPolicyTests: XCTestCase {
|
||||
private let session = URLSession(configuration: .ephemeral)
|
||||
|
||||
override func tearDown() {
|
||||
session.invalidateAndCancel()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
/// Runs the production delegate for a redirect of `original` and returns
|
||||
/// the request it chose to follow (nil means the redirect is refused).
|
||||
private func followedRequest(for original: URLRequest) -> URLRequest? {
|
||||
// A task that is never resumed still exposes `originalRequest`.
|
||||
let task = session.dataTask(with: original)
|
||||
let redirectTarget = URL(string: "https://elsewhere.example/avatar.png")!
|
||||
let response = HTTPURLResponse(
|
||||
url: original.url!, statusCode: 302, httpVersion: nil,
|
||||
headerFields: ["Location": redirectTarget.absoluteString]
|
||||
)!
|
||||
var followed: URLRequest?
|
||||
var called = false
|
||||
NativeEmojiRedirectPolicy.shared.urlSession(
|
||||
session,
|
||||
task: task,
|
||||
willPerformHTTPRedirection: response,
|
||||
newRequest: URLRequest(url: redirectTarget)
|
||||
) { request in
|
||||
called = true
|
||||
followed = request
|
||||
}
|
||||
XCTAssertTrue(called, "the delegate must always call its completion handler")
|
||||
return followed
|
||||
}
|
||||
|
||||
func testRefusesRedirectForRequestCarryingRelayAssertion() {
|
||||
var request = URLRequest(url: URL(string: "https://relay.example/media/emoji.png")!)
|
||||
request.setValue("token", forHTTPHeaderField: NativeEmojiRedirectPolicy.relayAssertionHeader)
|
||||
|
||||
XCTAssertNil(followedRequest(for: request))
|
||||
}
|
||||
|
||||
func testFollowsRedirectForOrdinaryRequest() {
|
||||
let request = URLRequest(url: URL(string: "https://cdn.example/emoji.png")!)
|
||||
|
||||
XCTAssertEqual(
|
||||
followedRequest(for: request)?.url,
|
||||
URL(string: "https://elsewhere.example/avatar.png")
|
||||
)
|
||||
}
|
||||
|
||||
func testAssertionHeaderNameMatchesDartBridge() {
|
||||
XCTAssertEqual(NativeEmojiRedirectPolicy.relayAssertionHeader, "Nostr-Federated-Identity")
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,10 @@ import '../../shared/widgets/app_list_card_item.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/native_message_presentation.dart';
|
||||
import '../../shared/relay/media_auth.dart';
|
||||
import '../../shared/hosted/builderlab_account.dart';
|
||||
import '../../shared/hosted/hosted_relay_auth.dart';
|
||||
import '../../shared/hosted/native_media_headers.dart';
|
||||
import '../../shared/relay/relay_provider.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/emoji/emoji_burst.dart';
|
||||
import '../../shared/emoji/emoji_avatar.dart';
|
||||
@@ -725,6 +728,9 @@ Future<bool> _showNativeReactionDetails(
|
||||
var addRequested = false;
|
||||
final handled = await NativeMessagePresentation.withLease(() async {
|
||||
final container = ProviderScope.containerOf(context, listen: false);
|
||||
final theme = Theme.of(context);
|
||||
if (reactions.value.isEmpty) return true;
|
||||
final nativeMediaHeaders = container.read(nativeMediaHeadersProvider);
|
||||
final requestId = DateTime.now().microsecondsSinceEpoch.toString();
|
||||
List<String> pubkeys() =>
|
||||
reactions.value.expand((r) => r.userPubkeys).toSet().toList();
|
||||
@@ -757,9 +763,7 @@ Future<bool> _showNativeReactionDetails(
|
||||
'avatarColor': avatar.colorValue,
|
||||
} else if (url != null && !url.startsWith('data:')) ...{
|
||||
'url': url,
|
||||
'headers': container
|
||||
.read(mediaGetAuthServiceProvider)
|
||||
.headersFor(url),
|
||||
'headers': nativeMediaHeaders(url),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -818,9 +822,7 @@ Future<bool> _showNativeReactionDetails(
|
||||
'users': reaction.userPubkeys.toSet().toList(),
|
||||
if (reaction.emojiUrl case final String url) ...{
|
||||
'url': url,
|
||||
'headers': container
|
||||
.read(mediaGetAuthServiceProvider)
|
||||
.headersFor(url),
|
||||
'headers': nativeMediaHeaders(url),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -839,18 +841,36 @@ Future<bool> _showNativeReactionDetails(
|
||||
}
|
||||
|
||||
reactions.addListener(updateReactions);
|
||||
// Native headers read the cached assertion synchronously, and that cache
|
||||
// reads as empty inside its refresh margin while the live socket is still
|
||||
// valid. Present at once with what is cached, refresh in the background,
|
||||
// and resend the media rows if the refresh produced a new assertion, so a
|
||||
// stalled issuer never delays the sheet.
|
||||
final config = container.read(relayConfigProvider);
|
||||
final auth = container.read(hostedRelayAuthProvider);
|
||||
final cachedAuth = auth.cachedHeaders(config);
|
||||
unawaited(() async {
|
||||
try {
|
||||
await auth.headers(config);
|
||||
} on AccountFailure {
|
||||
// Send without the assertion; the relay decides, and sign-in is
|
||||
// offered elsewhere.
|
||||
return;
|
||||
}
|
||||
if (disposed || mapEquals(auth.cachedHeaders(config), cachedAuth)) return;
|
||||
sent.clear();
|
||||
updateReactions();
|
||||
}());
|
||||
try {
|
||||
final sheetColors = utilitySurfaceThemeData(
|
||||
Theme.of(context),
|
||||
).colorScheme;
|
||||
final sheetColors = utilitySurfaceThemeData(theme).colorScheme;
|
||||
final pending = NativeMessagePresentation.present('reactions', {
|
||||
'requestId': requestId,
|
||||
'sheetColor': sheetColors.surface.toARGB32(),
|
||||
'foregroundColor': sheetColors.primary.toARGB32(),
|
||||
'outlineColor': Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.25).toARGB32(),
|
||||
'dark': Theme.of(context).brightness == Brightness.dark,
|
||||
'outlineColor': theme.colorScheme.onSurface
|
||||
.withValues(alpha: 0.25)
|
||||
.toARGB32(),
|
||||
'dark': theme.brightness == Brightness.dark,
|
||||
'canAddReaction': onAddReaction != null,
|
||||
'addReactionIconCodePoint': BuzzIcons.smilePlus.codePoint,
|
||||
'profiles': profiles(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../emoji/emoji_data.dart';
|
||||
import '../emoji/emoji_data_provider.dart';
|
||||
import '../emoji/emoji_search.dart';
|
||||
import '../emoji/native_emoji_glyph.dart';
|
||||
import '../hosted/hosted_relay_auth.dart';
|
||||
import '../relay/relay.dart';
|
||||
import '../theme/theme.dart';
|
||||
import '../widgets/buzz_sheet_header.dart';
|
||||
|
||||
@@ -119,6 +119,8 @@ Future<void> _presentIosEmojiPicker({
|
||||
}
|
||||
final recent = container.read(recentEmojiProvider);
|
||||
final mediaAuth = container.read(mediaGetAuthServiceProvider);
|
||||
final hostedAuth = container.read(hostedRelayAuthProvider);
|
||||
final relayConfig = container.read(relayConfigProvider);
|
||||
final prefs = container.read(savedPrefsProvider);
|
||||
final colors = context.colors;
|
||||
var dismissed = false;
|
||||
@@ -135,9 +137,16 @@ Future<void> _presentIosEmojiPicker({
|
||||
switch (call.method) {
|
||||
case 'mediaHeaders':
|
||||
final url = call.arguments;
|
||||
return url is String
|
||||
? mediaAuth.headersFor(url)
|
||||
: const <String, String>{};
|
||||
if (url is! String) return const <String, String>{};
|
||||
// Native downloads bypass the hosted HTTP client, so add its relay
|
||||
// assertion here too: a hosted relay refuses media reads without it.
|
||||
// Await the assertion before minting the short-lived media proof so a
|
||||
// slow refresh cannot age it.
|
||||
final assertion = await hostedAuth.headersForUrl(
|
||||
relayConfig,
|
||||
Uri.parse(url),
|
||||
);
|
||||
return <String, String>{...mediaAuth.headersFor(url), ...assertion};
|
||||
case 'selected':
|
||||
final emoji = call.arguments;
|
||||
if (emoji is String && emoji.isNotEmpty) onSelect(emoji);
|
||||
|
||||
@@ -136,6 +136,28 @@ class HostedRelayAuth extends ChangeNotifier {
|
||||
return {'Nostr-Federated-Identity': 'Bearer ${cached.token}'};
|
||||
}
|
||||
|
||||
/// [headers] for a request made outside [HostedRelayHttpClient], such as a
|
||||
/// native download. Scoped like that client: only the relay's own origin
|
||||
/// gets the assertion, so it never reaches a third-party host.
|
||||
Future<Map<String, String>> headersForUrl(RelayConfig config, Uri url) async {
|
||||
if (!_isRelayOrigin(config, url)) return const {};
|
||||
return headers(config);
|
||||
}
|
||||
|
||||
/// Synchronous [headersForUrl] for callers that hand headers to native code
|
||||
/// up front. Uses the assertion the live relay session already holds; with
|
||||
/// none cached the request goes out without it and the relay decides.
|
||||
Map<String, String> cachedHeadersForUrl(RelayConfig config, Uri url) {
|
||||
if (!_isRelayOrigin(config, url)) return const {};
|
||||
return cachedHeaders(config) ?? const {};
|
||||
}
|
||||
|
||||
static bool _isRelayOrigin(RelayConfig config, Uri url) =>
|
||||
(url.scheme == 'http' || url.scheme == 'https') &&
|
||||
// `Uri.origin` throws for a hostless URI, which a community can supply.
|
||||
url.host.isNotEmpty &&
|
||||
url.origin == Uri.parse(config.baseUrl).origin;
|
||||
|
||||
Future<Map<String, String>> headers(RelayConfig config) async {
|
||||
final client = _accountFor(config);
|
||||
if (client == null) return const {};
|
||||
|
||||
26
mobile/lib/shared/hosted/native_media_headers.dart
Normal file
26
mobile/lib/shared/hosted/native_media_headers.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../relay/relay.dart';
|
||||
import 'hosted_relay_auth.dart';
|
||||
|
||||
/// Headers for a relay media read that does not go through
|
||||
/// [HostedRelayHttpClient], such as a native player or image loader that takes
|
||||
/// headers up front.
|
||||
///
|
||||
/// [MediaGetAuthService.headersFor] alone is not enough on a hosted relay:
|
||||
/// media reads also need the relay assertion, which only the hosted client adds.
|
||||
/// The assertion is scoped to the relay origin and comes from the live
|
||||
/// session's cache, so building headers never blocks.
|
||||
final nativeMediaHeadersProvider =
|
||||
Provider<Map<String, String> Function(String)>((ref) {
|
||||
final media = ref.watch(mediaGetAuthServiceProvider);
|
||||
final hosted = ref.watch(hostedRelayAuthProvider);
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
return (url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
return {
|
||||
...media.headersFor(url),
|
||||
if (uri != null) ...hosted.cachedHeadersForUrl(config, uri),
|
||||
};
|
||||
};
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:buzz/shared/emoji/emoji_picker.dart';
|
||||
import 'package:buzz/shared/emoji/recent_emoji_provider.dart';
|
||||
@@ -6,6 +7,8 @@ import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_data.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -136,6 +139,30 @@ const _relayCustomEmoji = [
|
||||
),
|
||||
];
|
||||
|
||||
class _FakeRelayConfigNotifier extends RelayConfigNotifier {
|
||||
_FakeRelayConfigNotifier(this.nsec);
|
||||
|
||||
final String nsec;
|
||||
|
||||
@override
|
||||
RelayConfig build() =>
|
||||
RelayConfig(baseUrl: 'https://relay.example', nsec: nsec);
|
||||
}
|
||||
|
||||
/// A hosted relay that issues a fixed assertion.
|
||||
class _FakeHostedRelayAuth extends HostedRelayAuth {
|
||||
_FakeHostedRelayAuth() : super(BuilderlabAccountClient(), enabled: false);
|
||||
|
||||
/// While set, issuance waits for it, like a slow account request.
|
||||
Completer<void>? gate;
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> headers(RelayConfig config) async {
|
||||
await gate?.future;
|
||||
return {'Nostr-Federated-Identity': 'Bearer hosted-token'};
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeCustomEmojiPaletteNotifier extends CustomEmojiPaletteNotifier {
|
||||
_FakeCustomEmojiPaletteNotifier(this.palette);
|
||||
|
||||
@@ -704,10 +731,15 @@ void main() {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final prefs = await _prefs();
|
||||
final nsec = nostr.Keys.generate().nsec;
|
||||
var clock = DateTime.utc(2026, 11, 5, 12);
|
||||
final mediaAuth = MediaGetAuthService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
nsec: nsec,
|
||||
now: () => clock,
|
||||
);
|
||||
final hostedAuth = _FakeHostedRelayAuth();
|
||||
addTearDown(hostedAuth.dispose);
|
||||
MethodCall? presentation;
|
||||
final selected = <String>[];
|
||||
var dismissals = 0;
|
||||
@@ -728,6 +760,10 @@ void main() {
|
||||
),
|
||||
),
|
||||
mediaGetAuthServiceProvider.overrideWithValue(mediaAuth),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FakeRelayConfigNotifier(nsec),
|
||||
),
|
||||
hostedRelayAuthProvider.overrideWithValue(hostedAuth),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
@@ -781,6 +817,41 @@ void main() {
|
||||
(relayHeaders as Map<Object?, Object?>)['Authorization'],
|
||||
startsWith('Nostr '),
|
||||
);
|
||||
// Native downloads skip the hosted HTTP client, so the bridge must
|
||||
// carry the relay assertion a hosted relay requires for media reads.
|
||||
expect(relayHeaders['Nostr-Federated-Identity'], 'Bearer hosted-token');
|
||||
|
||||
// A slow assertion refresh must not age the 60-second media proof:
|
||||
// it is minted only once the assertion is in hand.
|
||||
hostedAuth.gate = Completer<void>();
|
||||
final slowHeaders = _sendNativeEmojiPickerCall(
|
||||
tester,
|
||||
'mediaHeaders',
|
||||
'https://relay.example/media/buzzbee.png',
|
||||
);
|
||||
await tester.pump();
|
||||
clock = clock.add(const Duration(seconds: 90));
|
||||
hostedAuth.gate!.complete();
|
||||
final refreshed = (await slowHeaders) as Map<Object?, Object?>;
|
||||
hostedAuth.gate = null;
|
||||
final proof =
|
||||
jsonDecode(
|
||||
utf8.decode(
|
||||
base64Url.decode(
|
||||
base64Url.normalize(
|
||||
(refreshed['Authorization'] as String).substring(6),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
as Map<String, Object?>;
|
||||
final expiration = (proof['tags'] as List)
|
||||
.cast<List<Object?>>()
|
||||
.firstWhere((tag) => tag.first == 'expiration')[1];
|
||||
expect(
|
||||
int.parse(expiration as String),
|
||||
greaterThan(clock.millisecondsSinceEpoch ~/ 1000),
|
||||
);
|
||||
|
||||
await _sendNativeEmojiPickerCall(tester, 'skinToneChanged', 4);
|
||||
await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji_render.dart';
|
||||
import 'package:buzz/shared/emoji/native_emoji_glyph.dart';
|
||||
import 'package:buzz/shared/relay/media_image.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_avatar.dart';
|
||||
@@ -12,6 +11,13 @@ import 'package:buzz/features/channels/channel_identity_names_provider.dart';
|
||||
import 'package:buzz/shared/identity_names/identity_names.dart';
|
||||
import 'package:buzz/features/channels/message_long_press_region.dart';
|
||||
import 'package:buzz/features/channels/reaction_row.dart';
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/hosted/native_media_headers.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'dart:convert';
|
||||
import 'package:buzz/features/channels/timeline_message.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_burst.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_data.dart';
|
||||
@@ -37,6 +43,9 @@ const _fire = '\u{1F525}';
|
||||
/// 👀 — a real reaction that is deliberately not in the positive-burst set.
|
||||
const _eyes = '\u{1F440}';
|
||||
|
||||
const _aliceAvatarUrl = 'https://relay.example/media/alice.png';
|
||||
const _customEmojiUrl = 'https://relay.example/media/fire.png';
|
||||
|
||||
TimelineReaction _reaction({
|
||||
String emoji = _fire,
|
||||
int count = 1,
|
||||
@@ -297,9 +306,18 @@ void main() {
|
||||
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
|
||||
userCacheProvider.overrideWith(
|
||||
() => _FakeUserCacheNotifier({
|
||||
_alice: const UserProfile(pubkey: _alice, displayName: 'Honey'),
|
||||
_alice: const UserProfile(
|
||||
pubkey: _alice,
|
||||
displayName: 'Honey',
|
||||
avatarUrl: _aliceAvatarUrl,
|
||||
),
|
||||
}),
|
||||
),
|
||||
// Native image loads bypass the hosted HTTP client, so the sheet
|
||||
// must carry the relay-auth headers for each image itself.
|
||||
nativeMediaHeadersProvider.overrideWithValue(
|
||||
(url) => {'Nostr-Federated-Identity': 'Bearer $url'},
|
||||
),
|
||||
channelIdentityNamesProvider('channel').overrideWith(
|
||||
(ref) =>
|
||||
IdentityNameSources(
|
||||
@@ -316,7 +334,13 @@ void main() {
|
||||
messageId: _messageId,
|
||||
channelId: 'channel',
|
||||
reactions: [
|
||||
_reaction(userPubkeys: [_alice, bot, roster]),
|
||||
TimelineReaction(
|
||||
emoji: _fire,
|
||||
count: 3,
|
||||
reactedByCurrentUser: false,
|
||||
userPubkeys: [_alice, bot, roster],
|
||||
emojiUrl: _customEmojiUrl,
|
||||
),
|
||||
],
|
||||
onToggle: (_) {},
|
||||
),
|
||||
@@ -339,6 +363,14 @@ void main() {
|
||||
}
|
||||
|
||||
expect(labels(calls.first), ['Honey', 'Honey (agent)', 'Roster name']);
|
||||
final avatar = (calls.first['profiles'] as Map)[_alice] as Map;
|
||||
expect(avatar['headers'], {
|
||||
'Nostr-Federated-Identity': 'Bearer $_aliceAvatarUrl',
|
||||
});
|
||||
final reaction = (calls.first['reactions'] as List).single as Map;
|
||||
expect(reaction['headers'], {
|
||||
'Nostr-Federated-Identity': 'Bearer $_customEmojiUrl',
|
||||
});
|
||||
// Directory/roster changes must propagate without a user-cache event.
|
||||
directoryName = 'Helper';
|
||||
container.invalidate(channelIdentityNamesProvider('channel'));
|
||||
@@ -362,6 +394,211 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'native reaction sheet opens at once and resends media auth after a refresh',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
FlutterSecureStorage.setMockInitialValues({});
|
||||
final keys = nostr.Keys.generate();
|
||||
final organization = Uri.parse('https://org.example');
|
||||
final config = RelayConfig(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: keys.nsec,
|
||||
);
|
||||
final expiry = DateTime.now().toUtc().add(const Duration(days: 1));
|
||||
await const FlutterSecureStorage().write(
|
||||
key: 'buzz_hosted_session_org_example_v1',
|
||||
value: jsonEncode({
|
||||
'origin': organization.toString(),
|
||||
'credential': 'secret',
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
);
|
||||
var clock = DateTime.utc(2026, 11, 4, 12);
|
||||
var issued = 0;
|
||||
Completer<void>? issuerGate;
|
||||
final auth = HostedRelayAuth(
|
||||
BuilderlabAccountClient(),
|
||||
organization: BuilderlabAccountClient(
|
||||
accountOrigin: organization,
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path.endsWith('/assertions')) {
|
||||
await issuerGate?.future;
|
||||
}
|
||||
return switch (request.url.path) {
|
||||
'/api/goose/v1/auth/me' => http.Response(
|
||||
jsonEncode({
|
||||
'subject': 'member',
|
||||
'expires_at': expiry.toIso8601String(),
|
||||
}),
|
||||
200,
|
||||
),
|
||||
'/api/goose/v1/buzz/nostr-identities/current' => http.Response(
|
||||
jsonEncode({
|
||||
'identity': {'pubkey_hex': keys.public},
|
||||
}),
|
||||
200,
|
||||
),
|
||||
'/api/goose/v1/buzz/identity/assertions' => http.Response(
|
||||
jsonEncode({
|
||||
'assertion': 'token-${++issued}',
|
||||
'nostr_pubkey': keys.public,
|
||||
'expires_at': clock.millisecondsSinceEpoch ~/ 1000 + 120,
|
||||
}),
|
||||
200,
|
||||
),
|
||||
_ => http.Response('{}', 404),
|
||||
};
|
||||
}),
|
||||
),
|
||||
organizationRelay: (relay) => relay.host == 'relay.example',
|
||||
now: () => clock,
|
||||
);
|
||||
addTearDown(auth.dispose);
|
||||
// The live socket's assertion, issued when the session connected.
|
||||
expect(await auth.headers(config), {
|
||||
'Nostr-Federated-Identity': 'Bearer token-1',
|
||||
});
|
||||
final payloads = <Map<Object?, Object?>>[];
|
||||
final reactionUpdates = <List<Object?>>[];
|
||||
final profileUpdates = <Map<Object?, Object?>>[];
|
||||
var completion = Completer<Map<String, Object?>>();
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
NativeMessagePresentation.channel,
|
||||
(call) async {
|
||||
final args = call.arguments as Map<Object?, Object?>;
|
||||
switch (call.method) {
|
||||
case 'reactions':
|
||||
payloads.add(args);
|
||||
return completion.future;
|
||||
case 'updateReactions':
|
||||
reactionUpdates.add(args['reactions'] as List<Object?>);
|
||||
case 'updateProfiles':
|
||||
profileUpdates.add(args['profiles'] as Map<Object?, Object?>);
|
||||
}
|
||||
return <String, Object?>{};
|
||||
},
|
||||
);
|
||||
addTearDown(
|
||||
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
NativeMessagePresentation.channel,
|
||||
null,
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
|
||||
relayConfigProvider.overrideWith(
|
||||
() => _FixedRelayConfigNotifier(config),
|
||||
),
|
||||
hostedRelayAuthProvider.overrideWithValue(auth),
|
||||
userCacheProvider.overrideWith(
|
||||
() => _FakeUserCacheNotifier({
|
||||
_alice: const UserProfile(
|
||||
pubkey: _alice,
|
||||
displayName: 'Honey',
|
||||
avatarUrl: _aliceAvatarUrl,
|
||||
),
|
||||
}),
|
||||
),
|
||||
channelIdentityNamesProvider('channel').overrideWith(
|
||||
(ref) => IdentityNameSources(
|
||||
profiles: ref.watch(userCacheProvider),
|
||||
).scope([_alice]),
|
||||
),
|
||||
],
|
||||
child: ReactionRow(
|
||||
messageId: _messageId,
|
||||
channelId: 'channel',
|
||||
reactions: [
|
||||
TimelineReaction(
|
||||
emoji: _fire,
|
||||
count: 1,
|
||||
reactedByCurrentUser: false,
|
||||
userPubkeys: [_alice],
|
||||
emojiUrl: _customEmojiUrl,
|
||||
),
|
||||
],
|
||||
onToggle: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// Rendering the row may itself fetch media through the hosted client,
|
||||
// so enter the margin only now (120s lifetime, 30s margin): the socket
|
||||
// is still valid, but the synchronous cache reads as empty.
|
||||
String? authOf(Object? item) =>
|
||||
((item as Map)['headers'] as Map?)?['Nostr-Federated-Identity']
|
||||
as String?;
|
||||
final issuedBeforeSheet = issued;
|
||||
clock = clock.add(const Duration(seconds: 100));
|
||||
expect(auth.cachedHeaders(config), isNull);
|
||||
// A stalled issuer must not hold the sheet back: cached names and
|
||||
// emoji show at once, and media gets the assertion when it arrives.
|
||||
issuerGate = Completer<void>();
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('reaction-pill-$_fire')),
|
||||
);
|
||||
await tester.pump();
|
||||
final payload = payloads.single;
|
||||
final initialProfiles = payload['profiles'] as Map;
|
||||
expect((initialProfiles[_alice] as Map)['name'], 'Honey');
|
||||
expect(authOf(initialProfiles[_alice]), isNull);
|
||||
expect(authOf((payload['reactions'] as List).single), isNull);
|
||||
expect(issued, issuedBeforeSheet);
|
||||
|
||||
issuerGate.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(issued, issuedBeforeSheet + 1);
|
||||
expect(authOf(reactionUpdates.last.single), 'Bearer token-$issued');
|
||||
expect(authOf(profileUpdates.last[_alice]), 'Bearer token-$issued');
|
||||
completion.complete(<String, Object?>{});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// A fresh assertion needs no refresh, so nothing is resent.
|
||||
reactionUpdates.clear();
|
||||
completion = Completer<Map<String, Object?>>();
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('reaction-pill-$_fire')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(payloads, hasLength(2));
|
||||
expect(
|
||||
authOf((payloads.last['reactions'] as List).single),
|
||||
'Bearer token-$issued',
|
||||
);
|
||||
expect(reactionUpdates, isEmpty);
|
||||
completion.complete(<String, Object?>{});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Unmounting while a slow issuer is pending sends nothing afterwards:
|
||||
// the sheet's cleanup marks it disposed before auth completes.
|
||||
issuerGate = Completer<void>();
|
||||
clock = clock.add(const Duration(seconds: 200));
|
||||
expect(auth.cachedHeaders(config), isNull);
|
||||
completion = Completer<Map<String, Object?>>();
|
||||
await tester.longPress(
|
||||
find.byKey(const ValueKey('reaction-pill-$_fire')),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(payloads, hasLength(3));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
completion.complete(<String, Object?>{});
|
||||
await tester.pumpAndSettle();
|
||||
reactionUpdates.clear();
|
||||
profileUpdates.clear();
|
||||
issuerGate.complete();
|
||||
await tester.pumpAndSettle();
|
||||
expect(reactionUpdates, isEmpty);
|
||||
expect(profileUpdates, isEmpty);
|
||||
expect(tester.takeException(), isNull);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'native hydration sends bounded diffs and preserves emoji avatars',
|
||||
(tester) async {
|
||||
@@ -900,3 +1137,12 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class _FixedRelayConfigNotifier extends RelayConfigNotifier {
|
||||
_FixedRelayConfigNotifier(this._config);
|
||||
|
||||
final RelayConfig _config;
|
||||
|
||||
@override
|
||||
RelayConfig build() => _config;
|
||||
}
|
||||
|
||||
58
mobile/test/shared/hosted/native_media_headers_test.dart
Normal file
58
mobile/test/shared/hosted/native_media_headers_test.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:buzz/shared/hosted/builderlab_account.dart';
|
||||
import 'package:buzz/shared/hosted/hosted_relay_auth.dart';
|
||||
import 'package:buzz/shared/hosted/native_media_headers.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
final _nsec = nostr.Keys.generate().nsec;
|
||||
|
||||
class _RelayConfigNotifier extends RelayConfigNotifier {
|
||||
@override
|
||||
RelayConfig build() =>
|
||||
RelayConfig(baseUrl: 'https://relay.example', nsec: _nsec);
|
||||
}
|
||||
|
||||
/// A hosted relay whose live session already holds an assertion.
|
||||
class _CachedHostedRelayAuth extends HostedRelayAuth {
|
||||
_CachedHostedRelayAuth() : super(BuilderlabAccountClient(), enabled: false);
|
||||
|
||||
@override
|
||||
Map<String, String>? cachedHeaders(RelayConfig config) => {
|
||||
'Nostr-Federated-Identity': 'Bearer hosted-token',
|
||||
};
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('native media headers add the relay assertion only for relay media', () {
|
||||
final hosted = _CachedHostedRelayAuth();
|
||||
addTearDown(hosted.dispose);
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
|
||||
mediaGetAuthServiceProvider.overrideWithValue(
|
||||
MediaGetAuthService(baseUrl: 'https://relay.example', nsec: _nsec),
|
||||
),
|
||||
hostedRelayAuthProvider.overrideWithValue(hosted),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final headersFor = container.read(nativeMediaHeadersProvider);
|
||||
|
||||
final relay = headersFor('https://relay.example/media/a.png');
|
||||
expect(relay['Authorization'], startsWith('Nostr '));
|
||||
expect(relay['Nostr-Federated-Identity'], 'Bearer hosted-token');
|
||||
|
||||
for (final other in [
|
||||
'https://cdn.example/a.png',
|
||||
'https://team.relay.example/media/a.png',
|
||||
'http://relay.example/media/a.png',
|
||||
'https:///media/a.png',
|
||||
'http:///media/a.png',
|
||||
'https://',
|
||||
]) {
|
||||
expect(headersFor(other), isNot(contains('Nostr-Federated-Identity')));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -236,6 +236,25 @@ void main() {
|
||||
expect(requests, isEmpty);
|
||||
});
|
||||
|
||||
test('URL headers carry the assertion only to the relay origin', () async {
|
||||
await saveSession();
|
||||
final auth = relayAuth(organizationClient());
|
||||
expect(
|
||||
await auth.headersForUrl(
|
||||
relay,
|
||||
Uri.parse('https://$relayHost/media/emoji.png'),
|
||||
),
|
||||
{'Nostr-Federated-Identity': 'Bearer org-token'},
|
||||
);
|
||||
for (final other in [
|
||||
'https://other.example.com/media/emoji.png',
|
||||
'https://team.$relayHost/media/emoji.png',
|
||||
'http://$relayHost:8080/media/emoji.png',
|
||||
]) {
|
||||
expect(await auth.headersForUrl(relay, Uri.parse(other)), isEmpty);
|
||||
}
|
||||
});
|
||||
|
||||
test('other relays never receive the organization assertion', () async {
|
||||
await saveSession();
|
||||
final auth = relayAuth(organizationClient());
|
||||
|
||||
Reference in New Issue
Block a user