mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
Starter channels use UUIDv5 IDs, but mobile push accepted only UUIDv4 channel IDs, so building a subscription that included a starter channel threw locally. After #8608, no non-v4 ID reaches mobile push today: positive `#h` filters cover joined DMs only, and mutes are limited to subscribed channels. This PR is therefore a safety net for future starter-channel subscriptions, and it prepares mobile for a relay that accepts them. Mobile now accepts canonical lowercase, hyphenated UUIDs of any version in subscriptions. It recognizes both the current `uuid-v4-lowercase` relay descriptor and a future `uuid-lowercase` descriptor. Against a `uuid-v4-lowercase` relay, mobile checks positive and mute channel filters before enrollment or publication, keeps the desired filters, and rechecks relay capabilities every five minutes instead of sending a lease the relay would reject. The relay is unchanged. It keeps validating and advertising `uuid-v4-lowercase`. Installed mobile builds reject any other `h_grammar`, and they parse the descriptor before renewal, opt-out and revocation, so changing the advertisement now would leave their leases active until expiry. The relay validation and advertisement change will follow in a separate PR once those builds have drained. `docs/buzz-push.md` records this sequencing. ### Validation - Regression tests reproduced the UUIDv5 rejection in Dart before the fix. Added coverage checks positive and mute filters, persisted subscription round trips, encrypted lease content, malformed IDs, retained bounds, parsing of both descriptor grammars, and pre-publication rejection of non-v4 IDs against a `uuid-v4-lowercase` relay. - Mobile push tests on the current head: 104 passed, 4 skipped. `flutter analyze` on the push code and tests is clean. - Before the split, exercised actual mobile lease construction, NIP44 encryption and NIP42 publication against an isolated local relay. The relay half of that exercise is no longer part of this PR. - Relay source is identical to `main`, so the relay serves the same descriptor that installed builds parse today. The standing mobile test exception applies. Agent review and changed-behavior exercise are complete. buzz-review-completed --------- Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
302 lines
9.1 KiB
Dart
302 lines
9.1 KiB
Dart
import 'package:buzz/shared/push/dev_push_lease.dart';
|
|
import 'package:buzz/shared/community/community.dart';
|
|
import 'package:buzz/shared/push/push_bootstrap.dart';
|
|
import 'package:buzz/shared/push/push_subscription.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
void main() {
|
|
testWidgets('first opt-in starts registration without switching community', (
|
|
tester,
|
|
) async {
|
|
var registrations = 0;
|
|
Widget bootstrap(bool enabled) => BuzzPushRegistrationBootstrap(
|
|
shouldRegister: enabled,
|
|
attemptKey: 'same-community|same-relay',
|
|
startRegistration: () async {
|
|
registrations += 1;
|
|
},
|
|
child: const SizedBox(),
|
|
);
|
|
await tester.pumpWidget(bootstrap(false));
|
|
expect(registrations, 0);
|
|
await tester.pumpWidget(bootstrap(true));
|
|
expect(registrations, 1);
|
|
await tester.pumpWidget(bootstrap(true));
|
|
expect(registrations, 1);
|
|
});
|
|
|
|
testWidgets('new community starts registration once capability arrives', (
|
|
tester,
|
|
) async {
|
|
final community = Community.create(
|
|
name: 'Team',
|
|
relayUrl: 'wss://relay.example',
|
|
);
|
|
var registrations = 0;
|
|
Widget bootstrap(BuzzPushLeaseDescriptor? descriptor) =>
|
|
BuzzPushRegistrationBootstrap(
|
|
shouldRegister: buzzPushLifecycleEnabled(
|
|
community: community,
|
|
descriptor: descriptor,
|
|
),
|
|
attemptKey: '${community.id}|${community.relayUrl}',
|
|
startRegistration: () async => registrations += 1,
|
|
child: const SizedBox(),
|
|
);
|
|
await tester.pumpWidget(bootstrap(null));
|
|
expect(registrations, 0);
|
|
final descriptor = _descriptor(keyId: 'relay-v1', pubkey: _hex('b'));
|
|
await tester.pumpWidget(bootstrap(descriptor));
|
|
expect(registrations, 1);
|
|
await tester.pumpWidget(bootstrap(descriptor));
|
|
expect(registrations, 1);
|
|
});
|
|
|
|
test(
|
|
'superseded lease acceptance is a retryable publication failure',
|
|
() async {
|
|
await expectLater(
|
|
publishBuzzPushLeaseRecoverably(
|
|
reserveGeneration: () async => 1,
|
|
publish: (_) async {},
|
|
markAccepted: (_) async => false,
|
|
),
|
|
throwsStateError,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('failed bootstrap attempt becomes retryable after the delay', () async {
|
|
final gate = BuzzPushAttemptGate(retryDelay: Duration.zero);
|
|
addTearDown(gate.dispose);
|
|
var retries = 0;
|
|
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
gate.failed('attempt', retry: () => retries += 1);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(retries, 1);
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
});
|
|
|
|
testWidgets('a failed attempt stays blocked until the retry delay', (
|
|
tester,
|
|
) async {
|
|
final gate = BuzzPushAttemptGate();
|
|
addTearDown(gate.dispose);
|
|
var retries = 0;
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
gate.failed('attempt', retry: () => retries++);
|
|
expect(gate.tryBegin('attempt'), isFalse);
|
|
await tester.pump(const Duration(seconds: 4));
|
|
expect(gate.tryBegin('attempt'), isFalse);
|
|
expect(retries, 0);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(retries, 1);
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
});
|
|
|
|
test('a new attempt cancels an obsolete scheduled retry', () async {
|
|
final gate = BuzzPushAttemptGate(retryDelay: Duration.zero);
|
|
addTearDown(gate.dispose);
|
|
var retries = 0;
|
|
|
|
expect(gate.tryBegin('old'), isTrue);
|
|
gate.failed('old', retry: () => retries += 1);
|
|
expect(gate.tryBegin('new'), isTrue);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(retries, 0);
|
|
expect(gate.tryBegin('new'), isFalse);
|
|
});
|
|
|
|
test('successful bootstrap becomes retryable at renewal time', () async {
|
|
final gate = BuzzPushAttemptGate(retryDelay: Duration.zero);
|
|
addTearDown(gate.dispose);
|
|
var retries = 0;
|
|
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
gate.retryAfter('attempt', delay: Duration.zero, retry: () => retries += 1);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(retries, 1);
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
});
|
|
|
|
test('completed bootstrap attempt can run again for later work', () {
|
|
final gate = BuzzPushAttemptGate();
|
|
addTearDown(gate.dispose);
|
|
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
gate.complete('attempt');
|
|
expect(gate.tryBegin('attempt'), isTrue);
|
|
});
|
|
|
|
test('publication attempt changes when the relay executor rotates', () {
|
|
final subscription = BuzzPushSubscription(
|
|
filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]),
|
|
notificationClass: 'default',
|
|
);
|
|
final original = buzzPushPublicationAttemptKey(
|
|
communityId: 'community',
|
|
relayBaseUrl: 'https://relay.example',
|
|
token: 'token',
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')),
|
|
subscriptions: [subscription],
|
|
);
|
|
|
|
expect(
|
|
buzzPushPublicationAttemptKey(
|
|
communityId: 'community',
|
|
relayBaseUrl: 'https://relay.example',
|
|
token: 'token',
|
|
descriptor: _descriptor(keyId: 'relay-v2', pubkey: _hex('b')),
|
|
subscriptions: [subscription],
|
|
),
|
|
isNot(original),
|
|
);
|
|
expect(
|
|
buzzPushPublicationAttemptKey(
|
|
communityId: 'community',
|
|
relayBaseUrl: 'https://relay.example',
|
|
token: 'token',
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('c')),
|
|
subscriptions: [subscription],
|
|
),
|
|
isNot(original),
|
|
);
|
|
});
|
|
|
|
test('relay grammar upgrade changes publication attempt identity', () {
|
|
String key(String grammar) => buzzPushPublicationAttemptKey(
|
|
communityId: 'community',
|
|
relayBaseUrl: 'https://relay.example',
|
|
token: 'token',
|
|
descriptor: _descriptor(
|
|
keyId: 'relay-v1',
|
|
pubkey: _hex('b'),
|
|
hGrammar: grammar,
|
|
),
|
|
subscriptions: const [],
|
|
);
|
|
expect(key('uuid-lowercase'), isNot(key('uuid-v4-lowercase')));
|
|
});
|
|
|
|
test('default push requires relay capability and respects opt-out', () {
|
|
final enabled = Community.create(
|
|
name: 'Team',
|
|
relayUrl: 'wss://relay.example',
|
|
);
|
|
final disabled = enabled.copyWith(pushNotificationsEnabled: false);
|
|
|
|
expect(
|
|
buzzPushLifecycleEnabled(
|
|
community: disabled,
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')),
|
|
),
|
|
isFalse,
|
|
);
|
|
expect(
|
|
buzzPushLifecycleEnabled(
|
|
community: enabled,
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')),
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(
|
|
buzzPushLifecycleEnabled(community: enabled, descriptor: null),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test('pending opt-out tombstone keeps active push lifecycle disabled', () {
|
|
final subscription = BuzzPushSubscription(
|
|
filter: BuzzPushFilter(kinds: const [9], pTags: [_hex('a')]),
|
|
notificationClass: 'default',
|
|
);
|
|
final community =
|
|
Community.create(
|
|
name: 'Team',
|
|
relayUrl: 'wss://relay.example',
|
|
).copyWith(
|
|
pushNotificationsEnabled: false,
|
|
pushSubscriptionState:
|
|
BuzzPushLeaseSubscriptionState.desired(desired: [subscription])
|
|
.withAccepted(subscriptions: [subscription], generation: 3)
|
|
.withPendingTombstone(4),
|
|
);
|
|
|
|
expect(
|
|
buzzPushLifecycleEnabled(
|
|
community: community,
|
|
descriptor: _descriptor(keyId: 'relay-v1', pubkey: _hex('b')),
|
|
),
|
|
isFalse,
|
|
);
|
|
});
|
|
|
|
test(
|
|
'relay commit followed by local failure retries at a newer generation',
|
|
() async {
|
|
var durableCursor = 0;
|
|
var relayGeneration = 0;
|
|
var acceptedGeneration = 0;
|
|
var failLocalSave = true;
|
|
|
|
Future<int> reserve() async => ++durableCursor;
|
|
Future<void> publish(int generation) async {
|
|
expect(generation, greaterThan(relayGeneration));
|
|
relayGeneration = generation;
|
|
}
|
|
|
|
Future<bool> markAccepted(int generation) async {
|
|
if (failLocalSave) {
|
|
failLocalSave = false;
|
|
throw StateError('injected local persistence failure');
|
|
}
|
|
acceptedGeneration = generation;
|
|
return true;
|
|
}
|
|
|
|
await expectLater(
|
|
publishBuzzPushLeaseRecoverably(
|
|
reserveGeneration: reserve,
|
|
publish: publish,
|
|
markAccepted: markAccepted,
|
|
),
|
|
throwsStateError,
|
|
);
|
|
expect(relayGeneration, 1);
|
|
expect(acceptedGeneration, 0);
|
|
|
|
await publishBuzzPushLeaseRecoverably(
|
|
reserveGeneration: reserve,
|
|
publish: publish,
|
|
markAccepted: markAccepted,
|
|
);
|
|
expect(relayGeneration, 2);
|
|
expect(acceptedGeneration, 2);
|
|
},
|
|
);
|
|
}
|
|
|
|
BuzzPushLeaseDescriptor _descriptor({
|
|
required String keyId,
|
|
required String pubkey,
|
|
String hGrammar = 'uuid-v4-lowercase',
|
|
}) => BuzzPushLeaseDescriptor(
|
|
origin: 'wss://relay.example',
|
|
hGrammar: hGrammar,
|
|
executorKeyId: keyId,
|
|
executorPubkey: pubkey,
|
|
transport: 'apns',
|
|
maxLeaseTtlSeconds: 3600,
|
|
maxContentLength: 4096,
|
|
maxPlaintextLength: 4096,
|
|
maxEndpointLength: 2048,
|
|
maxStringLength: 512,
|
|
);
|
|
|
|
String _hex(String character) => List.filled(64, character).join();
|