mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
## Summary Communities joined via an invite link never connect: the app dials `ws://` on port 80 instead of `wss://` on 443 and sits on "Reconnecting…" indefinitely. `RelayConfig.baseUrl` is documented as an HTTP origin, but the two onboarding flows disagree on what they persist: - **Device pairing** validates and stores `https://` — `pairing_provider.dart:657` throws on anything else. - **Invite join** stores the relay URL straight off the invite link, and `deep_link.dart:165` always emits `ws://` or `wss://`. `wsUrl` only special-cased `https://`, so a `wss://` base fell through to the plaintext branch: ```dart final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; // 'wss' is not 'https' ``` The claim request itself succeeds, because `_claimUrlFromRelay` (`invite_join_provider.dart:242`) maps `wss → https` explicitly. Only the socket path is missing that conversion — which is why the community appears, correctly named, and then never loads. The same `baseUrl` also feeds `/query` (`relay_session.dart:136`), media upload (`media_upload.dart:765`), Blossom auth (`media_auth.dart:128`) and `relayClientProvider` (`relay_provider.dart:113`), so those requests were malformed too. Where port 80 *does* answer, it is additionally a silent TLS downgrade after `validateInviteRelayUri` insisted on `wss://`. This folds the websocket schemes back to their HTTP equivalents in `baseUrl` itself, so every consumer is correct by construction rather than needing a second getter remembered at each call site, and communities **already persisted** with `wss://` are repaired on read without a migration. `community_icon_provider.dart:46` already performs this same conversion locally. One subtlety worth flagging for review: the normalization is derived in the getter rather than applied in the constructor, so the constructor stays `const`. The compile-time fallback at `relay_provider.dart:77` relies on const canonicalization for a stable identity across rebuilds, and Riverpod's `defaultUpdateShouldNotify` is `previous != next` (`element.dart:361`), which falls back to identity for this class. A `factory` constructor here yields a fresh instance per rebuild, which tears down and resubscribes every listener — `channels_provider_test.dart` catches it as an unexpected unsubscribe during reconnect. ### Related issue Fixes #2662. ### Testing `flutter test` — **705 passed, 1 skipped, 0 failed** `flutter analyze` — No issues found `dart format --set-exit-if-changed .` — 249 files, 0 changed Run against the Hermit-pinned SDK (Flutter 3.41.7 / Dart 3.11.5), matching CI. 10 new unit tests in `mobile/test/shared/relay/relay_config_test.dart` covering both onboarding schemes, `http`/`https` passthrough, non-default ports, and agreement between the invite and pairing paths for the same relay. Verified end-to-end against a self-hosted relay behind `tailscale serve`, which terminates TLS on 443 and leaves port 80 closed. Relay logs show the invite claim succeeding over HTTPS at the moment of joining, while no WebSocket connection ever arrives — no `WebSocket connection established`, no NIP-42 auth, no `kind:0` profile, no push registration — across the relay's entire history, even though the member row is present and correct. Port-80 refusals are not logged by `tailscaled`'s netstack, which is why the retries leave no trace server-side. Reproduced on both iOS and Android. --------- Signed-off-by: Krishna C <github@kumb.uk>
93 lines
3.0 KiB
Dart
93 lines
3.0 KiB
Dart
import 'package:buzz/shared/relay/identity_scoped_prefs.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
const canonical = 'k_v1:https://relay.example:pk';
|
|
const legacy = 'k_v1:wss://relay.example:pk';
|
|
|
|
Future<SharedPreferences> prefsWith(Map<String, Object> values) async {
|
|
SharedPreferences.setMockInitialValues(values);
|
|
return SharedPreferences.getInstance();
|
|
}
|
|
|
|
String? readString(SharedPreferences prefs) => readMigratedPref<String>(
|
|
prefs,
|
|
canonicalKey: canonical,
|
|
legacyKey: legacy,
|
|
read: prefs.getString,
|
|
write: prefs.setString,
|
|
);
|
|
|
|
test('returns the canonical value when present', () async {
|
|
final prefs = await prefsWith({canonical: 'new', legacy: 'old'});
|
|
expect(readString(prefs), 'new');
|
|
});
|
|
|
|
test('falls back to the legacy value and returns it immediately', () async {
|
|
final prefs = await prefsWith({legacy: 'carried over'});
|
|
expect(readString(prefs), 'carried over');
|
|
});
|
|
|
|
test('promotes the legacy value onto the canonical key', () async {
|
|
final prefs = await prefsWith({legacy: 'carried over'});
|
|
readString(prefs);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(prefs.getString(canonical), 'carried over');
|
|
expect(prefs.getString(legacy), isNull, reason: 'legacy entry is cleared');
|
|
});
|
|
|
|
test('is idempotent across repeated reads', () async {
|
|
final prefs = await prefsWith({legacy: 'carried over'});
|
|
readString(prefs);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(readString(prefs), 'carried over');
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(prefs.getString(canonical), 'carried over');
|
|
});
|
|
|
|
test('returns null when neither key holds a value', () async {
|
|
final prefs = await prefsWith({});
|
|
expect(readString(prefs), isNull);
|
|
});
|
|
|
|
test('does not touch storage when the keys coincide', () async {
|
|
// A pairing-created community already stores an HTTP origin, so canonical
|
|
// and legacy are the same string and there is nothing to migrate.
|
|
SharedPreferences.setMockInitialValues({canonical: 'only'});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final value = readMigratedPref<String>(
|
|
prefs,
|
|
canonicalKey: canonical,
|
|
legacyKey: canonical,
|
|
read: prefs.getString,
|
|
write: prefs.setString,
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(value, 'only');
|
|
expect(prefs.getString(canonical), 'only');
|
|
});
|
|
|
|
test('migrates string lists as well as strings', () async {
|
|
final prefs = await prefsWith({
|
|
legacy: <String>['alpha', 'beta'],
|
|
});
|
|
final value = readMigratedPref<List<String>>(
|
|
prefs,
|
|
canonicalKey: canonical,
|
|
legacyKey: legacy,
|
|
read: prefs.getStringList,
|
|
write: prefs.setStringList,
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(value, ['alpha', 'beta']);
|
|
expect(prefs.getStringList(canonical), ['alpha', 'beta']);
|
|
expect(prefs.getStringList(legacy), isNull);
|
|
});
|
|
}
|