Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ AWS_BUCKET=northwestern-laravel-starter
LOCAL_AUTH_ENABLED=true
API_ENABLED=true
SUPPORT_ENABLED=true
CHANGELOG_ENABLED=true

WILDCARD_PHOTO_SYNC_ENABLED=true
EVENT_HUB_MOCK_ENABLED=true
Expand Down
2 changes: 1 addition & 1 deletion .github/coverage-with-base.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 📊 Code Coverage Report
## 📊 Code Coverage Report

{{#with overall_coverage}}
| Metric | Base | Current | Difference |
Expand Down
2 changes: 1 addition & 1 deletion .github/coverage-without-base.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# 📊 Code Coverage Report
## 📊 Code Coverage Report

{{#with overall_coverage}}
| Metric | Coverage |
Expand Down
4 changes: 0 additions & 4 deletions app/Console/Commands/AutoSeedListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,6 @@ private function outputTable(array $seeders): void
*/
private function formatDependencies(array $dependencies): string
{
if (count($dependencies) === 0) {
return '<fg=gray>none</>';
}

if (count($dependencies) === 1) {
return "<fg=yellow>{$dependencies[0]}</>";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,6 @@ private function processToken(AccessToken $token, int $daysUntilExpiration): voi
{
$user = $token->user;

if (! $user) {
$this->components->warn("Skipping token #{$token->id}: user no longer exists");

return;
}

$this->line("⏳ Processing token for {$user->username} ({$user->email})");

Mail::to($user->email)->queue(
Expand Down
10 changes: 1 addition & 9 deletions app/Domains/Core/Services/IdempotentSeederResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,6 @@ private function expandGlobPatterns(array $patterns): Collection
*/
private function scanDirectory(string $path): Collection
{
if (! is_dir($path)) {
return collect();
}

/** @var Collection<int, SeederClass> */
return collect(File::allFiles($path))
->filter(fn (SplFileInfo $file): bool => $file->getExtension() === 'php')
Expand Down Expand Up @@ -154,7 +150,7 @@ private function extractFullyQualifiedClassName(SplFileInfo $file): ?string
*/
private function parseNamespaceFromFile(string $filePath): ?string
{
$handle = fopen($filePath, 'rb');
$handle = @fopen($filePath, 'rb');

if ($handle === false) {
return null;
Expand Down Expand Up @@ -221,10 +217,6 @@ private function extractSeederMetadata(string $className): ?SeederInfo
{
$reflection = new ReflectionClass($className);

if ($reflection->isAbstract()) {
return null;
}

$attributes = $reflection->getAttributes(AutoSeed::class);

if (blank($attributes)) {
Expand Down
18 changes: 9 additions & 9 deletions app/Domains/User/Actions/PersistUserWithUniqueUsername.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ final class PersistUserWithUniqueUsername
{
public function __invoke(User $user): User
{
return DB::transaction(function () use ($user) {
try {
try {
return DB::transaction(function () use ($user) {
$user->save();

if ($user->auth_type === AuthTypeEnum::SSO) {
Expand All @@ -35,12 +35,12 @@ public function __invoke(User $user): User
}

return $user;
} catch (UniqueConstraintViolationException) {
return User::query()
->where('auth_type', $user->auth_type)
->where('username', $user->username)
->firstOrFail();
}
});
});
} catch (UniqueConstraintViolationException) {
return User::query()
->where('auth_type', $user->auth_type)
->where('username', $user->username)
->firstOrFail();
}
}
}
6 changes: 6 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
<directory>app/Domains/Auth/Enums</directory>
<directory>app/Domains/Core/Enums</directory>
<directory>app/Domains/Support/Enums</directory>
<file>app/Domains/User/Enums/AffiliationEnum.php</file>
<file>app/Domains/User/Enums/NetIdUpdateActionEnum.php</file>
<file>app/Domains/User/Enums/UserSegmentEnum.php</file>

<!-- Models (casts, relationships, property declarations) -->
<directory>app/Domains/Auth/Models</directory>
Expand Down Expand Up @@ -86,8 +89,11 @@
<file>app/Http/Controllers/Controller.php</file>
<file>app/Http/Controllers/Api/BaseApiController.php</file>
<file>app/Http/Controllers/Api/V1/ApiController.php</file>
<file>app/View/Components/Breadcrumbs.php</file>
<file>app/View/Components/ErrorLayout.php</file>

<!-- Service Providers (bootstrap wiring, no domain logic) -->
<file>app/Providers/AppServiceProvider.php</file>
<file>app/Providers/EloquentServiceProvider.php</file>
<file>app/Providers/FilamentServiceProvider.php</file>
<file>app/Providers/Filament/AdministrationPanelProvider.php</file>
Expand Down
14 changes: 14 additions & 0 deletions tests/Feature/Commands/AutoSeedListCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,20 @@ public function test_outputs_table_and_hint_by_default(): void
$this->assertStringContainsString('Use the --show-dependencies option', $output);
}

public function test_dependency_tree_shows_no_dependencies_marker(): void
{
$seederInfo = new SeederInfo(className: 'App\\Seeders\\StandaloneSeeder', dependsOn: []); // @phpstan-ignore argument.type

$this->mock(IdempotentSeederResolver::class, function (MockInterface $mock) use ($seederInfo) {
$mock->shouldReceive('discover')->once()->andReturn([$seederInfo]);
});

$this->artisan('db:seed:list', ['--show-dependencies' => true])
->expectsOutputToContain('Dependency Tree:')
->expectsOutputToContain('(no dependencies)')
->assertExitCode(0);
}

public function test_outputs_error_on_exception(): void
{
$this->mock(IdempotentSeederResolver::class, function (MockInterface $mock) {
Expand Down
45 changes: 45 additions & 0 deletions tests/Feature/Domains/Auth/Actions/Api/RotateAccessTokenTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
use App\Domains\Auth\Models\AccessToken;
use App\Domains\User\Models\User;
use Auth;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;

Expand Down Expand Up @@ -40,4 +42,47 @@ public function test_it_rotates_a_token(): void
$this->assertNotNull($oldToken->revoked_at);
$this->assertNull($newToken->revoked_at);
}

public function test_it_throws_when_no_rotating_user_is_provided_and_no_auth_session_exists(): void
{
$user = User::factory()->api()->create();
[, $oldToken] = new IssueAccessToken()($user, 'Test');

Auth::logout();

$rotator = new RotateAccessToken(new IssueAccessToken());

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('A user performing the rotation must be provided or an authenticated session must be active.');

$rotator($oldToken, 'Rotated Token');
}

public function test_it_propagates_expires_at_and_allowed_ips_to_the_new_token(): void
{
$user = User::factory()->api()->create();
[$oldTokenString, $oldToken] = new IssueAccessToken()($user, 'Original');

$rotatedBy = User::factory()->api()->create();
$expiresAt = CarbonImmutable::parse('2030-01-01 00:00:00');
$allowedIps = ['10.0.0.1', '192.168.1.0/24'];

$rotator = new RotateAccessToken(new IssueAccessToken());
$newTokenString = $rotator(
previousAccessToken: $oldToken,
name: 'Rotated',
rotatedBy: $rotatedBy,
expiresAt: $expiresAt,
allowedIps: $allowedIps,
);

$this->assertNotSame($oldTokenString, $newTokenString);

$newToken = AccessToken::where('token_hash', AccessToken::hashFromPlain($newTokenString))->first();

$this->assertNotNull($newToken);
$this->assertTrue($newToken->expires_at?->equalTo($expiresAt));
$this->assertSame($allowedIps, $newToken->allowed_ips);
$this->assertSame($rotatedBy->id, $newToken->rotated_by_user_id);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,22 @@ public function test_resend_returns_404_when_disabled(): void

$response->assertNotFound();
}

public function test_send_returns_validation_error_when_challenge_issue_fails(): void
{
config(['local-auth.rate_limit_per_hour' => 1]);
User::factory()->affiliate()->create(['email' => 'test@example.com']);

RateLimiter::hit('login-code:test@example.com', 3600);

$response = $this->post(route('login-code.send'), [
'email' => 'test@example.com',
]);

$response->assertSessionHasErrors('email');
$this->assertStringContainsString(
'Too many login attempts',
(string) session('errors')->first('email')
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,38 @@ public function test_handles_non_numeric_challenge_id_without_database_error():
$response->assertOk();
$response->assertViewIs('auth.login-code');
}

public function test_clears_challenge_when_encrypted_id_cannot_be_decrypted(): void
{
$email = 'test@example.com';

$response = $this->withSession([
LoginCodeSession::EMAIL => $email,
LoginCodeSession::CHALLENGE_ID => 'invalid-ciphertext',
])->get(route('login-code.code'));

$response->assertOk();
$response->assertViewIs('auth.login-code');
$response->assertSessionMissing(LoginCodeSession::CHALLENGE_ID);
}

public function test_clears_consumed_challenge_id(): void
{
$user = User::factory()->affiliate()->create(['email' => 'consumed@example.com']);
$consumedChallenge = LoginChallenge::create([
'email' => $user->email,
'code_hash' => Hash::make('123456'),
'expires_at' => now()->addMinutes(10),
'consumed_at' => now()->subMinute(),
]);

$response = $this->withSession([
LoginCodeSession::EMAIL => $user->email,
LoginCodeSession::CHALLENGE_ID => Crypt::encryptString((string) $consumedChallenge->id),
])->get(route('login-code.code'));

$response->assertOk();
$response->assertViewIs('auth.login-code');
$response->assertSessionMissing(LoginCodeSession::CHALLENGE_ID);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,85 @@ public function test_handles_non_numeric_challenge_id_without_database_error():
$response->assertSessionHasErrors('code');
$this->assertGuest();
}

public function test_returns_error_when_challenge_id_is_missing_from_session(): void
{
$response = $this->post(route('login-code.verify'), ['code' => '123456']);

$response->assertSessionHasErrors('code');
$this->assertGuest();
}

public function test_clears_session_and_returns_error_when_challenge_id_cannot_be_decrypted(): void
{
$response = $this->withSession([
LoginCodeSession::EMAIL => 'test@example.com',
LoginCodeSession::CHALLENGE_ID => 'invalid-ciphertext',
])->post(route('login-code.verify'), ['code' => '123456']);

$response->assertSessionHasErrors('code');
$response->assertSessionMissing(LoginCodeSession::CHALLENGE_ID);
$this->assertGuest();
}

public function test_returns_lockout_error_when_challenge_is_locked(): void
{
config(['local-auth.code.lock_minutes' => 15]);

$user = User::factory()->affiliate()->create(['email' => 'locked@example.com']);
$challenge = LoginChallenge::create([
'email' => $user->email,
'code_hash' => Hash::make('123456'),
'expires_at' => now()->addMinutes(10),
'locked_until' => now()->addMinutes(5),
]);

$response = $this->withSession([
LoginCodeSession::EMAIL => $user->email,
LoginCodeSession::CHALLENGE_ID => Crypt::encryptString((string) $challenge->id),
])->post(route('login-code.verify'), ['code' => '123456']);

$response->assertSessionHasErrors('code');
$this->assertStringContainsString('Too many attempts', (string) session('errors')->first('code'));
$this->assertGuest();
}

public function test_returns_invalid_code_when_challenge_user_no_longer_exists(): void
{
$challenge = LoginChallenge::create([
'email' => 'missing-user@example.com',
'code_hash' => Hash::make('123456'),
'expires_at' => now()->addMinutes(10),
]);

$response = $this->withSession([
LoginCodeSession::EMAIL => 'missing-user@example.com',
LoginCodeSession::CHALLENGE_ID => Crypt::encryptString((string) $challenge->id),
])->post(route('login-code.verify'), ['code' => '123456']);

$response->assertSessionHasErrors('code');
$this->assertGuest();
}

public function test_valid_code_marks_email_as_verified_when_missing(): void
{
$user = User::factory()->affiliate()->create([
'email' => 'unverified@example.com',
'email_verified_at' => null,
]);

$challenge = LoginChallenge::create([
'email' => $user->email,
'code_hash' => Hash::make('123456'),
'expires_at' => now()->addMinutes(10),
]);

$response = $this->withSession([
LoginCodeSession::EMAIL => $user->email,
LoginCodeSession::CHALLENGE_ID => Crypt::encryptString((string) $challenge->id),
])->post(route('login-code.verify'), ['code' => '123456']);

$response->assertRedirect('/');
$this->assertNotNull($user->fresh()->email_verified_at);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,30 @@ public function test_token_with_mixed_ipv4_and_ipv6_restrictions(): void
]);
}

public function test_null_request_ip_with_ip_restrictions_rejects_and_reports(): void
{
$user = User::factory()->api()->create();
[$plainToken, $token] = $this->issueToken($user, [
'allowed_ips' => ['192.168.1.100'],
]);

$this->withServerVariables(['REMOTE_ADDR' => ''])
->getJson($this->endpoint, $this->bearerHeader($plainToken))
->assertUnauthorized()
->assertJson([
'type' => 'about:blank',
'title' => 'Unauthorized',
'status' => 401,
'detail' => 'Authentication failed',
'instance' => '/api/test',
]);

$this->assertSame(
ApiRequestFailureEnum::IP_DENIED->value,
Context::get(ApiRequestContext::FAILURE_REASON),
);
}

public function test_ip_restriction_does_not_prevent_last_used_update_on_failure(): void
{
$user = User::factory()->api()->create();
Expand Down
Loading