Save workspace changes
This commit is contained in:
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
98
tests/Feature/Auth/SetupEmailTest.php
Normal file
98
tests/Feature/Auth/SetupEmailTest.php
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user