createToken(TokenKind::Service); $userId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-disabled', disabled: true); $response = $this->request('GET', '/api/v1/admin/me', [ 'Authorization' => 'Bearer ' . $token, 'X-Acting-User-Id' => (string) $userId, ]); self::assertSame(403, $response->getStatusCode()); self::assertSame('user_disabled', $this->decode($response)['error']); } public function testEnabledUserOfSameKindStillWorksWhenAnotherIsDisabled(): void { // Sanity: disabling user A must not break impersonation for user B. $token = $this->createToken(TokenKind::Service); $disabledId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-A', disabled: true); $activeId = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-B', disabled: false); $disabled = $this->request('GET', '/api/v1/admin/me', [ 'Authorization' => 'Bearer ' . $token, 'X-Acting-User-Id' => (string) $disabledId, ]); self::assertSame(403, $disabled->getStatusCode()); $active = $this->request('GET', '/api/v1/admin/me', [ 'Authorization' => 'Bearer ' . $token, 'X-Acting-User-Id' => (string) $activeId, ]); self::assertSame(200, $active->getStatusCode()); } public function testUpsertOidcRefusesDisabledUserAndDoesNotRecomputeRole(): void { // A returning OIDC subject whose row was disabled must 403, // and the role on the (still disabled) row must remain // whatever it was — we must NOT have applied the new groups. $token = $this->createToken(TokenKind::Service); $this->db->insert('users', [ 'subject' => 'churn-disabled', 'email' => 'old@example.com', 'display_name' => 'Churned', 'role' => Role::Viewer->value, 'is_local' => 0, 'disabled_at' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format('Y-m-d H:i:s'), ]); $this->db->insert('oidc_role_mappings', [ 'group_id' => 'admin-grp', 'role' => Role::Admin->value, ]); $response = $this->request( 'POST', '/api/v1/auth/users/upsert-oidc', [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], (string) json_encode([ 'subject' => 'churn-disabled', 'email' => 'new@example.com', 'display_name' => 'Renamed', 'groups' => ['admin-grp'], ]) ); self::assertSame(403, $response->getStatusCode()); self::assertSame('user_disabled', $this->decode($response)['error']); // Role must not have been recomputed. $row = $this->db->fetchAssociative( "SELECT role, email, display_name FROM users WHERE subject = 'churn-disabled'" ); self::assertIsArray($row); self::assertSame('viewer', $row['role'], 'disabled user role must not be recomputed from new groups'); self::assertSame('old@example.com', $row['email']); self::assertSame('Churned', $row['display_name']); // No user.role_changed audit row from this denied attempt. $count = (int) $this->db->fetchOne( "SELECT COUNT(*) FROM audit_log WHERE action = 'user.role_changed'" ); self::assertSame(0, $count, 'denied login must not emit role_changed audit'); } public function testUpsertLocalRefusesWhenLocalAdminDisabled(): void { $token = $this->createToken(TokenKind::Service); // Seed the local admin row pre-disabled. $this->createUser(Role::Admin, isLocal: true, disabled: true); $response = $this->request( 'POST', '/api/v1/auth/users/upsert-local', [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], (string) json_encode(['username' => 'admin']) ); self::assertSame(403, $response->getStatusCode()); self::assertSame('user_disabled', $this->decode($response)['error']); } public function testActorViaIsLocalForServiceImpersonationOfLocalAdmin(): void { // Layer B2: any audit emitted while impersonating a local user // carries actor_via='local'; an OIDC user yields 'oidc'. $token = $this->createToken(TokenKind::Service); $localAdmin = $this->createUser(Role::Admin, isLocal: true); $oidcAdmin = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-via-oidc'); // Drive an audit-emitting write under the local admin. $this->request( 'POST', '/api/v1/admin/manual-blocks', [ 'Authorization' => 'Bearer ' . $token, 'X-Acting-User-Id' => (string) $localAdmin, 'Content-Type' => 'application/json', ], (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.1', 'reason' => 'via=local']) ); $row = $this->db->fetchAssociative( "SELECT actor_kind, actor_id, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1" ); self::assertIsArray($row); self::assertSame('user', $row['actor_kind']); self::assertSame((string) $localAdmin, $row['actor_id']); self::assertSame('local', $row['actor_via']); // Same write under the OIDC admin → actor_via='oidc'. $this->request( 'POST', '/api/v1/admin/manual-blocks', [ 'Authorization' => 'Bearer ' . $token, 'X-Acting-User-Id' => (string) $oidcAdmin, 'Content-Type' => 'application/json', ], (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.2', 'reason' => 'via=oidc']) ); $row = $this->db->fetchAssociative( "SELECT actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1" ); self::assertIsArray($row); self::assertSame('oidc', $row['actor_via']); } public function testActorViaIsAdminTokenForBareAdminToken(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $this->request( 'POST', '/api/v1/admin/manual-blocks', [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], (string) json_encode(['kind' => 'ip', 'ip' => '203.0.113.3', 'reason' => 'via=admin-token']) ); $row = $this->db->fetchAssociative( "SELECT actor_kind, actor_via FROM audit_log WHERE action = 'manual_block.created' ORDER BY id DESC LIMIT 1" ); self::assertIsArray($row); self::assertSame('admin-token', $row['actor_kind']); self::assertSame('admin-token', $row['actor_via']); } public function testAdminUsersListIncludesDisabledFlag(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $active = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-active'); $disabled = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-off', disabled: true); $response = $this->request('GET', '/api/v1/admin/users', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); $byId = []; foreach ($body['items'] as $item) { $byId[(int) $item['id']] = $item; } self::assertArrayHasKey($active, $byId); self::assertArrayHasKey($disabled, $byId); self::assertFalse($byId[$active]['disabled']); self::assertNull($byId[$active]['disabled_at']); self::assertTrue($byId[$disabled]['disabled']); self::assertNotNull($byId[$disabled]['disabled_at']); } public function testAdminPatchDisablesUserAndEmitsAudit(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'patch-target'); $response = $this->request( 'PATCH', '/api/v1/admin/users/' . $target, [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], (string) json_encode(['disabled' => true]) ); self::assertSame(200, $response->getStatusCode()); $body = $this->decode($response); self::assertTrue($body['disabled']); self::assertNotNull($body['disabled_at']); $row = $this->db->fetchAssociative( "SELECT action, target_id, actor_kind FROM audit_log WHERE action = 'user.disabled' ORDER BY id DESC LIMIT 1" ); self::assertIsArray($row); self::assertSame((string) $target, $row['target_id']); // Reverse — flipping back emits user.enabled. $back = $this->request( 'PATCH', '/api/v1/admin/users/' . $target, [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], (string) json_encode(['disabled' => false]) ); self::assertSame(200, $back->getStatusCode()); self::assertFalse($this->decode($back)['disabled']); $enabledRow = $this->db->fetchAssociative( "SELECT action FROM audit_log WHERE action = 'user.enabled' ORDER BY id DESC LIMIT 1" ); self::assertIsArray($enabledRow); } public function testAdminPatchRefusesSelfDisable(): void { $token = $this->createToken(TokenKind::Service); $self = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-self'); $response = $this->request( 'PATCH', '/api/v1/admin/users/' . $self, [ 'Authorization' => 'Bearer ' . $token, 'X-Acting-User-Id' => (string) $self, 'Content-Type' => 'application/json', ], (string) json_encode(['disabled' => true]) ); self::assertSame(409, $response->getStatusCode()); self::assertSame('cannot_disable_self', $this->decode($response)['error']); $row = $this->db->fetchAssociative( 'SELECT disabled_at FROM users WHERE id = :id', ['id' => $self] ); self::assertIsArray($row); self::assertNull($row['disabled_at']); } public function testAdminPatchRefusesDisablingLocalAdmin(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $local = $this->createUser(Role::Admin, isLocal: true); $response = $this->request( 'PATCH', '/api/v1/admin/users/' . $local, [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], (string) json_encode(['disabled' => true]) ); self::assertSame(409, $response->getStatusCode()); self::assertSame('cannot_disable_local_admin', $this->decode($response)['error']); } public function testAdminPatchIsIdempotentWithNoAuditOnNoOp(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Admin); $target = $this->createUser(Role::Viewer, isLocal: false, subject: 'sub-noop'); // Already enabled → patch enabled=false (no, we want already-active, set enabled): set disabled=false (no-op). $beforeAuditCount = (int) $this->db->fetchOne( "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')" ); $response = $this->request( 'PATCH', '/api/v1/admin/users/' . $target, [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], (string) json_encode(['disabled' => false]) ); self::assertSame(200, $response->getStatusCode()); $afterAuditCount = (int) $this->db->fetchOne( "SELECT COUNT(*) FROM audit_log WHERE action IN ('user.disabled', 'user.enabled')" ); self::assertSame($beforeAuditCount, $afterAuditCount, 'no-op patch must not emit audit'); } public function testAdminUsersRequiresAdminRole(): void { $token = $this->createToken(TokenKind::Admin, role: Role::Viewer); $response = $this->request('GET', '/api/v1/admin/users', [ 'Authorization' => 'Bearer ' . $token, ]); self::assertSame(403, $response->getStatusCode()); } public function testAuditLogActorViaFilter(): void { // Filter on actor_via='local' returns only impersonated-local rows. $token = $this->createToken(TokenKind::Service); $local = $this->createUser(Role::Admin, isLocal: true); $oidc = $this->createUser(Role::Admin, isLocal: false, subject: 'sub-filter'); $localToken = $this->createToken(TokenKind::Admin, role: Role::Admin); // Three writes: two impersonated (one local, one oidc) + one admin token. $this->request('POST', '/api/v1/admin/manual-blocks', [ 'Authorization' => 'Bearer ' . $token, 'X-Acting-User-Id' => (string) $local, 'Content-Type' => 'application/json', ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.1', 'reason' => 'l'])); $this->request('POST', '/api/v1/admin/manual-blocks', [ 'Authorization' => 'Bearer ' . $token, 'X-Acting-User-Id' => (string) $oidc, 'Content-Type' => 'application/json', ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.2', 'reason' => 'o'])); $this->request('POST', '/api/v1/admin/manual-blocks', [ 'Authorization' => 'Bearer ' . $localToken, 'Content-Type' => 'application/json', ], (string) json_encode(['kind' => 'ip', 'ip' => '198.51.100.3', 'reason' => 'a'])); // Admin-list filtered to actor_via=local. $resp = $this->request( 'GET', '/api/v1/admin/audit-log?actor_via=local', ['Authorization' => 'Bearer ' . $localToken], ); self::assertSame(200, $resp->getStatusCode()); $body = $this->decode($resp); $vias = array_map(static fn (array $row) => $row['actor_via'], $body['items']); self::assertNotEmpty($vias); foreach ($vias as $via) { self::assertSame('local', $via); } } }