diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php index 4bd99be..2291742 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -34,6 +34,8 @@ final class Account private const string TimezoneKey = 'devnull.timezone'; + private const string TermsKey = 'devnull.terms-to-accept'; + public function user(): ?User { $attributes = Cache::get(self::CacheKey); @@ -191,8 +193,30 @@ public function rememberTimezone(?string $timezone): void } /** - * Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, every answer, image and - * voice note kept for a quick start, and your unsent drafts and recordings. + * The version of the terms devnull last said you have yet to accept, so the app opens on them (see AcceptTerms), + * or null when you're up to date. + */ + public function termsToAccept(): ?string + { + $version = Cache::get(self::TermsKey); + + return is_string($version) && $version !== '' ? $version : null; + } + + public function rememberTermsToAccept(?string $version): void + { + if ($version === null) { + Cache::forget(self::TermsKey); + + return; + } + + Cache::forever(self::TermsKey, $version); + } + + /** + * Signs this phone out: who it was, the badges, its pastéis and Remotes, your timezone, the terms you owe, every + * answer, image and voice note kept for a quick start, and your unsent drafts and recordings. */ public function forget(): void { @@ -209,5 +233,6 @@ public function forget(): void Cache::forget(self::ProfileKey); Cache::forget(self::PinWarningKey); Cache::forget(self::TimezoneKey); + Cache::forget(self::TermsKey); } } diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 28d9d7e..dccb518 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -123,6 +123,11 @@ public const string SettingsPath = 'me/settings'; + /** + * Where the signed-in account accepts the terms and privacy policy in force. + */ + public const string TermsPath = 'me/terms'; + /** * The commits the signed-in account scheduled, as the web's Scheduled * commits page lists them. @@ -184,16 +189,24 @@ public static function pastelPath(string $id): string * Trades the code dev-null.sh sent back, with the PKCE verifier only this * phone knows, for the user it approved this phone for. It's signed like * every request, so the code only works for the key it was made for. + * + * No sign-in starts until the terms are accepted, so this says they were. + * devnull works out which version that means from when it hears. */ public function redeemSignIn(string $code, #[SensitiveParameter] string $verifier): User { - return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: ['code' => $code, 'code_verifier' => $verifier]))); + return User::fromArray($this->data($this->send('POST', self::SignInPath, payload: [ + 'code' => $code, + 'code_verifier' => $verifier, + 'terms_accepted' => true, + ]))); } /** * Signs in on a phone devnull doesn't know yet with an Apple ID linked to * an account, which approves this phone's key. Apple's identity token * carries sha256 of the nonce, and devnull checks it against the raw one. + * It says the terms were accepted, as redeemSignIn() does. */ public function signInWithApple(#[SensitiveParameter] string $identityToken, #[SensitiveParameter] string $nonce, string $deviceName): User { @@ -201,6 +214,7 @@ public function signInWithApple(#[SensitiveParameter] string $identityToken, #[S 'apple_identity_token' => $identityToken, 'nonce' => $nonce, 'device_name' => $deviceName, + 'terms_accepted' => true, ]))); } @@ -232,6 +246,19 @@ public function me(): CurrentUser return CurrentUser::fromArray($this->get('me')); } + /** + * Accepts the terms and privacy policy, naming the version devnull said is in force. When they changed again + * since, devnull refuses with a 422 rather than take a yes to a version nobody showed you. It answers as + * `GET /me` does, and every kept copy of you takes it. + */ + public function acceptTerms(string $version): CurrentUser + { + $json = $this->json($this->send('PUT', self::TermsPath, payload: ['version' => $version])); + $this->cache->meChanged($json); + + return CurrentUser::fromArray($json); + } + /** * Deletes the account this phone is signed in as, the way the web's Delete * account does. devnull deletes its devices with it, so this phone's key diff --git a/app/Devnull/Data/CurrentUser.php b/app/Devnull/Data/CurrentUser.php index c082edb..9fead8f 100644 --- a/app/Devnull/Data/CurrentUser.php +++ b/app/Devnull/Data/CurrentUser.php @@ -15,6 +15,10 @@ * it only was when you signed in). It has whether pastéis mode is on, how * many pastéis you can still give today, and how many others have given you, * and your Remotes settings, or null while you have Remotes off. + * + * And the terms: the version in force, which is the moment it took effect, when + * you last accepted them, and whether that was this version. A devnull too old + * to say leaves all three null. */ final readonly class CurrentUser { @@ -30,6 +34,9 @@ public function __construct( public Pasteis $pasteisToday = new Pasteis, public int $pasteisReceived = 0, public ?Remotes $remotes = null, + public ?string $termsVersion = null, + public ?CarbonImmutable $termsAcceptedAt = null, + public ?bool $termsAccepted = null, ) {} /** @@ -59,9 +66,28 @@ public static function fromArray(array $json): self pasteisReceived: is_int($data['pasteis_received'] ?? null) ? max(0, $data['pasteis_received']) : 0, // Only devnull saying null means they're off: a devnull too old to say has them on, as everyone starts. remotes: array_key_exists('remotes', $data) ? Remotes::fromArray($data['remotes']) : new Remotes, + termsVersion: is_string($data['terms_version'] ?? null) && $data['terms_version'] !== '' ? $data['terms_version'] : null, + termsAcceptedAt: Commit::date($data['terms_accepted_at'] ?? null), + termsAccepted: is_bool($data['terms_accepted'] ?? null) ? $data['terms_accepted'] : null, ); } + /** + * The version of the terms devnull is waiting for you to accept, exactly as it sent it, or null when you have. + */ + public function termsToAccept(): ?string + { + return $this->termsAccepted === false ? $this->termsVersion : null; + } + + /** + * When the terms in force took effect. + */ + public function termsTookEffect(): ?CarbonImmutable + { + return Commit::date($this->termsVersion); + } + /** * You as this phone remembers you, until devnull says the rest. */ diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php index 441cc2d..43fbd5f 100644 --- a/app/Devnull/Display.php +++ b/app/Devnull/Display.php @@ -138,6 +138,14 @@ public static function fullDateTime(CarbonInterface $date, ?string $timezone = n return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('g:i A · M j, Y'); } + /** + * The day something happened, with no time: "Nov 1, 2026", in your timezone. + */ + public static function day(CarbonInterface $date, ?string $timezone = null): string + { + return CarbonImmutable::instance($date)->setTimezone($timezone ?? app(Timezone::class)->yours())->format('M j, Y'); + } + /** * When a scheduled commit is due, as the web's Scheduled commits page * prints it: "Oct 16, 2026, 10:30 AM", in the phone's timezone. diff --git a/app/NativeComponents/AcceptTerms.php b/app/NativeComponents/AcceptTerms.php new file mode 100644 index 0000000..9804ae4 --- /dev/null +++ b/app/NativeComponents/AcceptTerms.php @@ -0,0 +1,161 @@ +version = app(Account::class)->termsToAccept(); + + if ($this->version === null) { + $this->replace('/log'); + + return; + } + + $kept = $this->cached('me'); + $this->acceptedBefore = $kept !== null && CurrentUser::fromArray($kept)->termsAcceptedAt !== null; + + $this->revalidate(); + } + + protected function revalidate(): void + { + $this->refresh('me', [], fn (array $json) => $this->takeTerms(CurrentUser::fromArray($json)), quiet: true); + } + + protected function showsContent(): bool + { + return true; + } + + public function accept(): void + { + if ($this->version === null) { + return; + } + + try { + $current = app(Client::class)->acceptTerms($this->version); + } catch (Unauthenticated $exception) { + $this->signOut($exception); + + return; + } catch (ApiException $exception) { + if ($exception->status !== 422) { + $this->error = $exception->firstError(); + + return; + } + + // The version in force moved on since devnull said which, so this asks it for the new one. + $this->error = self::ChangedAgain; + $this->retry(); + + return; + } catch (KeychainUnavailable $exception) { + $this->error = $exception->getMessage(); + + return; + } + + $this->error = null; + $this->takeTerms($current); + } + + #[On(ButtonPressed::class)] + public function onAlertButton(string $label = '', ?string $id = null): void + { + if ($id === self::SignOutAlert && $label === 'Sign out') { + $this->forget(); + } + + if ($id === self::DeleteAccountAlert && $label === 'Delete') { + $this->deleteAccount(); + } + } + + public function render(): View + { + $tookEffect = Commit::date($this->version); + + return view('native.accept-terms', [ + 'logo' => TabsLayout::logo(), + 'title' => $this->acceptedBefore ? 'We updated our terms' : 'Accept our terms', + 'explanation' => $this->acceptedBefore && $tookEffect !== null + ? 'Our terms of service changed on '.Display::day($tookEffect).'. Read them, and accept them to keep using /dev/null.' + : 'Read the terms of service and privacy policy, and accept them to keep using /dev/null.', + ]); + } + + /** + * Takes devnull's word on the terms: on to the log once you're up to date with them, as after accepting them + * here or on the web, or else the version it's waiting for now. + */ + protected function takeTerms(CurrentUser $current): void + { + if ($current->termsAccepted === null || $current->user->username === '') { + return; + } + + app(Account::class)->remember($current->user); + app(Account::class)->rememberTermsToAccept($current->termsToAccept()); + + if ($current->termsToAccept() === null) { + $this->replace('/log'); + + return; + } + + $this->version = $current->termsToAccept(); + $this->acceptedBefore = $current->termsAcceptedAt !== null; + } +} diff --git a/app/NativeComponents/Concerns/LeavesDevnull.php b/app/NativeComponents/Concerns/LeavesDevnull.php new file mode 100644 index 0000000..5dfb31b --- /dev/null +++ b/app/NativeComponents/Concerns/LeavesDevnull.php @@ -0,0 +1,81 @@ + 'Cancel', 'style' => 'cancel'], + ['label' => 'Sign out', 'style' => 'destructive'], + ], + )->id(self::SignOutAlert); + } + + public function confirmDeleteAccount(): void + { + Dialog::alert( + 'Delete your account?', + 'Your profile, commits, replies, stars and follows are removed from /dev/null. There is no undo.', + [ + ['label' => 'Cancel', 'style' => 'cancel'], + ['label' => 'Delete', 'style' => 'destructive'], + ], + )->id(self::DeleteAccountAlert); + } + + /** + * Signs out: the key goes from this phone, and with it who you are and + * every answer kept for you (see Account::forget). + */ + public function forget(): void + { + app(DeviceKey::class)->forget(); + app(Account::class)->forget(); + + $this->replace('/onboarding'); + } + + /** + * Deletes the account on devnull, which takes its devices with it, then + * signs out. When devnull can't, the error shows and you stay signed in. + */ + public function deleteAccount(): void + { + $deleted = $this->attempt(function (Client $devnull): bool { + $devnull->deleteAccount(); + + return true; + }); + + if ($deleted === true) { + $this->forget(); + } + } +} diff --git a/app/NativeComponents/Concerns/OpensLegalPages.php b/app/NativeComponents/Concerns/OpensLegalPages.php new file mode 100644 index 0000000..97a0fb5 --- /dev/null +++ b/app/NativeComponents/Concerns/OpensLegalPages.php @@ -0,0 +1,22 @@ +remember($current->user); app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); + $this->takeTerms($current); } app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); }, quiet: true); } + /** + * Keeps whether devnull is waiting for you to accept its terms, when its + * answer says, and sends you to accept them the moment it is, whichever + * screen you're on (see AcceptTerms). + */ + protected function takeTerms(CurrentUser $current): void + { + if ($current->termsAccepted === null) { + return; + } + + app(Account::class)->rememberTermsToAccept($current->termsToAccept()); + + if ($current->termsToAccept() !== null) { + $this->replace(AcceptTerms::Path); + } + } + /** * Asks devnull for what only you see in the web's settings, and keeps the * timezone you chose there, which `/me` doesn't carry, so the app shows diff --git a/app/NativeComponents/Launch.php b/app/NativeComponents/Launch.php index fb249a6..4c55633 100644 --- a/app/NativeComponents/Launch.php +++ b/app/NativeComponents/Launch.php @@ -9,7 +9,8 @@ use Native\Mobile\Edge\NativeComponent; /** - * The first screen: straight to the log for a verified device, otherwise onboarding. + * The first screen: straight to the log for a verified device, otherwise onboarding. A verified device whose account + * owed terms when devnull last said goes to accept them first (see AcceptTerms). * * A verified device whose key has gone, as on a new phone restored from a * backup, or when the build's signing team changed and the Keychain with it, @@ -35,7 +36,7 @@ public function mount(Account $account, DeviceKey $deviceKey): void return; } - $this->replace('/log'); + $this->replace($account->termsToAccept() === null ? '/log' : AcceptTerms::Path); } public function render(): View diff --git a/app/NativeComponents/Onboarding.php b/app/NativeComponents/Onboarding.php index 02b85b2..2b3cef0 100644 --- a/app/NativeComponents/Onboarding.php +++ b/app/NativeComponents/Onboarding.php @@ -10,6 +10,7 @@ use App\Devnull\Exceptions\ApiException; use App\Devnull\Exceptions\KeychainUnavailable; use App\Devnull\SignIn; +use App\NativeComponents\Concerns\OpensLegalPages; use App\NativeComponents\Concerns\SignsInWithApple; use App\NativeComponents\Layouts\TabsLayout; use Illuminate\Support\Facades\Log; @@ -40,6 +41,7 @@ */ class Onboarding extends NativeComponent { + use OpensLegalPages; use SignsInWithApple; public const string NotStartedHere = "fatal: that sign-in didn't start on this phone, so it was ignored."; @@ -54,6 +56,12 @@ class Onboarding extends NativeComponent */ public ?string $appleError = null; + /** + * Whether the box for the terms and privacy policy is ticked. Neither way in starts until it is. Don't read it when + * a sign-in finishes: the browser's answer can land on a new screen, where it's false again. + */ + public bool $acceptedTerms = false; + private string $deviceName = ''; /** @@ -94,13 +102,18 @@ public function onResume(): void $this->finishSignIn(); } + public function toggleTerms(): void + { + $this->acceptedTerms = ! $this->acceptedTerms; + } + /** * Opens dev-null.sh with a fresh verifier and state. Dismissing the sheet * sends nothing back, so the screen simply stays as it is. */ public function signIn(): void { - if ($this->fingerprint === '') { + if ($this->fingerprint === '' || ! $this->acceptedTerms) { return; } @@ -118,7 +131,7 @@ public function signIn(): void */ public function signInWithApple(): void { - if ($this->fingerprint === '' || ! AppleSignIn::isAvailable()) { + if ($this->fingerprint === '' || ! $this->acceptedTerms || ! AppleSignIn::isAvailable()) { return; } @@ -239,8 +252,9 @@ private function finishSignIn(): void /** * A key devnull already knows and approved signs in with nothing to do, - * as after the app's stored sign-in was lost. Any other answer leaves - * onboarding as it is, without an error: there was nothing to ask for. + * as after the app's stored sign-in was lost, then asks for any terms + * still to accept. Any other answer leaves onboarding as it is, without + * an error: there was nothing to ask for. */ private function signInWithExistingKey(): void { @@ -258,8 +272,9 @@ private function signInWithExistingKey(): void app(Account::class)->rememberUnreadNotifications($current->unreadNotificationsCount); app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); + app(Account::class)->rememberTermsToAccept($current->termsToAccept()); $this->keepProfile($current->user); - $this->replace('/log'); + $this->replace($current->termsToAccept() === null ? '/log' : AcceptTerms::Path); } /** diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index 1630f33..f8516be 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -15,6 +15,8 @@ use App\Devnull\ResponseCache; use App\Devnull\Timezone; use App\Devnull\WhoToFollow; +use App\NativeComponents\Concerns\LeavesDevnull; +use App\NativeComponents\Concerns\OpensLegalPages; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SignsInWithApple; use App\NativeComponents\Concerns\SyncsOutbox; @@ -33,7 +35,8 @@ * Your account, reached from your own profile, laid out like the web's profile * settings: your profile as devnull mirrors it from GitHub, with a sync, your * colour, your timezone, pastéis mode, Remotes and their settings, the people - * you mute and block, then this phone's fingerprint, signing out and deleting + * you mute and block, then this phone's fingerprint, signing out, the terms + * and privacy policy with which version you accepted and when, and deleting * the account. * Where the app talks to only shows when it isn't dev-null.sh. * @@ -47,18 +50,16 @@ */ class ThisDevice extends NativeComponent { + use LeavesDevnull; + use OpensLegalPages; use RefreshesInBackground; use SignsInWithApple; use SyncsOutbox; use TalksToDevnull; use WritesInBackground; - public const string SignOutAlert = 'sign-out'; - public const string UnlinkAppleAlert = 'unlink-apple'; - public const string DeleteAccountAlert = 'delete-account'; - /** * Where Edit on GitHub goes, as on the web. */ @@ -583,30 +584,6 @@ public function openDevices(): void Browser::inApp(rtrim((string) config('devnull.url'), '/').'/settings/devices'); } - public function confirmSignOut(): void - { - Dialog::alert( - 'Sign out?', - 'This deletes the key from this phone. The phone stays listed on dev-null.sh until you remove it there, and signing in again makes a new key.', - [ - ['label' => 'Cancel', 'style' => 'cancel'], - ['label' => 'Sign out', 'style' => 'destructive'], - ], - )->id(self::SignOutAlert); - } - - public function confirmDeleteAccount(): void - { - Dialog::alert( - 'Delete your account?', - 'Your profile, commits, replies, stars and follows are removed from /dev/null. There is no undo.', - [ - ['label' => 'Cancel', 'style' => 'cancel'], - ['label' => 'Delete', 'style' => 'destructive'], - ], - )->id(self::DeleteAccountAlert); - } - /** * Opens Apple's sheet. Its answer arrives as an event (see SignsInWithApple). */ @@ -661,35 +638,6 @@ public function onAlertButton(string $label = '', ?string $id = null): void } } - /** - * Signs out: the key goes from this phone, and with it who you are and - * every answer kept for you (see Account::forget). - */ - public function forget(): void - { - app(DeviceKey::class)->forget(); - app(Account::class)->forget(); - - $this->replace('/onboarding'); - } - - /** - * Deletes the account on devnull, which takes its devices with it, then - * signs out. When devnull can't, the error shows and you stay signed in. - */ - public function deleteAccount(): void - { - $deleted = $this->attempt(function (Client $devnull): bool { - $devnull->deleteAccount(); - - return true; - }); - - if ($deleted === true) { - $this->forget(); - } - } - public function render(): View { $current = $this->current; @@ -716,9 +664,33 @@ public function render(): View false => 'not linked', null => 'unknown', }, + 'terms' => $current === null ? null : self::terms($current), ]); } + /** + * Which terms you accepted and when, each version named by the day it took effect. devnull keeps only when you + * accepted, so a version older than the one in force can't be named. Null until devnull has said. + */ + private static function terms(CurrentUser $current): ?string + { + $tookEffect = $current->termsTookEffect(); + + if ($tookEffect === null || $current->termsAccepted === null) { + return null; + } + + $terms = Display::day($tookEffect); + $acceptedOn = $current->termsAcceptedAt === null ? null : Display::day($current->termsAcceptedAt); + + return match (true) { + $current->termsAccepted && $acceptedOn !== null => "You accepted the terms of {$terms} on {$acceptedOn}.", + $current->termsAccepted => "You accepted the terms of {$terms}.", + $acceptedOn !== null => "You accepted an earlier version on {$acceptedOn}. The terms changed on {$terms}.", + default => "You haven't accepted the terms of {$terms} yet.", + }; + } + /** * What devnull mirrors from GitHub, in the web's order, with null for what GitHub doesn't have. * @@ -913,6 +885,7 @@ private function takeMe(array $json): void app(Account::class)->rememberPasteis($current->pasteis()); app(Account::class)->rememberRemotes($current->remotes); app(ResponseCache::class)->meChanged($json); + $this->takeTerms($current); } /** diff --git a/resources/views/native/accept-terms.blade.php b/resources/views/native/accept-terms.blade.php new file mode 100644 index 0000000..aee64a7 --- /dev/null +++ b/resources/views/native/accept-terms.blade.php @@ -0,0 +1,31 @@ +@use('App\Icons\Lucide') + + + + + + + {{ $title }} + {{ $explanation }} + There's no tolerance for objectionable content or abusive users. + If you don't accept them, you can sign out, or delete your account. + + + Terms of service + Privacy policy + + + + + + @if ($error) + {{ $error }} + @endif + + Accept the terms + Sign out + + Delete account + + + diff --git a/resources/views/native/onboarding.blade.php b/resources/views/native/onboarding.blade.php index 9c45eb3..563a8b8 100644 --- a/resources/views/native/onboarding.blade.php +++ b/resources/views/native/onboarding.blade.php @@ -16,12 +16,34 @@ {{ $appleError }} @endif + {{-- What Apple asks of an app with user-generated content: agree to the terms before you're in. Neither button works until it's ticked. --}} + + + + @if ($acceptedTerms) + + @endif + + + + I agree to the terms of service and privacy policy. There's no tolerance for objectionable content or abusive users. + + + Terms of service + + + Privacy policy + + + + + {{-- Commit Blue, large, as the web's primary button. Nobody's lane colours it here: primary is always Commit Blue. --}} - {{ $error && $fingerprint !== '' ? 'Try again' : 'Sign in on dev-null.sh' }} + {{ $error && $fingerprint !== '' ? 'Try again' : 'Sign in on dev-null.sh' }} {{-- Apple's button in Apple's colours, the theme's accent, with its logo, as big as the dev-null.sh one, as Apple's guidelines ask. --}} @if ($offersApple) - Sign in with Apple + Sign in with Apple @endif diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index cededae..93c613b 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -318,6 +318,15 @@ Deletes the key from this phone and signs you out. + + @include('native.partials.settings-heading', ['title' => 'Terms and privacy', 'description' => $terms]) + + + Terms of service + Privacy policy + + + @include('native.partials.settings-heading', [ 'title' => 'Delete account', diff --git a/routes/mobile.php b/routes/mobile.php index c444401..764b9a2 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -1,6 +1,7 @@ and($user->username)->toBe('local_dev'); Http::assertSent(function (Request $request): bool { - $body = '{"code":"the-code","code_verifier":"the-verifier"}'; + $body = '{"code":"the-code","code_verifier":"the-verifier","terms_accepted":true}'; expect($request->method())->toBe('POST') ->and($request->url())->toBe('http://devnull.test/api/v1/'.Client::SignInPath) @@ -677,7 +677,7 @@ function receivedMessage(Request $request): string expect($user->username)->toBe('local_dev'); Http::assertSent(function (Request $request): bool { - $body = '{"apple_identity_token":"the.identity.token","nonce":"raw-nonce","device_name":"devnull on iPhone"}'; + $body = '{"apple_identity_token":"the.identity.token","nonce":"raw-nonce","device_name":"devnull on iPhone","terms_accepted":true}'; expect($request->method())->toBe('POST') ->and($request->url())->toBe('http://devnull.test/api/v1/'.Client::SignInPath) diff --git a/tests/Feature/Screens/AcceptTermsTest.php b/tests/Feature/Screens/AcceptTermsTest.php new file mode 100644 index 0000000..fc10116 --- /dev/null +++ b/tests/Feature/Screens/AcceptTermsTest.php @@ -0,0 +1,205 @@ + 'http://devnull.test']); + signIn(); + app(Account::class)->rememberTimezone('Europe/Lisbon'); +}); + +/** + * devnull said, the last time it was asked, that the terms in force since Nov 1, 2026 are still to be accepted. + */ +function oweTerms(?string $acceptedAt = '2026-10-05T10:00:00Z'): void +{ + keep('me', currentUserTermsJson(false, $acceptedAt)); + app(Account::class)->rememberTermsToAccept('2026-11-01T14:35:00Z'); +} + +/** + * devnull's answer to a version that isn't the one in force. + * + * @return array{0: array, 1: int} + */ +function staleTermsVersion(): array +{ + return [['message' => 'The selected version is invalid.', 'errors' => ['version' => ['The selected version is invalid.']]], 422]; +} + +it('sends you to accept the terms the moment devnull says you owe them', function () { + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson()])], + 'GET /api/v1/me' => [currentUserTermsJson(false)], + ]); + + Native::visit('/log')->assertReplacedWith(AcceptTerms::Path); + + expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z'); +}); + +it('sends you to accept them from your account too', function () { + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + Native::visit('/profile/device')->assertReplacedWith(AcceptTerms::Path); +}); + +it('lets you carry on, and forgets terms owed before, once devnull says you accepted them', function () { + app(Account::class)->rememberTermsToAccept('2026-10-01T00:00:00Z'); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson()])], + 'GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')], + ]); + + Native::visit('/log')->assertNoNavigation(); + + expect(app(Account::class)->termsToAccept())->toBeNull(); +}); + +it('asks for nothing when devnull does not say which terms are in force', function () { + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson()])], + 'GET /api/v1/me' => [currentUserJson()], + ]); + + Native::visit('/log')->assertNoNavigation(); + + expect(app(Account::class)->termsToAccept())->toBeNull(); +}); + +it('says the terms changed, and when, to someone who accepted them before', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + Native::visit(AcceptTerms::Path) + ->assertNoNavigation() + ->assertSee('We updated our terms') + ->assertSee('Our terms of service changed on Nov 1, 2026. Read them, and accept them to keep using /dev/null.') + ->assertSee("There's no tolerance for objectionable content or abusive users.") + ->assertSee("If you don't accept them, you can sign out, or delete your account.") + ->assertSee('Accept the terms') + ->assertSee('Sign out') + ->assertSee('Delete account'); +}); + +it('only asks someone who never accepted any', function () { + oweTerms(acceptedAt: null); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false, null)]]); + + Native::visit(AcceptTerms::Path) + ->assertSee('Accept our terms') + ->assertSee('Read the terms of service and privacy policy, and accept them to keep using /dev/null.') + ->assertDontSee('We updated our terms'); +}); + +it('accepts the version devnull said, then carries on to the log', function () { + oweTerms(); + fakeApi([ + 'GET /api/v1/me' => [currentUserTermsJson(false)], + 'PUT /api/v1/me/terms' => [currentUserTermsJson(true, '2026-11-03T12:00:00Z')], + ]); + + Native::visit(AcceptTerms::Path)->tap('accept')->assertReplacedWith('/log'); + + expect(sentTo('PUT', 'me/terms'))->toBe([json_encode(['version' => '2026-11-01T14:35:00Z'])]) + ->and(app(Account::class)->termsToAccept())->toBeNull() + ->and(CurrentUser::fromArray(kept('me'))->termsAccepted)->toBeTrue(); +}); + +it('asks again when the terms changed while it was open, then accepts the new ones', function () { + oweTerms(); + serveApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'PUT /api/v1/me/terms' => staleTermsVersion()]); + + $screen = Native::visit(AcceptTerms::Path); + + serveApi(['GET /api/v1/me' => [currentUserTermsJson(false, version: '2026-12-01T09:00:00Z')], 'PUT /api/v1/me/terms' => staleTermsVersion()]); + + $screen->tap('accept') + ->assertNoNavigation() + ->assertSee(AcceptTerms::ChangedAgain) + ->assertSet('version', '2026-12-01T09:00:00Z') + ->assertSee('Our terms of service changed on Dec 1, 2026. Read them, and accept them to keep using /dev/null.'); + + serveApi(['GET /api/v1/me' => [currentUserTermsJson(false, version: '2026-12-01T09:00:00Z')], 'PUT /api/v1/me/terms' => [currentUserTermsJson(true, '2026-12-02T09:00:00Z', '2026-12-01T09:00:00Z')]]); + + $screen->tap('accept')->assertReplacedWith('/log'); + + expect(sentTo('PUT', 'me/terms'))->toBe([ + json_encode(['version' => '2026-11-01T14:35:00Z']), + json_encode(['version' => '2026-12-01T09:00:00Z']), + ]); +}); + +it('says why when devnull cannot take the yes, and stays', function () { + oweTerms(); + serveApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'PUT /api/v1/me/terms' => [['message' => 'Server Error'], 500]]); + + Native::visit(AcceptTerms::Path) + ->tap('accept') + ->assertNoNavigation() + ->assertSet('error', 'Server Error') + ->assertSee('Server Error'); + + expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z'); +}); + +it('lets you straight through when you accepted them on the web since', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')]]); + + Native::visit(AcceptTerms::Path)->assertReplacedWith('/log'); + + expect(app(Account::class)->termsToAccept())->toBeNull(); +}); + +it('goes to the log when there are no terms to accept', function () { + fakeApi([]); + + Native::visit(AcceptTerms::Path)->assertReplacedWith('/log'); + + Http::assertNothingSent(); +}); + +it('opens the terms and privacy policy in the app', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + Native::visit(AcceptTerms::Path) + ->tap('terms-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms') + ->tap('privacy-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy'); +}); + +it('signs out after a confirm, and forgets the terms owed', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + Native::visit(AcceptTerms::Path) + ->tap('sign-out') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Sign out?') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Sign out', 'id' => AcceptTerms::SignOutAlert]) + ->assertReplacedWith('/onboarding'); + + expect(app(Account::class)->user())->toBeNull() + ->and(app(Account::class)->termsToAccept())->toBeNull(); +}); + +it('deletes the account after a confirm', function () { + oweTerms(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)], 'DELETE /api/v1/me' => [null, 204]]); + + Native::visit(AcceptTerms::Path) + ->press('delete-account') + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Delete your account?') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => AcceptTerms::DeleteAccountAlert]) + ->assertReplacedWith('/onboarding'); + + expect(sentTo('DELETE', 'me'))->toHaveCount(1) + ->and(app(Account::class)->user())->toBeNull(); +}); diff --git a/tests/Feature/Screens/AccountTermsTest.php b/tests/Feature/Screens/AccountTermsTest.php new file mode 100644 index 0000000..74f0083 --- /dev/null +++ b/tests/Feature/Screens/AccountTermsTest.php @@ -0,0 +1,59 @@ + 'http://devnull.test']); + signIn(); + app(Account::class)->rememberTimezone('Europe/Lisbon'); +}); + +it('says which terms you accepted and when, just before deleting the account', function (?string $acceptedAt, string $sentence) { + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, $acceptedAt)]]); + + $screen = Native::visit('/profile/device') + ->assertNoNavigation() + ->assertSee('Terms and privacy') + ->assertSee($sentence); + + $sections = findNodes($screen->tree(), fn (array $node): bool => in_array($node['ref'] ?? null, ['sign-out', 'terms-section', 'delete-section'], true)); + + expect(array_column($sections, 'ref'))->toBe(['sign-out', 'terms-section', 'delete-section']); +})->with([ + 'with the day' => ['2026-11-02T09:00:00Z', 'You accepted the terms of Nov 1, 2026 on Nov 2, 2026.'], + 'without the day' => [null, 'You accepted the terms of Nov 1, 2026.'], +]); + +it('says when you have not accepted the terms in force, until devnull sends you to accept them', function (?string $acceptedAt, string $sentence) { + keep('me', currentUserTermsJson(false, $acceptedAt)); + holdRefreshes(); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false, $acceptedAt)]]); + + $screen = Native::visit('/profile/device')->assertSee($sentence); + + finishRefreshes($screen)->assertReplacedWith(AcceptTerms::Path); +})->with([ + 'an earlier version' => ['2026-10-05T10:00:00Z', 'You accepted an earlier version on Oct 5, 2026. The terms changed on Nov 1, 2026.'], + 'never accepted' => [null, "You haven't accepted the terms of Nov 1, 2026 yet."], +]); + +it('shows only the links when devnull does not say which terms are in force', function () { + fakeApi(['GET /api/v1/me' => [currentUserJson()]]); + + Native::visit('/profile/device') + ->assertSee('Terms of service') + ->assertSee('Privacy policy') + ->assertDontSee('accepted'); +}); + +it('opens the terms and privacy policy in the app', function () { + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(true, '2026-11-02T09:00:00Z')]]); + + Native::visit('/profile/device') + ->tap('terms-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms') + ->tap('privacy-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy'); +}); diff --git a/tests/Feature/Screens/LaunchTest.php b/tests/Feature/Screens/LaunchTest.php index 77c5a8d..57ea065 100644 --- a/tests/Feature/Screens/LaunchTest.php +++ b/tests/Feature/Screens/LaunchTest.php @@ -2,6 +2,7 @@ use App\Devnull\Account; use App\Devnull\Data\User; +use App\NativeComponents\AcceptTerms; use App\NativeComponents\Launch; use Native\Mobile\Testing\Native; @@ -17,6 +18,13 @@ Native::visit('/')->assertReplacedWith('/log'); }); +it('sends a verified device to accept the terms first when devnull last said they are owed', function () { + signIn(); + app(Account::class)->rememberTermsToAccept('2026-11-01T14:35:00Z'); + + Native::visit('/')->assertReplacedWith(AcceptTerms::Path); +}); + it('stays signed in across a rebuild, which keeps the key and the stored sign-in', function () { $bridge = signIn(); diff --git a/tests/Feature/Screens/OnboardingAppleTest.php b/tests/Feature/Screens/OnboardingAppleTest.php index f297ccf..8c2460e 100644 --- a/tests/Feature/Screens/OnboardingAppleTest.php +++ b/tests/Feature/Screens/OnboardingAppleTest.php @@ -30,8 +30,8 @@ $buttons = findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'button'); expect(array_column($buttons, 'ref'))->toBe(['sign-in', 'sign-in-apple']) - ->and(childRefs($screen, 'actions'))->toBe(['sign-in', 'sign-in-apple']) - ->and(shownText($screen))->toBe([]) + ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in', 'sign-in-apple']) + ->and(shownText($screen))->toBe(termsText()) ->and($buttons[1]['props'])->toMatchArray(['label' => 'Sign in with Apple', 'variant' => 'accent', 'size' => 'lg', 'leading_icon' => 'apple.logo']) ->and($buttons[0]['props'])->toMatchArray(['variant' => 'primary', 'size' => 'lg']) ->and($buttons[1]['layout'])->toEqual($buttons[0]['layout'])->toEqual(['width' => 'fill']) @@ -49,7 +49,7 @@ expect(AppleSignIn::isAvailable())->toBeFalse() ->and((new NativeServiceProvider(app()))->plugins())->not->toContain(NativephpSocialAuthServiceProvider::class) - ->and(childRefs($screen, 'actions'))->toBe(['sign-in']); + ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']); fakeApi(['GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0, 'apple_linked' => false]]]); signIn(); @@ -64,14 +64,14 @@ $bridge = appleBridge(); $screen = Native::visit('/onboarding', platform: 'ios'); - $screen->tap('sign-in-apple')->assertNoNavigation(); + $screen->set('acceptedTerms', true)->tap('sign-in-apple')->assertNoNavigation(); $first = appleSheetParams($bridge); expect($first['nonce'])->toBe(heldAppleNonce())->toMatch('/^[0-9a-f]{64}$/') ->and($first['scopes'])->toBe([]) ->and($first['state'])->toBeNull(); - $screen->tap('sign-in-apple'); + $screen->set('acceptedTerms', true)->tap('sign-in-apple'); $second = appleSheetParams($bridge); expect($second['nonce'])->toBe(heldAppleNonce())->not->toBe($first['nonce']); @@ -86,7 +86,7 @@ 'POST /api/v1/devices' => [['data' => userWithCountsJson()]], 'GET /api/v1/users/local_dev' => [['data' => userWithCountsJson()]], ]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $nonce = heldAppleNonce(); appleAnswers($screen, $nonce)->assertReplacedWith('/log'); @@ -99,7 +99,7 @@ Http::assertSentCount(2); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://devnull.test/api/v1/'.Client::SignInPath - && $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'iPhone']) + && $request->body() === json_encode(['apple_identity_token' => AppleIdentityToken, 'nonce' => $nonce, 'device_name' => 'iPhone', 'terms_accepted' => true]) && $request->hasHeader('X-SSH-Signature')); // Apple's token carries sha256(nonce) as hex, which is what devnull checks against the raw nonce. @@ -110,7 +110,7 @@ appleBridge(); $message = ErrorCode::AppleNotLinked->message(); fakeApi(['POST /api/v1/devices' => [['message' => 'No /dev/null account is linked to this Apple ID.', 'code' => 'apple_not_linked'], 404]]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $screen = appleAnswers($screen, heldAppleNonce()) ->assertNoNavigation() @@ -119,8 +119,8 @@ ->assertSet('error', null) ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Sign in on dev-null.sh'); - expect(childRefs($screen, 'actions'))->toBe(['apple-error', 'sign-in', 'sign-in-apple']) - ->and(shownText($screen))->toBe([$message]); + expect(childRefs($screen, 'actions'))->toBe(['apple-error', 'terms', 'sign-in', 'sign-in-apple']) + ->and(shownText($screen))->toBe([$message, ...termsText()]); expect(app(Account::class)->user())->toBeNull(); }); @@ -128,7 +128,7 @@ it("says when devnull can't verify Apple's token, without turning dev-null.sh into Try again", function () { appleBridge(); fakeApi(['POST /api/v1/devices' => [['message' => 'That Apple sign-in could not be verified.', 'code' => 'invalid_apple_token'], 422]]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); appleAnswers($screen, heldAppleNonce()) ->assertNoNavigation() @@ -142,7 +142,7 @@ it("returns quietly when Apple's sheet is closed", function () { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $screen->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'Sign in with Apple was cancelled.', 'errorCode' => AppleSignIn::Cancelled]) ->assertNoNavigation() @@ -150,7 +150,7 @@ ->assertSet('error', null) ->assertDontSee('cancelled'); - expect(shownText($screen))->toBe([]); + expect(shownText($screen))->toBe(termsText()); expect(heldAppleNonce())->toBeNull(); Http::assertNothingSent(); @@ -159,7 +159,7 @@ it("says so when Apple's sign-in fails another way, and ignores Google's failures", function () { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $screen->emitNative(SignInFailed::class, ['provider' => 'google', 'error' => 'Nope.', 'errorCode' => 'SIGN_IN_FAILED']) ->assertSet('appleError', null); @@ -176,7 +176,7 @@ it('rejects an answer whose nonce is not the one it sent, and sends nothing', function (?string $nonce) { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); appleAnswers($screen, '', ['nonce' => $nonce]) ->assertNoNavigation() @@ -201,7 +201,7 @@ it('spends an attempt on its first answer, so a replayed one is rejected', function () { appleBridge(); fakeApi(['POST /api/v1/devices' => [['message' => 'That Apple sign-in could not be verified.', 'code' => 'invalid_apple_token'], 422]]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $nonce = heldAppleNonce(); appleAnswers($screen, $nonce); @@ -213,7 +213,7 @@ it('says so when Apple sends no identity token', function () { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); appleAnswers($screen, heldAppleNonce(), ['identityToken' => null])->assertSet('appleError', AppleSignIn::NoToken); @@ -224,7 +224,7 @@ fakeKeychain(testSeed())->respondTo('NativephpSocialAuth.AppleSignIn', null); Native::visit('/onboarding', platform: 'ios') - ->tap('sign-in-apple') + ->set('acceptedTerms', true)->tap('sign-in-apple') ->assertSee(AppleSignIn::CouldNotOpen); expect(heldAppleNonce())->toBeNull(); @@ -238,7 +238,7 @@ }); $bridge = appleBridge(); fakeApi(['POST /api/v1/devices' => [['data' => userWithCountsJson()]]]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); $nonce = heldAppleNonce(); expect(print_r(app(AppleSignIn::class), true))->not->toContain($nonce) @@ -267,7 +267,7 @@ Native::visit('/onboarding', platform: 'ios') ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in-apple' && ($node['props']['disabled'] ?? false) === true) - ->call('signInWithApple'); + ->set('acceptedTerms', true)->call('signInWithApple'); $bridge->assertNotCalled('NativephpSocialAuth.AppleSignIn'); }); @@ -277,7 +277,7 @@ $screen = Native::visit('/onboarding', platform: 'android') ->assertDontSee('Sign in with Apple') - ->call('signInWithApple'); + ->set('acceptedTerms', true)->call('signInWithApple'); expect(findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'button'))->toHaveCount(1); $bridge->assertNotCalled('NativephpSocialAuth.AppleSignIn'); @@ -286,7 +286,7 @@ it("asks whether the phone has an Apple Account when Apple's error says nothing more", function () { appleBridge(); fakeApi([]); - $screen = Native::visit('/onboarding', platform: 'ios')->tap('sign-in-apple'); + $screen = Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in-apple'); // What closing iOS's "Sign in to your Apple Account" alert sends back. $screen->emitNative(SignInFailed::class, ['provider' => 'apple', 'error' => 'The operation couldn’t be completed. (com.apple.AuthenticationServices.AuthorizationError error 1000.) (ASAuthorizationError.unknown)', 'errorCode' => 'SIGN_IN_FAILED']) diff --git a/tests/Feature/Screens/OnboardingSignInAgainTest.php b/tests/Feature/Screens/OnboardingSignInAgainTest.php index fec81c5..91a4a9b 100644 --- a/tests/Feature/Screens/OnboardingSignInAgainTest.php +++ b/tests/Feature/Screens/OnboardingSignInAgainTest.php @@ -3,6 +3,7 @@ use App\Devnull\Account; use App\Devnull\DeviceKey; use App\Devnull\ErrorCode; +use App\NativeComponents\AcceptTerms; use App\NativeComponents\Launch; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; @@ -35,6 +36,18 @@ $bridge->assertNotCalled('SecureStorage.Set'); }); +it('signs straight back in to the terms when they are still to be accepted', function () { + fakeKeychain(testSeed()); + fakeApi(['GET /api/v1/me' => [currentUserTermsJson(false)]]); + + $screen = Native::visit('/onboarding'); + + $this->travel(1)->seconds(); + $screen->firePoll('tick')->assertReplacedWith(AcceptTerms::Path); + + expect(app(Account::class)->termsToAccept())->toBe('2026-11-01T14:35:00Z'); +}); + it('asks only once, and says nothing, when devnull does not know the key', function (int $status) { fakeKeychain(testSeed()); fakeApi(['GET /api/v1/me' => [['message' => 'Unauthenticated.'], $status]]); diff --git a/tests/Feature/Screens/OnboardingTest.php b/tests/Feature/Screens/OnboardingTest.php index c358a3a..05b279c 100644 --- a/tests/Feature/Screens/OnboardingTest.php +++ b/tests/Feature/Screens/OnboardingTest.php @@ -59,7 +59,7 @@ function openedAuthQuery(FakeBridge $bridge): array */ function tapSignIn(TestableComponent $screen, FakeBridge $bridge): string { - $screen->tap('sign-in'); + $screen->set('acceptedTerms', true)->tap('sign-in'); return openedAuthQuery($bridge)['state']; } @@ -96,7 +96,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent $screen = Native::visit('/onboarding'); - expect(shownText($screen))->toBe([]); + expect(shownText($screen))->toBe(termsText()); $screen->assertDontSee('This phone makes its own key') ->assertSet('fingerprint', app(DeviceKey::class)->current()->fingerprint()) @@ -128,7 +128,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->and($space['layout'])->toMatchArray(['width' => 'fill', 'flex_grow' => 1, 'align_items' => AlignItems::Center->value, 'justify_content' => JustifyContent::Center->value]) ->and(array_column($screen->tree()['children'], 'ref'))->toBe(['logo-space', 'actions']) ->and($screen->tree()['layout'])->toMatchArray(['height' => 'fill', 'safe_area' => 1]) - ->and(childRefs($screen, 'actions'))->toBe(['sign-in']) + ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']) // Drawn at up to 293pt on the widest iPhone, so it's rendered 300pt wide at @3x, at the lockup's own 15:4. ->and([$width, $height])->toBe([900, 240]) ->and(getimagesize(public_path(TabsLayout::DarkLogo))[0])->toBe(900); @@ -153,7 +153,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent $bridge = signInBridge(testSeed()); $screen = Native::visit('/onboarding', platform: 'ios'); - $screen->tap('sign-in')->assertNoNavigation(); + $screen->set('acceptedTerms', true)->tap('sign-in')->assertNoNavigation(); $url = $bridge->callsTo('Browser.OpenAuth')[0]['params']['url']; $query = openedAuthQuery($bridge); @@ -182,7 +182,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent config(['nativephp.app_id' => 'com.nativephp.devnull']); $bridge = signInBridge(testSeed()); - Native::visit('/onboarding', platform: 'android')->tap('sign-in'); + Native::visit('/onboarding', platform: 'android')->set('acceptedTerms', true)->tap('sign-in'); expect(openedAuthQuery($bridge))->toMatchArray(['app_name' => '/dev/null for Android', 'app_id' => 'android:com.nativephp.devnull']); }); @@ -191,7 +191,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent config(['nativephp.app_id' => null]); $bridge = signInBridge(testSeed()); - Native::visit('/onboarding', platform: 'ios')->tap('sign-in'); + Native::visit('/onboarding', platform: 'ios')->set('acceptedTerms', true)->tap('sign-in'); expect(openedAuthQuery($bridge)['app_id'])->toBe('ios:com.nativephp.devnull'); }); @@ -227,7 +227,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent Http::assertSentCount(2); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->url() === 'http://devnull.test/api/v1/'.Client::SignInPath - && $request->body() === json_encode(['code' => 'the-code', 'code_verifier' => $verifier]) + && $request->body() === json_encode(['code' => 'the-code', 'code_verifier' => $verifier, 'terms_accepted' => true]) && $request->hasHeader('X-SSH-Signature')); }); @@ -236,6 +236,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent fakeApi(['POST /api/v1/devices' => [['data' => userWithCountsJson()]]]); $screen = Native::visit('/onboarding'); $state = tapSignIn($screen, $bridge); + $verifier = pendingAttempt()->verifier(); // On a phone the callback screen is pushed over onboarding, so it pops back rather than replacing. $callback = new AuthCallback; @@ -250,6 +251,9 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent expect($callback->getNavigationIntent()?->type)->toBe(NavigationIntent::BACK); $screen->call('onResume')->assertReplacedWith('/log'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->body() === json_encode(['code' => 'c', 'code_verifier' => $verifier, 'terms_accepted' => true])); }); it('rejects a callback whose state is not the one it sent, and redeems nothing', function () { @@ -297,7 +301,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->assertNoNavigation() ->assertSet('error', null); - expect(shownText($screen))->toBe([]); + expect(shownText($screen))->toBe(termsText()); $screen ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Sign in on dev-null.sh'); @@ -316,8 +320,8 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && $node['props']['label'] === 'Try again'); // Just above the buttons, and the only text on the screen. - expect(childRefs($screen, 'actions'))->toBe(['error', 'sign-in']) - ->and(shownText($screen))->toBe(['Something broke on our side.']); + expect(childRefs($screen, 'actions'))->toBe(['error', 'terms', 'sign-in']) + ->and(shownText($screen))->toBe(['Something broke on our side.', ...termsText()]); $retry = tapSignIn($screen, $bridge); @@ -398,7 +402,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent it('says so when the auth browser does not open', function () { fakeKeychain(testSeed())->respondTo('Browser.OpenAuth', ['success' => false]); - Native::visit('/onboarding')->tap('sign-in')->assertSee('fatal: could not open dev-null.sh to sign in.'); + Native::visit('/onboarding')->set('acceptedTerms', true)->tap('sign-in')->assertSee('fatal: could not open dev-null.sh to sign in.'); }); it("doesn't say why a 401 sent it here: the buttons are enough", function (string $message) { @@ -406,8 +410,8 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent $screen = Native::visit('/onboarding', ['message' => $message])->assertDontSee($message); - expect(shownText($screen))->toBe([]) - ->and(childRefs($screen, 'actions'))->toBe(['sign-in']); + expect(shownText($screen))->toBe(termsText()) + ->and(childRefs($screen, 'actions'))->toBe(['terms', 'sign-in']); })->with([ 'a key devnull no longer takes' => ['This key is no longer accepted.'], 'a phone to approve' => [ErrorCode::DeviceNotApproved->message()], @@ -423,7 +427,7 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->assertSee('fatal: could not read the device key from the Keychain. Device is locked.') ->assertSet('fingerprint', '') ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'sign-in' && ($node['props']['disabled'] ?? false) === true) - ->call('signIn'); + ->set('acceptedTerms', true)->call('signIn'); $bridge->assertNotCalled('SecureStorage.Set')->assertNotCalled('Browser.OpenAuth'); }); @@ -473,3 +477,29 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent ->and(file_get_contents(base_path('.env.example')))->not->toContain('DEVNULL_GITHUB_CLIENT_ID')->toContain("NATIVEPHP_DEEPLINK_SCHEME=devnull\n") ->and(file_get_contents(base_path('README.md')))->not->toContain('DEVNULL_GITHUB_CLIENT_ID')->not->toContain('device flow'); }); + +it('keeps both ways in shut until the terms and privacy policy are agreed to, and links to them', function () { + $bridge = signInBridge(); + $bridge->respondTo('Browser.OpenInApp', ['success' => true]); + + $screen = Native::visit('/onboarding'); + + $disabled = fn (string $ref): bool => (bool) ($screen->tree() && (nodeByRef($screen, $ref)['props']['disabled'] ?? false)); + + expect($disabled('sign-in'))->toBeTrue(); + + $screen->call('signIn'); + + expect($bridge->callsTo('Browser.OpenAuth'))->toBe([]); + + $screen->press('accept-terms')->assertSet('acceptedTerms', true)->call('signIn'); + + expect($bridge->callsTo('Browser.OpenAuth'))->toHaveCount(1); + + $screen->press('terms-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/terms') + ->press('privacy-link') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/privacy'); + + $screen->press('accept-terms')->assertSet('acceptedTerms', false); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 107be23..6583077 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -459,6 +459,17 @@ function currentUserJson(array $overrides = [], array $extra = []): array ]; } +/** + * `GET /me` with the terms in force since Nov 1, 2026, or the version given, for someone who last accepted the terms + * when given (null for never), and whether that was this version. + * + * @return array + */ +function currentUserTermsJson(bool $accepted, ?string $acceptedAt = '2026-10-05T10:00:00Z', string $version = '2026-11-01T14:35:00Z'): array +{ + return currentUserJson(['terms_version' => $version, 'terms_accepted_at' => $acceptedAt, 'terms_accepted' => $accepted]); +} + /** * A commit as the API sends it to a signed request. The short hash and the link * follow the id and the author, and the body's segments follow the body, unless @@ -1021,6 +1032,20 @@ function sentTo(string $method, string $path): array ->all(); } +/** + * What the sign-in screen says about the terms, above its buttons. + * + * @return list + */ +function termsText(): array +{ + return [ + 'I agree to the terms of service and privacy policy. There\'s no tolerance for objectionable content or abusive users.', + 'Terms of service', + 'Privacy policy', + ]; +} + /** * A commit you scheduled, as devnull lists it: still waiting, until a test says otherwise. Its body's segments * follow its body, as the server works them out.