diff --git a/mobile/lib/shared/hosted/browser_auth.dart b/mobile/lib/shared/hosted/browser_auth.dart index d437a6bd44..8d4406958f 100644 --- a/mobile/lib/shared/hosted/browser_auth.dart +++ b/mobile/lib/shared/hosted/browser_auth.dart @@ -142,11 +142,13 @@ class BrowserAuth { } catch (_) { throw BrowserAuthFailure.storage; } + // Public accounts must finish web setup before native code issuance. + // Organization accounts retain their existing direct CLI login. + final publicAccount = origin == HostedEnvironment.origin; return origin.replace( - path: '/api/goose/v1/auth/login', + path: publicAccount ? '/auth/native' : '/api/goose/v1/auth/login', queryParameters: { - 'type': 'cli', - 'product': 'buzz', + if (!publicAccount) ...{'type': 'cli', 'product': 'buzz'}, 'returnTo': callback, 'state': state, 'code_challenge': base64UrlEncode( diff --git a/mobile/lib/shared/hosted/builderlab_account.dart b/mobile/lib/shared/hosted/builderlab_account.dart index 32dec638d1..31bdd3eb69 100644 --- a/mobile/lib/shared/hosted/builderlab_account.dart +++ b/mobile/lib/shared/hosted/builderlab_account.dart @@ -237,12 +237,8 @@ class BuilderlabAccountClient { throw AccountFailure.invalidResponse; } if (!parsed.isAfter(DateTime.now().toUtc())) throw AccountFailure.signedOut; - final standing = result['account_standing']; final needsSetup = - standing is Map && - (standing['details_status'] == 'ACCOUNT_DETAILS_STATUS_MISSING' || - standing['screening_status'] == - 'ACCOUNT_SCREENING_STATUS_NOT_SCREENED'); + result['account_status'] == 'ACCOUNT_STATUS_NEEDS_DETAILS'; return BuilderlabAccount( subject: subject, expiresAt: parsed, diff --git a/mobile/lib/shared/hosted/hosted_flow.dart b/mobile/lib/shared/hosted/hosted_flow.dart index bd484454e8..1a74c563d1 100644 --- a/mobile/lib/shared/hosted/hosted_flow.dart +++ b/mobile/lib/shared/hosted/hosted_flow.dart @@ -533,6 +533,15 @@ class HostedFlow extends ChangeNotifier { if ((target == null && !accountOnly) || session == null) { throw AccountFailure.signedOut; } + // Older native sessions may predate the web setup gate. Preserve their + // subject and destination while completing the same browser handoff. + if (session.needsAccountSetup) { + _set(HostedFlowStage.signIn); + await browser.start( + target == null ? {'type': 'account'} : _intent(target), + ); + return; + } final store = this.store.forSubject(session.subject); if (target != null && !_acceptTarget(target)) return; if (target == null && _batchSubject != session.subject) { diff --git a/mobile/test/features/onboarding/hosted_journeys_test.dart b/mobile/test/features/onboarding/hosted_journeys_test.dart index 914b4e862b..c240a034f8 100644 --- a/mobile/test/features/onboarding/hosted_journeys_test.dart +++ b/mobile/test/features/onboarding/hosted_journeys_test.dart @@ -847,143 +847,160 @@ void main() { requiresBuilderlab: false, ); - journey( - 'fresh account signs in, backs up, accepts invite and retries starters before home', - (tester, f) async { - await f.initialize( - localIdentity: false, - cloudChoice: true, - signedIn: false, - ); - f.starterFailures = 2; - final browserReturn = Completer(); - f.beforeBrowserReturn = () => browserReturn.future; - final enrollmentReached = Completer(); - final enrollmentReturn = Completer(); - f.beforeEnrollmentReturn = () { - enrollmentReached.complete(); - return enrollmentReturn.future; - }; - await f.mount(tester); - await f.link(tester, 'one'); - try { + for (final savedIncomplete in [false, true]) { + journey( + '${savedIncomplete ? 'incomplete saved' : 'fresh'} account signs in, backs up, accepts invite and retries starters before home', + (tester, f) async { + await f.initialize( + localIdentity: false, + cloudChoice: true, + signedIn: savedIncomplete, + ); + f.needsAccountSetup = savedIncomplete; + f.starterFailures = 2; + final browserReturn = Completer(); + f.beforeBrowserReturn = () async { + await browserReturn.future; + f.needsAccountSetup = false; + }; + final enrollmentReached = Completer(); + final enrollmentReturn = Completer(); + f.beforeEnrollmentReturn = () { + enrollmentReached.complete(); + return enrollmentReturn.future; + }; + await f.mount(tester); + await f.link(tester, 'one'); + try { + expect( + find.text('Enter a community link').hitTestable(), + findsOneWidget, + ); + expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); + expect(f.browserUrls.single.path, '/auth/native'); + expect(f.browserUrls.single.origin, HostedEnvironment.origin.origin); + expect( + await f.flow.browser.retainedIntent(), + containsPair('code', 'invite-one'), + ); + expect(f.sent('/exchange'), savedIncomplete ? hasLength(1) : isEmpty); + expect(f.sent('/api/invites/claim'), isEmpty); + expect(find.text('Start login again'), findsNothing); + expect(find.text('Complete sign-in in your browser.'), findsNothing); + } finally { + browserReturn.complete(); + } + await f.settle(tester); + await tester.tap(find.text('Create my Buzz identity')); + await enrollmentReached.future.timeout(const Duration(seconds: 5)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + try { + expect(find.byTooltip('Back').hitTestable(), findsNothing); + // External teardown still fences pending enrollment. + await f.flow.leave(); + await tester.pump(); + } finally { + enrollmentReturn.complete(); + } + await f.settle(tester); + expect(f.sent('/nsec-backups'), isEmpty); + final retainedKey = (await f.store.read())!.pubkey; + f.beforeEnrollmentReturn = null; + // A dispatched upload can succeed after cancellation. Its receipt must + // still be durable; resuming must not replay an acknowledged backup. + f.beforeUploadReturn = () => f.flow.leave(); + await f.link(tester, 'one'); + expect((await f.store.read())!.backup, isNotNull); + f.beforeUploadReturn = null; + // Production legal documents exceed the ordinary 64 KiB account limit. + f.policyResponses.add( + http.Response( + jsonEncode({ + 'policy': { + 'version': 'one.communities.buzz.xyz', + 'age_attestation_required': true, + 'terms_markdown': 'Be kind to this community.', + 'privacy_markdown': 'Public privacy notice. ' * 5000, + }, + }), + 200, + ), + ); + await f.link(tester, 'one'); + expect((await f.store.read())!.pubkey, retainedKey); + expect(f.browserStarts, 1); + final generated = (await f.store.read())!; + expect(generated.pubkey, f.bound); + expect(generated.backup, isNotNull); + expect(f.sent('/verify'), hasLength(1)); + expect(f.sent('/nsec-backups'), hasLength(1)); + final accept = find.widgetWithText(FilledButton, 'Accept and join'); + expect(tester.widget(accept).onPressed, isNull); + expect(f.sent('/claim'), isEmpty); + expect(await f.storage.loadAll(), isEmpty); + await f.tap(tester, 'Terms of service'); + expect(find.text('Be kind to this community.'), findsOneWidget); + await tester.tap(find.byType(CheckboxListTile)); + await f.settle(tester); + await f.tap(tester, 'Accept and join'); + expect(find.text('Try again').hitTestable(), findsOneWidget); + final pending = (await f.storage.loadAll()).single; + expect(pending.starterSetupIncomplete, isTrue); + expect(pending.pubkey, generated.pubkey); + expect(f.sent('/claim'), hasLength(1)); expect( - find.text('Enter a community link').hitTestable(), + jsonDecode(f.sent('/claim').single.body)['policy_receipt'], + 'synthetic-policy-receipt', + ); + expect( + jsonDecode(f.sent('/accept-policy').single.body)['age_confirmed'], + isTrue, + ); + // Reopen the saved invite after its claim was consumed. Starter recovery + // must retain the durable marker and must not claim it again. + await f.link(tester, 'one'); + expect( + (await f.storage.loadAll()).single.starterSetupIncomplete, + isTrue, + ); + expect(find.text('Try again').hitTestable(), findsOneWidget); + await f.tap(tester, 'Try again'); + expect( + find.byType(ProfileSetupPage, skipOffstage: false), findsOneWidget, ); - expect(find.text('Scan a QR code').hitTestable(), findsOneWidget); - expect(find.text('Start login again'), findsNothing); - expect(find.text('Complete sign-in in your browser.'), findsNothing); - } finally { - browserReturn.complete(); - } - await f.settle(tester); - await tester.tap(find.text('Create my Buzz identity')); - await enrollmentReached.future.timeout(const Duration(seconds: 5)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); - try { - expect(find.byTooltip('Back').hitTestable(), findsNothing); - // External teardown still fences pending enrollment. - await f.flow.leave(); - await tester.pump(); - } finally { - enrollmentReturn.complete(); - } - await f.settle(tester); - expect(f.sent('/nsec-backups'), isEmpty); - final retainedKey = (await f.store.read())!.pubkey; - f.beforeEnrollmentReturn = null; - // A dispatched upload can succeed after cancellation. Its receipt must - // still be durable; resuming must not replay an acknowledged backup. - f.beforeUploadReturn = () => f.flow.leave(); - await f.link(tester, 'one'); - expect((await f.store.read())!.backup, isNotNull); - f.beforeUploadReturn = null; - // Production legal documents exceed the ordinary 64 KiB account limit. - f.policyResponses.add( - http.Response( - jsonEncode({ - 'policy': { - 'version': 'one.communities.buzz.xyz', - 'age_attestation_required': true, - 'terms_markdown': 'Be kind to this community.', - 'privacy_markdown': 'Public privacy notice. ' * 5000, - }, - }), - 200, - ), - ); - await f.link(tester, 'one'); - expect((await f.store.read())!.pubkey, retainedKey); - expect(f.browserStarts, 1); - final generated = (await f.store.read())!; - expect(generated.pubkey, f.bound); - expect(generated.backup, isNotNull); - expect(f.sent('/verify'), hasLength(1)); - expect(f.sent('/nsec-backups'), hasLength(1)); - final accept = find.widgetWithText(FilledButton, 'Accept and join'); - expect(tester.widget(accept).onPressed, isNull); - expect(f.sent('/claim'), isEmpty); - expect(await f.storage.loadAll(), isEmpty); - await f.tap(tester, 'Terms of service'); - expect(find.text('Be kind to this community.'), findsOneWidget); - await tester.tap(find.byType(CheckboxListTile)); - await f.settle(tester); - await f.tap(tester, 'Accept and join'); - expect(find.text('Try again').hitTestable(), findsOneWidget); - final pending = (await f.storage.loadAll()).single; - expect(pending.starterSetupIncomplete, isTrue); - expect(pending.pubkey, generated.pubkey); - expect(f.sent('/claim'), hasLength(1)); - expect( - jsonDecode(f.sent('/claim').single.body)['policy_receipt'], - 'synthetic-policy-receipt', - ); - expect( - jsonDecode(f.sent('/accept-policy').single.body)['age_confirmed'], - isTrue, - ); - // Reopen the saved invite after its claim was consumed. Starter recovery - // must retain the durable marker and must not claim it again. - await f.link(tester, 'one'); - expect((await f.storage.loadAll()).single.starterSetupIncomplete, isTrue); - expect(find.text('Try again').hitTestable(), findsOneWidget); - await f.tap(tester, 'Try again'); - expect( - find.byType(ProfileSetupPage, skipOffstage: false), - findsOneWidget, - ); - expect( - find.ancestor( - of: find.byType(TextField).hitTestable(), - matching: find.byType(BottomSheet), - ), - findsNothing, - ); - expect(f.sent('/claim'), hasLength(1)); - expect(f.recoveryScopes, hasLength(3)); - expect( - f.recoveryScopes.every((scope) => scope.nsec == pending.nsec), - isTrue, - ); - await tester.enterText( - find.byType(TextField).hitTestable(), - 'Journey Person', - ); - await f.tap(tester, 'Continue'); - expect(f.profileWrites, isEmpty); - await f.tap(tester, 'Skip'); - expect(f.profileWrites, ['Journey Person']); - final saved = (await f.storage.loadAll()).single; - expect(saved.id, pending.id); - expect(saved.starterSetupIncomplete, isFalse); - expect(await f.storage.loadActiveId(), saved.id); - expect(find.byType(OnboardingAppEntry), findsNothing); - expect(find.text('Community home').hitTestable(), findsOneWidget); - }, - requiresAuthDomain: true, - ); + expect( + find.ancestor( + of: find.byType(TextField).hitTestable(), + matching: find.byType(BottomSheet), + ), + findsNothing, + ); + expect(f.sent('/claim'), hasLength(1)); + expect(f.recoveryScopes, hasLength(3)); + expect( + f.recoveryScopes.every((scope) => scope.nsec == pending.nsec), + isTrue, + ); + await tester.enterText( + find.byType(TextField).hitTestable(), + 'Journey Person', + ); + await f.tap(tester, 'Continue'); + expect(f.profileWrites, isEmpty); + await f.tap(tester, 'Skip'); + expect(f.profileWrites, ['Journey Person']); + final saved = (await f.storage.loadAll()).single; + expect(saved.id, pending.id); + expect(saved.starterSetupIncomplete, isFalse); + expect(await f.storage.loadActiveId(), saved.id); + expect(find.byType(OnboardingAppEntry), findsNothing); + expect(find.text('Community home').hitTestable(), findsOneWidget); + }, + requiresAuthDomain: true, + ); + } for (final boundAccount in [false, true]) { journey( @@ -1283,16 +1300,26 @@ void main() { ); journey( - 'signed-in invite survives restart and leaving consent abandons only setup', + 'saved pending account retains invite across retry without reopening setup', (tester, f) async { await f.initialize(); + // /me reports stored details as pending even when legacy screening + // standing still says MISSING + NOT_SCREENED. + f.screeningPending = true; + // If setup is incorrectly reopened, allow its callback to finish so the + // no-browser assertion fails instead of cycling until the test times out. + f.beforeBrowserReturn = () async => f.screeningPending = false; await f.mount(tester); await f.link(tester, 'one'); + expect(f.browserStarts, 0); + expect(f.sent('/claim'), isEmpty); + expect(await f.storage.loadAll(), isEmpty); expect(find.text('Accept and join').hitTestable(), findsOneWidget); await f.restart(tester); await f.tap(tester, 'Start login again'); expect(find.text('Accept and join').hitTestable(), findsOneWidget); expect(f.browserStarts, 0); + f.screeningPending = false; expect( f.sent('/api/join-policy').last.url.host, 'one.communities.buzz.xyz', diff --git a/mobile/test/helpers/hosted_journey_fixture.dart b/mobile/test/helpers/hosted_journey_fixture.dart index e0dd1994f7..f6293c2fdd 100644 --- a/mobile/test/helpers/hosted_journey_fixture.dart +++ b/mobile/test/helpers/hosted_journey_fixture.dart @@ -63,6 +63,9 @@ class HostedJourneyFixture { int starterFailures = 0; final policyResponses = []; int browserStarts = 0; + final browserUrls = []; + bool needsAccountSetup = false; + bool screeningPending = false; int secondRelayFailures = 0; DateTime assertionNow = DateTime.now(); bool relayAuthEnabled = false; @@ -143,7 +146,20 @@ class HostedJourneyFixture { }; } else if (path.endsWith('/me')) { if (expiredSession) return http.Response('{}', 401); - result = {'subject': subject, 'expires_at': expiry}; + result = { + 'subject': subject, + 'expires_at': expiry, + 'account_status': needsAccountSetup + ? 'ACCOUNT_STATUS_NEEDS_DETAILS' + : screeningPending + ? 'ACCOUNT_STATUS_PENDING' + : 'ACCOUNT_STATUS_OK', + if (needsAccountSetup || screeningPending) + 'account_standing': { + 'details_status': 'ACCOUNT_DETAILS_STATUS_MISSING', + 'screening_status': 'ACCOUNT_SCREENING_STATUS_NOT_SCREENED', + }, + }; } else if (path.endsWith('/identity/assertions')) { await beforeAssertionReturn?.call(); if (expiredSession) return http.Response('{}', 401); @@ -369,7 +385,8 @@ class HostedJourneyFixture { HostedFlow createFlow(Ref ref) => activeFlow = createHostedFlow( ref, - browserAuth: _Browser(() async { + browserAuth: _Browser((uri) async { + browserUrls.add(uri); browserStarts++; await beforeBrowserReturn?.call(); }), @@ -428,7 +445,7 @@ class HostedJourneyFixture { final signIn = OrganizationSignIn( account: ref.watch(organizationAccountProvider)!, browser: _Browser( - () async => organizationBrowserStarts++, + (_) async => organizationBrowserStarts++, origin: organizationOrigin, ), // As in production: only the organization's relay reconnects. @@ -664,10 +681,10 @@ class _Recovery implements InviteJoinRecovery { } class _Browser extends BrowserAuth { - _Browser(Future Function() onStart, {super.origin}) + _Browser(Future Function(Uri) onStart, {super.origin}) : super( openBrowser: (uri) async { - await onStart(); + await onStart(uri); return Uri.parse(BrowserAuth.callback) .replace( queryParameters: { diff --git a/mobile/test/shared/hosted/browser_auth_test.dart b/mobile/test/shared/hosted/browser_auth_test.dart index 96c971972d..b370ab40ce 100644 --- a/mobile/test/shared/hosted/browser_auth_test.dart +++ b/mobile/test/shared/hosted/browser_auth_test.dart @@ -34,6 +34,20 @@ void main() { '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.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.delayed(Duration.zero); @@ -48,6 +62,17 @@ void main() { ); 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);