create(['activated' => 1])); $this->getJson(route('api.users.me'))->assertOk(); } public function test_deactivated_user_api_token_is_refused(): void { Passport::actingAs(User::factory()->create(['activated' => 0])); $this->getJson(route('api.users.me'))->assertUnauthorized(); } public function test_deactivated_user_cannot_write_via_api(): void { Passport::actingAs(User::factory()->superuser()->create(['activated' => 0])); $this->postJson(route('api.users.store'), [ 'first_name' => 'Should', 'last_name' => 'Not Persist', 'username' => 'shouldnotpersist', 'password' => 'This-Is-A-Long-Password-123!', 'password_confirmation' => 'This-Is-A-Long-Password-123!', ])->assertUnauthorized(); $this->assertDatabaseMissing('users', ['username' => 'shouldnotpersist']); } /** * Escalation regression pin from the researcher's report: a * deactivated account with users.edit used to be able to * PATCH /api/v1/users/{own_id} {"activated": 1} and re-enable * itself, permanently defeating the deactivation. The middleware * now refuses the request before it reaches the controller. */ public function test_deactivated_admin_cannot_self_reactivate_via_api(): void { $user = User::factory()->admin()->create(['activated' => 0]); Passport::actingAs($user); $this->patchJson(route('api.users.update', $user), [ 'activated' => 1, ])->assertUnauthorized(); $this->assertSame(0, (int) $user->fresh()->activated, 'Account must remain deactivated'); } /** * Response body deliberately does NOT distinguish "known token, * deactivated account" from a generic 401. Locks in the generic * message so a later change doesn't leak the specific reason (which * would help an attacker validate that a token they hold is * otherwise correct and just points at a disabled account). */ public function test_deactivated_response_is_generic_unauthorized(): void { Passport::actingAs(User::factory()->create(['activated' => 0])); $response = $this->getJson(route('api.users.me'))->assertUnauthorized(); $body = $response->json(); $this->assertSame('error', $body['status'] ?? null); // The generic 'general.unauthorized' translation; must NOT // mention "activated" / "disabled" / "inactive". $this->assertStringNotContainsStringIgnoringCase('activat', (string) ($body['messages'] ?? '')); $this->assertStringNotContainsStringIgnoringCase('disabled', (string) ($body['messages'] ?? '')); } /** * Rollbar regression pin: a Python client hitting /api/v1/hardware * with a bearer token but without an Accept: application/json * header used to fatal with BadMethodCallException. The middleware * fell past its expectsJson() check (Python's requests library * doesn't set Accept by default) and called Auth::logout() on * Passport's TokenGuard, which doesn't define that method. * * A bearer token is now a sufficient signal that the caller is on * the API surface, so the middleware returns a JSON 401 instead * of trying to terminate a session that doesn't exist. */ public function test_bearer_authenticated_request_without_accept_header_returns_json_not_500(): void { Passport::actingAs(User::factory()->create(['activated' => 0])); $response = $this ->withHeader('Authorization', 'Bearer fake-token-value') ->get(route('api.assets.index')); $response->assertUnauthorized(); $this->assertSame('error', $response->json('status')); } }