Files
buzz/mobile/test/shared/hosted/browser_auth_test.dart
Tom Brow da93ce3f62 fix(mobile): open the public Builderlab login the iOS bridge allows (#8914)
On main, "or sign in with email" on iOS fails immediately with "We could
not finish connecting. You can try again." No Builderlab page opens.

[#8700](https://github.com/block/buzz/pull/8700) changed the iOS browser
bridge so the public account hosts (`app.builderlab.xyz`,
`app.builderlabstaging.xyz`) may only open `/auth/native`. The Dart side
still opens `/api/goose/v1/auth/login` for them, so the bridge rejects
every public sign-in before presenting a browser. Every iOS build since
#8700 is affected; organization sign-in on tenant hosts is not.

The matching Dart change was Wes's
[#8712](https://github.com/block/buzz/pull/8712). It was merged into the
#8700 branch, but main's squash of #8700 kept only the bridge half. This
PR restores #8712 on top of main:

- The public account opens `/auth/native` (state, S256 challenge,
callback and retained invite unchanged, without the CLI-only
`type`/`product` parameters). Organization tenants keep
`/api/goose/v1/auth/login`.
- A saved session whose account still needs web setup goes through the
same browser handoff, keeping its subject and destination.

Android has no path allowlist in its browser plugin, so it needs no
change.

### Validation

- Reproduced on an iPhone 17 Pro (iOS 27) simulator with a main-based
debug build: "or sign in with email" fails instantly. With this change
applied, the same build shows the system "Buzz wants to use
app.builderlab.xyz to sign in" prompt, then the login.builderlab.xyz
"Welcome back to Buzz" page, which confirms production serves
`/auth/native`.
- Restored #8712's tests on top of main's current tests: the browser
test checks the public login URL and its exact parameters, and the
hosted invite journey runs for both a fresh account and a saved account
that still needs setup, asserting `/auth/native` and that the invite is
retained. Without the Dart fix, these fail.
- Mobile format and analyze pass; the hosted onboarding journeys and
`shared/hosted` tests pass with the CI auth-domain configuration.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-11-02 10:00:08 -08:00

186 lines
6.1 KiB
Dart

import 'dart:convert';
import 'package:buzz/shared/hosted/browser_auth.dart';
import 'package:buzz/shared/hosted/hosted_environment.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() => FlutterSecureStorage.setMockInitialValues({}));
test('cold-start callback restores PKCE and intent, consumes once', () async {
late Uri login;
final first = BrowserAuth(
openBrowser: (uri) async {
login = uri;
return null;
},
);
await first.start({
'relay': 'wss://example.communities.buzz.xyz',
'code': 'invite',
});
await first.dispose();
final restored = BrowserAuth();
final codes = <BrowserAuthCode>[];
final failures = <BrowserAuthFailure>[];
restored.codes.listen(codes.add);
restored.failures.listen(failures.add);
final callback = Uri.parse(BrowserAuth.callback).replace(
queryParameters: {
'state': login.queryParameters['state']!,
'code': 'exchange-code',
},
);
// A temporary backend failure must surface retry without losing the invite.
await restored.receive(
callback.replace(
queryParameters: {
'state': login.queryParameters['state']!,
'error': 'temporarily_unavailable',
},
),
);
await Future<void>.delayed(Duration.zero);
expect(codes, isEmpty);
expect(failures, [BrowserAuthFailure.restartLogin]);
expect(await restored.retainedIntent(), containsPair('code', 'invite'));
failures.clear();
await restored.receive(callback);
await restored.receive(callback);
await Future<void>.delayed(Duration.zero);
expect(codes, hasLength(1));
expect(failures, isEmpty);
expect(codes.single.intent['code'], 'invite');
expect(
login.queryParameters['code_challenge'],
base64UrlEncode(
sha256.convert(ascii.encode(codes.single.verifier)).bytes,
).replaceAll('=', ''),
);
expect(login.queryParameters['code_challenge_method'], 'S256');
expect(login.origin, HostedEnvironment.origin.origin);
expect(login.path, '/auth/native');
expect(
login.queryParametersAll.keys,
unorderedEquals([
'returnTo',
'state',
'code_challenge',
'code_challenge_method',
]),
);
expect(login.queryParametersAll.values, everyElement(hasLength(1)));
final stored = await const FlutterSecureStorage().readAll();
expect(stored.keys, everyElement(contains(HostedEnvironment.origin.host)));
expect(login.queryParameters['returnTo'], BrowserAuth.callback);
expect(await restored.retainedIntent(), codes.single.intent);
await restored.dispose();
});
test(
'invalid state and destination never consume the valid pending login',
() async {
late Uri login;
final auth = BrowserAuth(
openBrowser: (uri) async {
login = uri;
return null;
},
);
final codes = <BrowserAuthCode>[];
final failures = <BrowserAuthFailure>[];
auth.codes.listen(codes.add);
auth.failures.listen(failures.add);
await auth.start({'relay': 'wss://example.communities.buzz.xyz'});
final state = login.queryParameters['state']!;
for (final invalid in [
'${BrowserAuth.callback}?code=x',
'${BrowserAuth.callback}?state=wrong&code=x',
'${BrowserAuth.callback}?state=$state&state=$state&code=x',
'xyz.block.buzz.mobile://auth/callback?state=$state&code=x',
'${BrowserAuth.callback}/?state=$state&code=x',
'${BrowserAuth.callback}?state=$state&code=x#fragment',
]) {
await auth.receive(Uri.parse(invalid));
}
await Future<void>.delayed(Duration.zero);
expect(codes, isEmpty);
expect(failures, everyElement(BrowserAuthFailure.restartLogin));
expect(failures, isEmpty);
expect(await auth.retainedIntent(), isNotNull);
await auth.receive(
Uri.parse('${BrowserAuth.callback}?state=$state&code=good'),
);
await Future<void>.delayed(Duration.zero);
expect(codes.single.code, 'good');
await auth.dispose();
},
);
test('expiry and cancel preserve destination but prevent exchange', () async {
var now = DateTime.utc(2026);
late Uri login;
final auth = BrowserAuth(
now: () => now,
openBrowser: (uri) async {
login = uri;
return null;
},
);
final codes = <BrowserAuthCode>[];
auth.codes.listen(codes.add);
await auth.start({'relay': 'wss://example.communities.buzz.xyz'});
now = now.add(const Duration(minutes: 11));
await auth.receive(
Uri.parse(
'${BrowserAuth.callback}?state=${login.queryParameters['state']}&code=x',
),
);
await auth.cancel();
now = now.subtract(const Duration(minutes: 11));
await auth.receive(
Uri.parse(
'${BrowserAuth.callback}?state=${login.queryParameters['state']}&code=x',
),
);
await Future<void>.delayed(Duration.zero);
expect(codes, isEmpty);
expect(await auth.retainedIntent(), isNotNull);
await auth.dispose();
});
test('old callback cannot fail a newer pending login', () async {
final logins = <Uri>[];
final auth = BrowserAuth(
openBrowser: (uri) async {
logins.add(uri);
return null;
},
);
final failures = <BrowserAuthFailure>[];
final codes = <BrowserAuthCode>[];
auth.failures.listen(failures.add);
auth.codes.listen(codes.add);
await auth.start({'target': 'old'});
await auth.start({'target': 'new'});
await auth.receive(
Uri.parse(
'${BrowserAuth.callback}?state=${logins.first.queryParameters['state']}&code=old',
),
);
await Future<void>.delayed(Duration.zero);
expect(failures, isEmpty);
expect(codes, isEmpty);
await auth.receive(
Uri.parse(
'${BrowserAuth.callback}?state=${logins.last.queryParameters['state']}&code=new',
),
);
await Future<void>.delayed(Duration.zero);
expect(codes.single.intent['target'], 'new');
await auth.dispose();
});
}