Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -23,15 +23,76 @@ test('users can authenticate using the login screen', function () {
$response->assertRedirect(route('dashboard', absolute: false));
});
test('users can not authenticate with invalid password', function () {
test('users with incomplete onboarding can authenticate with username', function () {
$user = User::factory()->create([
'onboarding_step' => null,
]);
$response = $this->post('/login', [
'email' => $user->username,
'password' => 'password',
]);
$this->assertAuthenticatedAs($user);
$response->assertRedirect(route('setup.email.create', absolute: false));
});
test('legacy users pending email upgrade can authenticate with username', function () {
$user = User::factory()->create([
'email' => 'legacy-user@users.skinbase.org',
'onboarding_step' => null,
]);
$response = $this->post('/login', [
'email' => $user->username,
'password' => 'password',
]);
$this->assertAuthenticatedAs($user);
$response->assertRedirect(route('setup.email.create', absolute: false));
});
test('standard users can not authenticate with username', function () {
config()->set('app.debug', false);
$user = User::factory()->create();
$this->post('/login', [
$response = $this->from('/login')->post('/login', [
'email' => $user->username,
'password' => 'password',
]);
$this->assertGuest();
$response->assertRedirect('/login');
$response->assertSessionHasErrors('email');
});
test('username-login upgrade session redirects users to setup email flow', function () {
$user = User::factory()->create([
'email' => 'legacy-user@users.skinbase.org',
'onboarding_step' => null,
]);
$response = $this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->get('/dashboard');
$response->assertRedirect(route('setup.email.create', absolute: false));
});
test('users can not authenticate with invalid password', function () {
config()->set('app.debug', false);
$user = User::factory()->create();
$response = $this->from('/login')->post('/login', [
'email' => $user->email,
'password' => 'wrong-password',
]);
$this->assertGuest();
$response->assertRedirect('/login');
$response->assertSessionHasErrors('email');
});
test('users can logout', function () {

View File

@@ -53,3 +53,13 @@ it('allows complete onboarding user to access profile and upload', function () {
->get('/upload')
->assertOk();
});
it('allows legacy users with null onboarding step to continue without setup redirect', function () {
$user = User::factory()->create([
'onboarding_step' => null,
]);
$this->actingAs($user)
->get('/upload')
->assertOk();
});

View File

@@ -30,7 +30,9 @@ it('throttles excessive registration attempts by ip', function () {
for ($i = 0; $i < 2; $i++) {
$this->post('/register', [
'email' => 'user-rate-' . $i . '@example.com',
])->assertRedirect('/register/notice');
])->assertRedirect('/setup/password');
auth()->logout();
}
$this->post('/register', [
@@ -94,20 +96,23 @@ it('shows turnstile when ip is in rate-limited state', function () {
it('enforces verification email cooldown per address', function () {
Queue::fake();
$this->post('/register', [
$first = $this->post('/register', [
'email' => 'cooldown2@example.com',
])->assertRedirect('/register/notice');
]);
$first->assertRedirect('/setup/password');
auth()->logout();
$response = $this->post('/register', [
'email' => 'cooldown2@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class, 1);
$response->assertRedirect('/setup/password');
$response->assertSessionHas('status', 'Continue with password setup.');
Queue::assertNothingPushed();
});
it('returns generic success for existing verified emails (anti-enumeration)', function () {
it('rejects registration for existing completed emails', function () {
Queue::fake();
User::factory()->create([
@@ -117,12 +122,12 @@ it('returns generic success for existing verified emails (anti-enumeration)', fu
'is_active' => true,
]);
$response = $this->post('/register', [
$response = $this->from('/register')->post('/register', [
'email' => 'existing@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
$response->assertRedirect('/register');
$response->assertSessionHasErrors('email');
Queue::assertNothingPushed();
});
@@ -151,6 +156,7 @@ it('still allows registration when turnstile passes', function () {
'cf-turnstile-response' => 'good-token',
]);
$response->assertRedirect('/register/notice');
$response->assertRedirect('/setup/password');
$this->assertDatabaseHas('users', ['email' => 'captcha-pass@example.com']);
Queue::assertNothingPushed();
});

View File

@@ -2,35 +2,21 @@
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\DB;
use App\Jobs\SendVerificationEmailJob;
uses(RefreshDatabase::class);
it('completes happy path registration onboarding flow', function () {
Queue::fake();
$register = $this->post('/register', [
'email' => 'flow-user@example.com',
]);
$register->assertRedirect('/register/notice');
$register->assertRedirect('/setup/password');
$user = User::query()->where('email', 'flow-user@example.com')->firstOrFail();
expect($user->onboarding_step)->toBe('email');
$token = null;
Queue::assertPushed(SendVerificationEmailJob::class, function (SendVerificationEmailJob $job) use (&$token) {
$token = $job->token;
return true;
});
$this->get('/verify/' . $token)->assertRedirect('/setup/password');
$user->refresh();
expect($user->onboarding_step)->toBe('verified');
expect($user->email_verified_at)->toBeNull();
$this->assertAuthenticatedAs($user);
$this->actingAs($user)
->post('/setup/password', [
@@ -79,8 +65,6 @@ it('rejects expired verification token', function () {
});
it('rejects duplicate email at registration', function () {
Queue::fake();
User::factory()->create([
'email' => 'duplicate-check@example.com',
'email_verified_at' => now(),
@@ -88,13 +72,12 @@ it('rejects duplicate email at registration', function () {
'is_active' => true,
]);
$response = $this->post('/register', [
$response = $this->from('/register')->post('/register', [
'email' => 'duplicate-check@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertNothingPushed();
$response->assertRedirect('/register');
$response->assertSessionHasErrors('email');
});
it('rejects username conflict during username setup', function () {

View File

@@ -8,13 +8,8 @@ use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('shows registration notice with email after first step', function () {
Queue::fake();
$this->post('/register', [
'email' => 'notice@example.com',
])->assertRedirect('/register/notice');
$this->get('/register/notice')
$this->withSession(['registration_email' => 'notice@example.com'])
->get('/register/notice')
->assertOk()
->assertSee('notice@example.com')
->assertSee('Change email');
@@ -29,9 +24,12 @@ it('prefills register form email from query string', function () {
it('blocks resend while cooldown is active', function () {
Queue::fake();
$this->post('/register', [
User::factory()->create([
'email' => 'cooldown@example.com',
])->assertRedirect('/register/notice');
'email_verified_at' => null,
'onboarding_step' => 'email',
'last_verification_sent_at' => now(),
]);
$response = $this->from('/register/notice')->post('/register/resend-verification', [
'email' => 'cooldown@example.com',
@@ -41,26 +39,24 @@ it('blocks resend while cooldown is active', function () {
$response->assertSessionHasNoErrors();
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class, 1);
Queue::assertNothingPushed();
});
it('resends verification after cooldown expires', function () {
Queue::fake();
$this->post('/register', [
User::factory()->create([
'email' => 'resend@example.com',
])->assertRedirect('/register/notice');
$user = User::query()->where('email', 'resend@example.com')->firstOrFail();
$user->forceFill([
'email_verified_at' => null,
'onboarding_step' => 'email',
'last_verification_sent_at' => now()->subMinutes(31),
])->save();
]);
$this->post('/register/resend-verification', [
'email' => 'resend@example.com',
])->assertRedirect('/register/notice');
Queue::assertPushed(SendVerificationEmailJob::class, 2);
Queue::assertPushed(SendVerificationEmailJob::class, 1);
expect(User::query()->where('email', 'resend@example.com')->exists())->toBeTrue();
});

View File

@@ -23,9 +23,9 @@ it('returns generic success even when quota is exceeded', function () {
'email' => 'quota-hit@example.com',
]);
$response->assertRedirect('/register/notice');
$response->assertSessionHas('status', 'If that email is valid, we sent a verification link.');
Queue::assertPushed(SendVerificationEmailJob::class);
$response->assertRedirect('/setup/password');
$response->assertSessionHas('status', 'Continue with password setup.');
Queue::assertNothingPushed();
});
it('blocks actual send in job when monthly quota is exceeded', function () {

View File

@@ -1,6 +1,6 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
test('registration screen can be rendered', function () {
@@ -23,18 +23,19 @@ test('new users can register', function () {
'email' => 'test@example.com',
]);
$this->assertGuest();
$response->assertRedirect(route('register.notice', absolute: false));
$user = User::query()->where('email', 'test@example.com')->firstOrFail();
$this->assertAuthenticatedAs($user);
$response->assertRedirect(route('setup.password.create', absolute: false));
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
'onboarding_step' => 'email',
'is_active' => 0,
'onboarding_step' => 'verified',
'is_active' => 1,
'needs_password_reset' => 1,
]);
$this->assertDatabaseHas('user_verification_tokens', [
'user_id' => (int) \App\Models\User::query()->where('email', 'test@example.com')->value('id'),
]);
Queue::assertPushed(SendVerificationEmailJob::class);
expect($user->email_verified_at)->toBeNull();
$this->assertDatabaseCount('user_verification_tokens', 0);
Queue::assertNothingPushed();
});

View File

@@ -1,6 +1,5 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
@@ -9,29 +8,19 @@ use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('stores verification tokens hashed instead of raw token', function () {
it('registration no longer creates verification tokens', function () {
Queue::fake();
$this->post('/register', [
$response = $this->post('/register', [
'email' => 'token-hash@example.com',
])->assertRedirect('/register/notice');
$rawToken = null;
Queue::assertPushed(SendVerificationEmailJob::class, function (SendVerificationEmailJob $job) use (&$rawToken) {
$rawToken = $job->token;
return true;
});
]);
$userId = (int) User::query()->where('email', 'token-hash@example.com')->value('id');
$column = Schema::hasColumn('user_verification_tokens', 'token_hash') ? 'token_hash' : 'token';
$storedToken = (string) DB::table('user_verification_tokens')
->where('user_id', $userId)
->value($column);
$response->assertRedirect('/setup/password');
expect($rawToken)->not->toBeNull();
expect($storedToken)->toBe(hash('sha256', (string) $rawToken));
expect($storedToken)->not->toBe((string) $rawToken);
expect($userId)->toBeGreaterThan(0);
expect(DB::table('user_verification_tokens')->where('user_id', $userId)->count())->toBe(0);
Queue::assertNothingPushed();
});
it('verifies token and redirects to password setup', function () {

View File

@@ -1,7 +1,7 @@
<?php
use App\Jobs\SendVerificationEmailJob;
use App\Mail\RegistrationVerificationMail;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
@@ -30,16 +30,20 @@ it('registration email contains verification link expiry and support url', funct
expect($html)->toContain('https://skinbase.example/support');
});
it('registration endpoint queues verification email job', function () {
it('registration endpoint skips verification email job and continues onboarding immediately', function () {
Queue::fake();
$this->post('/register', [
$response = $this->post('/register', [
'email' => 'mail-test@example.com',
])->assertRedirect('/register/notice');
]);
Queue::assertPushed(SendVerificationEmailJob::class);
$user = User::query()->where('email', 'mail-test@example.com')->firstOrFail();
$this->assertAuthenticatedAs($user);
$response->assertRedirect('/setup/password');
Queue::assertNothingPushed();
$this->assertDatabaseHas('users', [
'email' => 'mail-test@example.com',
'onboarding_step' => 'email',
'onboarding_step' => 'verified',
]);
});

View File

@@ -0,0 +1,98 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
uses(RefreshDatabase::class);
it('requires authentication to open setup email screen', function () {
$this->get('/setup/email')
->assertRedirect('/login');
});
it('renders setup email screen for authenticated user', function () {
$user = User::factory()->create([
'onboarding_step' => null,
]);
$this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->get('/setup/email')
->assertOk()
->assertSee('Add Your Email')
->assertSee('Continue');
});
it('saves email during setup email step and moves to password setup when reset is required', function () {
Mail::fake();
$user = User::factory()->create([
'email' => 'legacy-user@users.skinbase.org',
'onboarding_step' => null,
'needs_password_reset' => true,
]);
$response = $this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->post('/setup/email', [
'email' => 'upgraded@example.com',
]);
$response->assertRedirect('/setup/password');
$response->assertSessionHas('status', 'Email saved. Continue with password setup.');
$user->refresh();
expect($user->email)->toBe('upgraded@example.com');
expect($user->onboarding_step)->toBe('verified');
expect($user->email_verified_at)->toBeNull();
Mail::assertNothingQueued();
});
it('saves email during setup email step and moves to username step when password reset is not required', function () {
Mail::fake();
$user = User::factory()->create([
'email' => 'legacy-user@users.skinbase.org',
'email_verified_at' => null,
'onboarding_step' => null,
'needs_password_reset' => false,
]);
$response = $this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->post('/setup/email', [
'email' => 'upgraded@example.com',
]);
$response->assertRedirect('/setup/username');
$user->refresh();
expect($user->email)->toBe('upgraded@example.com');
expect($user->onboarding_step)->toBe('password');
expect($user->email_verified_at)->toBeNull();
Mail::assertNothingQueued();
});
it('redirects username-login upgrade users to username step after email save', function () {
$user = User::factory()->create([
'onboarding_step' => 'password',
]);
$this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->get('/dashboard')
->assertRedirect('/setup/username');
});
it('redirects username-login upgrade users to password step when reset is still required', function () {
$user = User::factory()->create([
'onboarding_step' => 'verified',
]);
$this->actingAs($user)
->withSession(['username_login_upgrade' => true])
->get('/dashboard')
->assertRedirect('/setup/password');
});