seed(RolePermissionSeeder::class); } #[Test] public function it_exchanges_credentials_for_a_token_and_provisions_the_user(): void { $this->fakePireon(); $response = $this->postJson('/api/v1/auth/token', [ 'grant_type' => 'password', 'username' => 'ada@example.com', 'password' => 'secret', ]); $response->assertOk() ->assertJsonPath('access_token', 'fake-access-token') ->assertJsonPath('user.email', 'ada@example.com') ->assertJsonPath('user.roles.0', config('pireon.default_role')); $this->assertDatabaseHas('users', ['oauth_id' => 4242]); // The password grant must reach the identity server, never a local // password column. Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token') && $request['grant_type'] === 'password'); } #[Test] public function it_does_not_leak_why_a_credential_was_rejected(): void { Http::fake([ "{$this->pireonServer}/oauth/token" => Http::response([ 'error' => 'invalid_grant', 'error_description' => 'The user credentials were incorrect.', ], 400), ]); $response = $this->postJson('/api/v1/auth/token', [ 'grant_type' => 'password', 'username' => 'ada@example.com', 'password' => 'wrong', ]); $response->assertStatus(422); $this->assertStringNotContainsString('credentials were incorrect', $response->content()); } #[Test] public function it_refuses_an_account_awaiting_approval(): void { $this->fakePireon(identity: ['approved_at' => null]); $this->postJson('/api/v1/auth/token', [ 'grant_type' => 'password', 'username' => 'ada@example.com', 'password' => 'secret', ]) ->assertForbidden() ->assertJsonPath('message', __('pireon.pending_approval')); $this->assertDatabaseCount('users', 0); } #[Test] public function the_password_grant_can_be_disabled(): void { config()->set('pireon.password_grant_enabled', false); $this->postJson('/api/v1/auth/token', [ 'grant_type' => 'password', 'username' => 'ada@example.com', 'password' => 'secret', ])->assertStatus(422); } #[Test] public function it_validates_the_grant_type(): void { $this->postJson('/api/v1/auth/token', ['grant_type' => 'implicit']) ->assertUnprocessable() ->assertJsonValidationErrors('grant_type'); } #[Test] public function one_account_exhausting_its_budget_does_not_lock_out_another_on_the_same_address(): void { // Everyone the MCP server signs in arrives from its one container. // Registered first so it wins over fakePireon()'s token stub, which // would otherwise accept every password. Http::fake(["{$this->pireonServer}/oauth/token" => fn ($request) => $request['username'] === 'ada@example.com' ? Http::response(['access_token' => 'fake-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600]) : Http::response(['error' => 'invalid_grant'], 400)]); $this->fakePireon(); foreach (range(1, 6) as $attempt) { $this->postJson('/api/v1/auth/token', [ 'grant_type' => 'password', 'username' => 'mallory@example.com', 'password' => "guess-{$attempt}", ])->assertStatus(422); } $this->postJson('/api/v1/auth/token', [ 'grant_type' => 'password', 'username' => 'MALLORY@example.com', 'password' => 'guess-7', ])->assertTooManyRequests(); $this->postJson('/api/v1/auth/token', [ 'grant_type' => 'password', 'username' => 'ada@example.com', 'password' => 'secret', ])->assertOk(); } #[Test] public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void { $user = User::factory()->create(); $user->syncRoles(['viewer']); $headers = $this->apiHeadersFor($user); $this->getJson('/api/v1/me', $headers)->assertOk(); $this->getJson('/api/v1/me', $headers)->assertOk(); $this->getJson('/api/v1/me', $headers)->assertOk(); // Introspection is cached, so three requests cost one upstream call. Http::assertSentCount(1); } }