Files
buzz/mobile/test/shared/hosted/native_media_headers_test.dart
Tom Brow 1b9b307176 fix(mobile): renew expired assertions for native media and drop retired-session avatars (#9134)
Follow-up to [#9069 media
auth](https://github.com/block/buzz/pull/9069), which merged at its
approved head. This PR carries the fixes for the last review round on
that PR.

### Changes

- **Native media reads renew an expired assertion** (Carl's [recommended
blocker](https://github.com/block/buzz/pull/9069#discussion_r4485183400)).
Voice notes and posterless video previews hand headers to native code.
They now use `freshNativeMediaHeadersProvider`, which awaits
`HostedRelayAuth.headersForUrl` and so asks for a new assertion once the
cached one has expired. Before, while the socket stayed up they sent
none and the relay refused the read. If the renewal fails, the read
falls back to cached headers and the relay decides. The voice-note
player's `headers` callback is now `FutureOr`. Both the iOS
authenticated download and the Android native load await it, then
recheck their generation, so a cancel during a slow renewal never starts
the stale load.
- **Notification avatars drop a retired organization session** ([review
thread](https://github.com/block/buzz/pull/9069#discussion_r4485125609)).
When an avatar download finishes after the organization session that
issued its assertion was replaced, BuzzPushKit removes the cached
response and refuses the avatar.
- **Test:** cached reads never present a signed-out or replaced
account's assertion ([optional
note](https://github.com/block/buzz/pull/9069#discussion_r4485183412)).

Not changed: native reaction-detail avatars still use the cached
assertion. After it expires they fall back to initials.

### Validation

- Each new test was checked to fail with the code it guards reverted:
- `replacedOrganizationSessionRefusesAvatar` (BuzzPushKit): a session
replaced mid-download must be refused.
- `fresh native media headers renew an expired assertion`: covers
renewal and the fallback when renewal fails.
- `remote voice notes renew an expired relay assertion`: a journey
through `VoiceNoteAttachment`; it fails when the attachment is switched
back to the synchronous provider.
- `Android defers remote auth until playback starts`: now cancels during
a slow renewal and expects no native load.
- `an issued assertion serves cached reads for the relay`: now signs out
and restores a replacement account. It fails without the account check.
- `swift test` (69 tests) passes. The full mobile suite (`just
mobile-install mobile-check mobile-test`) passes with these commits on
main as of [#9069 media auth](https://github.com/block/buzz/pull/9069).
- The automated full-app simulator run from [#9069 media
auth](https://github.com/block/buzz/pull/9069) covers the surrounding
flows. This change is limited to the header source for voice notes and
video previews, and none of the test communities enforce the hosted
assertion, so a simulator run would not show the difference. No
end-to-end run against a relay that enforces the assertion.

🤖 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:44:54 -08:00

119 lines
4.2 KiB
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_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',
};
}
/// A hosted relay whose cached assertion has expired: only a fresh request
/// gets one, and [fail] makes that request fail.
class _ExpiredHostedRelayAuth extends HostedRelayAuth {
_ExpiredHostedRelayAuth({this.fail = false})
: super(BuilderlabAccountClient(), enabled: false);
final bool fail;
final requested = <Uri>[];
@override
Map<String, String>? cachedHeaders(RelayConfig config) => null;
@override
Future<Map<String, String>> headersForUrl(RelayConfig config, Uri url) async {
requested.add(url);
if (fail) throw AccountFailure.unavailable;
return {'Nostr-Federated-Identity': 'Bearer fresh-token'};
}
}
ProviderContainer _container(HostedRelayAuth hosted) => ProviderContainer(
overrides: [
relayConfigProvider.overrideWith(_RelayConfigNotifier.new),
mediaGetAuthServiceProvider.overrideWithValue(
MediaGetAuthService(baseUrl: 'https://relay.example', nsec: _nsec),
),
hostedRelayAuthProvider.overrideWithValue(hosted),
],
);
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')));
}
});
test('fresh native media headers renew an expired assertion', () async {
const url = 'https://relay.example/media/voice.m4a';
final hosted = _ExpiredHostedRelayAuth();
addTearDown(hosted.dispose);
final container = _container(hosted);
addTearDown(container.dispose);
// The synchronous form can only offer what is cached.
expect(
container.read(nativeMediaHeadersProvider)(url),
isNot(contains('Nostr-Federated-Identity')),
);
final headers = await container.read(freshNativeMediaHeadersProvider)(url);
expect(headers['Authorization'], startsWith('Nostr '));
expect(headers['Nostr-Federated-Identity'], 'Bearer fresh-token');
expect(hosted.requested, [Uri.parse(url)]);
// A failed renewal still sends the media proof, and the relay decides.
final failing = _ExpiredHostedRelayAuth(fail: true);
addTearDown(failing.dispose);
final failingContainer = _container(failing);
addTearDown(failingContainer.dispose);
final fallback = await failingContainer.read(
freshNativeMediaHeadersProvider,
)(url);
expect(fallback['Authorization'], startsWith('Nostr '));
expect(fallback, isNot(contains('Nostr-Federated-Identity')));
});
}