Skip to content

Commit 9e3442b

Browse files
test: convert magic link tests to stateful single-use token flow
The old tests exercised signed-URL redemption, which 1fc7a47 replaced with stateful tokens. Redeem real tokens via MagicLinkManager, and add coverage for single-use replay and previous-link invalidation.
1 parent f0e03b1 commit 9e3442b

2 files changed

Lines changed: 54 additions & 48 deletions

File tree

tests/Feature/Auth/MagicLinkTest.php

Lines changed: 50 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
use Illuminate\Foundation\Testing\RefreshDatabase;
66
use Illuminate\Support\Facades\Mail;
7-
use Illuminate\Support\Facades\URL;
87
use Ssntpl\Neev\Mail\LoginUsingLink;
98
use Ssntpl\Neev\Models\User;
9+
use Ssntpl\Neev\Services\MagicLink\MagicLinkManager;
1010
use Ssntpl\Neev\Tests\TestCase;
1111
use Ssntpl\Neev\Tests\Traits\WithNeevConfig;
1212

@@ -20,6 +20,14 @@ private function createUser(array $state = []): User
2020
return User::factory()->create($state);
2121
}
2222

23+
/**
24+
* Generate a magic link for the user and return its plain token.
25+
*/
26+
private function magicLinkToken(User $user): array
27+
{
28+
return app(MagicLinkManager::class)->forWeb($user);
29+
}
30+
2331
// -----------------------------------------------------------------
2432
// POST /neev/sendLoginLink
2533
// -----------------------------------------------------------------
@@ -64,17 +72,13 @@ public function test_send_login_link_returns_401_for_non_existent_email(): void
6472
// GET /neev/loginUsingLink
6573
// -----------------------------------------------------------------
6674

67-
public function test_login_using_link_with_valid_signature_returns_token(): void
75+
public function test_login_using_link_with_valid_token_returns_token(): void
6876
{
6977
$user = $this->createUser();
7078

71-
$signedUrl = URL::temporarySignedRoute(
72-
'loginUsingLink',
73-
now()->addMinutes(60),
74-
['id' => $user->id]
75-
);
79+
$link = $this->magicLinkToken($user);
7680

77-
$response = $this->getJson($signedUrl);
81+
$response = $this->getJson('/neev/loginUsingLink?token=' . $link['token']);
7882

7983
$response->assertOk();
8084
$response->assertJson([
@@ -90,13 +94,9 @@ public function test_login_using_link_returns_email_verified_status(): void
9094
$user = $this->createUser();
9195

9296
// Email is verified by default from factory
93-
$signedUrl = URL::temporarySignedRoute(
94-
'loginUsingLink',
95-
now()->addMinutes(60),
96-
['id' => $user->id]
97-
);
97+
$link = $this->magicLinkToken($user);
9898

99-
$response = $this->getJson($signedUrl);
99+
$response = $this->getJson('/neev/loginUsingLink?token=' . $link['token']);
100100

101101
$response->assertOk();
102102
$response->assertJson([
@@ -105,50 +105,63 @@ public function test_login_using_link_returns_email_verified_status(): void
105105
]);
106106
}
107107

108-
public function test_login_using_link_with_invalid_signature_returns_403(): void
108+
public function test_login_using_link_with_invalid_token_returns_403(): void
109109
{
110-
$user = $this->createUser();
111-
112-
// Build a URL without a valid signature
113-
$url = route('loginUsingLink', ['id' => $user->id]) . '?signature=invalidsignature';
110+
$this->createUser();
114111

115-
$response = $this->getJson($url);
112+
$response = $this->getJson('/neev/loginUsingLink?token=not-a-real-token');
116113

117114
$response->assertStatus(403);
118115
$response->assertJson([
119116
'message' => 'Invalid or expired verification link.',
120117
]);
121118
}
122119

123-
public function test_login_using_link_with_expired_signature_returns_403(): void
120+
public function test_login_using_link_with_expired_token_returns_403(): void
124121
{
125122
$user = $this->createUser();
126123

127-
$signedUrl = URL::temporarySignedRoute(
128-
'loginUsingLink',
129-
now()->subMinutes(1), // Already expired
130-
['id' => $user->id]
131-
);
124+
$link = $this->magicLinkToken($user);
125+
$link['model']->forceFill(['expires_at' => now()->subMinute()])->save();
132126

133-
$response = $this->getJson($signedUrl);
127+
$response = $this->getJson('/neev/loginUsingLink?token=' . $link['token']);
134128

135129
$response->assertStatus(403);
136130
$response->assertJson([
137131
'message' => 'Invalid or expired verification link.',
138132
]);
139133
}
140134

135+
public function test_login_using_link_is_single_use(): void
136+
{
137+
$user = $this->createUser();
138+
139+
$link = $this->magicLinkToken($user);
140+
141+
$this->getJson('/neev/loginUsingLink?token=' . $link['token'])->assertOk();
142+
143+
// Replay: the token row was deleted on consumption.
144+
$this->getJson('/neev/loginUsingLink?token=' . $link['token'])->assertStatus(403);
145+
}
146+
147+
public function test_generating_a_new_link_invalidates_the_previous_one(): void
148+
{
149+
$user = $this->createUser();
150+
151+
$old = $this->magicLinkToken($user);
152+
$new = $this->magicLinkToken($user);
153+
154+
$this->getJson('/neev/loginUsingLink?token=' . $old['token'])->assertStatus(403);
155+
$this->getJson('/neev/loginUsingLink?token=' . $new['token'])->assertOk();
156+
}
157+
141158
public function test_login_using_link_creates_login_attempt(): void
142159
{
143160
$user = $this->createUser();
144161

145-
$signedUrl = URL::temporarySignedRoute(
146-
'loginUsingLink',
147-
now()->addMinutes(60),
148-
['id' => $user->id]
149-
);
162+
$link = $this->magicLinkToken($user);
150163

151-
$this->getJson($signedUrl);
164+
$this->getJson('/neev/loginUsingLink?token=' . $link['token']);
152165

153166
$this->assertDatabaseHas('login_attempts', [
154167
'user_id' => $user->id,
@@ -161,13 +174,9 @@ public function test_login_using_link_creates_access_token(): void
161174
{
162175
$user = $this->createUser();
163176

164-
$signedUrl = URL::temporarySignedRoute(
165-
'loginUsingLink',
166-
now()->addMinutes(60),
167-
['id' => $user->id]
168-
);
177+
$link = $this->magicLinkToken($user);
169178

170-
$this->getJson($signedUrl);
179+
$this->getJson('/neev/loginUsingLink?token=' . $link['token']);
171180

172181
$this->assertDatabaseHas('access_tokens', [
173182
'user_id' => $user->id,
@@ -179,13 +188,9 @@ public function test_login_using_link_for_inactive_user_returns_validation_error
179188
{
180189
$user = $this->createUser(['active' => false]);
181190

182-
$signedUrl = URL::temporarySignedRoute(
183-
'loginUsingLink',
184-
now()->addMinutes(60),
185-
['id' => $user->id]
186-
);
191+
$link = $this->magicLinkToken($user);
187192

188-
$response = $this->getJson($signedUrl);
193+
$response = $this->getJson('/neev/loginUsingLink?token=' . $link['token']);
189194

190195
$response->assertUnprocessable();
191196
$response->assertJsonValidationErrors(['email']);

tests/Feature/Auth/SpaCookieLoginTest.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
namespace Ssntpl\Neev\Tests\Feature\Auth;
44

55
use Illuminate\Foundation\Testing\RefreshDatabase;
6-
use Illuminate\Support\Facades\URL;
76
use Ssntpl\Neev\Models\User;
7+
use Ssntpl\Neev\Services\MagicLink\MagicLinkManager;
88
use Ssntpl\Neev\Services\SpaCsrfToken;
99
use Ssntpl\Neev\Tests\TestCase;
1010

@@ -85,9 +85,10 @@ public function test_spa_registration_sets_cookie_and_omits_token(): void
8585
public function test_spa_magic_link_login_sets_cookie(): void
8686
{
8787
$user = User::factory()->create(['email_verified_at' => now()]);
88-
$url = URL::temporarySignedRoute('loginUsingLink', now()->addMinutes(60), ['id' => $user->id]);
88+
$link = app(MagicLinkManager::class)->forWeb($user);
8989

90-
$response = $this->withHeader('Origin', 'https://app.example.com')->getJson($url);
90+
$response = $this->withHeader('Origin', 'https://app.example.com')
91+
->getJson('/neev/loginUsingLink?token=' . $link['token']);
9192

9293
$response->assertOk();
9394
$response->assertJsonMissingPath('token');

0 commit comments

Comments
 (0)