makeDb(); $users = new UserRepository($pdo); $this->assertSame(0, $users->count()); $r = $users->upsertFromOidc( oid: 'oid-alice', email: 'alice@example.com', name: 'Alice', promoteToAdmin: true, // caller's decision — see BootstrapAdmin::matches ); $this->assertTrue($r['user']->isAdmin); $this->assertNull($r['before']); } public function testSecondUserDoesNotBecomeAdmin(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', true); // Caller passes promoteToAdmin=false because an admin already exists. $r2 = $users->upsertFromOidc('oid-bob', 'bob@x', 'Bob', false); $this->assertFalse($r2['user']->isAdmin); } public function testReLoginDoesNotRegressAdminStatus(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', true); // simulate a later login: count > 0 so promoteToAdmin=false $r = $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', false); $this->assertTrue($r['user']->isAdmin, 're-login must not demote admin'); $this->assertNotNull($r['before']); } public function testForceAdminPromotesEvenOnUpdate(): void { // Local-admin login path sets forceAdmin=true so a demoted user gets // promoted back on next sign-in. $pdo = $this->makeDb(); $users = new UserRepository($pdo); $r1 = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', true, true); $this->assertTrue($r1['user']->isAdmin); // Manually demote. $pdo->exec('UPDATE users SET is_admin = 0 WHERE id = ' . $r1['user']->id); $r2 = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', false, true); $this->assertTrue($r2['user']->isAdmin, 'forceAdmin must re-promote on update'); } public function testUpsertUpdatesEmailAndName(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-alice', 'old@x', 'Old Name', true); $r = $users->upsertFromOidc('oid-alice', 'new@x', 'New Name', false); $this->assertSame('new@x', $r['user']->email); $this->assertSame('New Name', $r['user']->displayName); } public function testCountReflectsInsertedUsers(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $this->assertSame(0, $users->count()); $users->upsertFromOidc('oid-1', 'a@x', 'A', true); $this->assertSame(1, $users->count()); $users->upsertFromOidc('oid-2', 'b@x', 'B', false); $this->assertSame(2, $users->count()); // Re-upsert existing user shouldn't add a row. $users->upsertFromOidc('oid-1', 'a@x', 'A', false); $this->assertSame(2, $users->count()); } // ------------------------------------------------------------------ // Phase 9: users management page helpers // ------------------------------------------------------------------ public function testAllReturnsEveryUserOrderedByEmail(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-c', 'carol@x', 'Carol', true); $users->upsertFromOidc('oid-a', 'alice@x', 'Alice', false); $users->upsertFromOidc('oid-b', 'BOB@x', 'Bob', false); $all = $users->all(); $this->assertCount(3, $all); $this->assertSame(['alice@x', 'BOB@x', 'carol@x'], array_map(fn($u) => $u->email, $all)); } public function testCountAdmins(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $this->assertSame(0, $users->countAdmins()); $users->upsertFromOidc('oid-a', 'a@x', 'A', true); $this->assertSame(1, $users->countAdmins()); $users->upsertFromOidc('oid-b', 'b@x', 'B', false); $this->assertSame(1, $users->countAdmins()); $users->upsertFromOidc('oid-c', 'c@x', 'C', false, true); // forceAdmin $this->assertSame(2, $users->countAdmins()); } public function testSetAdminTogglesAndReportsDiff(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-a', 'a@x', 'A', true); $users->upsertFromOidc('oid-b', 'b@x', 'B', false); $bob = $users->findByOid('oid-b'); $r = $users->setAdmin($bob->id, true); $this->assertFalse($r['before']->isAdmin); $this->assertTrue ($r['after']->isAdmin); $this->assertSame(2, $users->countAdmins()); $r = $users->setAdmin($bob->id, false); $this->assertTrue ($r['before']->isAdmin); $this->assertFalse($r['after']->isAdmin); $this->assertSame(1, $users->countAdmins()); } // ------------------------------------------------------------------ // R01-N23: tombstone (soft-delete for privacy) // ------------------------------------------------------------------ public function testFreshUserIsNotTombstoned(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $r = $users->upsertFromOidc('oid-a', 'a@x', 'Alice', true); $this->assertFalse($r['user']->isTombstoned()); $this->assertNull($r['user']->tombstonedAt); } public function testSetTombstonedStampsAndForcesDemote(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-a', 'a@x', 'A', true); // first admin $users->upsertFromOidc('oid-b', 'b@x', 'B', false); $users->upsertFromOidc('oid-c', 'c@x', 'C', false, true); // forceAdmin → second admin $bob = $users->findByOid('oid-b'); $r = $users->setTombstoned($bob->id, true); $this->assertFalse($r['before']->isTombstoned()); $this->assertTrue ($r['after']->isTombstoned()); $this->assertNotNull($r['after']->tombstonedAt); $this->assertSame('(former user)', $r['after']->publicEmail()); $this->assertSame('(former user)', $r['after']->publicDisplayName()); $this->assertSame('b@x', $r['after']->email, 'audit-snapshot email is preserved'); $this->assertSame('B', $r['after']->displayName, 'audit-snapshot display name is preserved'); $this->assertFalse($r['after']->isAdmin, 'tombstone forces is_admin=0'); } public function testSetTombstonedClearsAdminEvenForCurrentlyAdminUsers(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-a', 'a@x', 'A', true); // first admin (kept) $users->upsertFromOidc('oid-b', 'b@x', 'B', false, true); // forceAdmin → second admin $bob = $users->findByOid('oid-b'); $this->assertTrue($bob->isAdmin); $users->setTombstoned($bob->id, true); $bob = $users->findByOid('oid-b'); $this->assertFalse($bob->isAdmin, 'tombstoning an admin must demote them'); $this->assertSame(1, $users->countAdmins()); } public function testRestoreClearsTombstoneButDoesNotPromote(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-a', 'a@x', 'A', true); $users->upsertFromOidc('oid-b', 'b@x', 'B', false, true); $bob = $users->findByOid('oid-b'); $users->setTombstoned($bob->id, true); $bob = $users->findByOid('oid-b'); $this->assertTrue($bob->isTombstoned()); $this->assertFalse($bob->isAdmin, 'precondition: tombstone demoted bob'); $r = $users->setTombstoned($bob->id, false); $this->assertFalse($r['after']->isTombstoned()); $this->assertNull($r['after']->tombstonedAt); $this->assertFalse($r['after']->isAdmin, 'restore must NOT auto-promote'); } public function testOidcSignInUntombstones(): void { $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('oid-a', 'a@x', 'A', true); $alice = $users->findByOid('oid-a'); $users->setTombstoned($alice->id, true); $this->assertTrue($users->findByOid('oid-a')->isTombstoned()); // A successful OIDC sign-in (the upsert path the AuthController calls) // must clear the tombstone — the user has demonstrated they still hold // the identity, so the `(former user)` label would lie. $r = $users->upsertFromOidc('oid-a', 'a@x', 'A', false); $this->assertFalse($r['user']->isTombstoned()); } public function testForceAdminAlsoUntombstones(): void { // Local-admin login path: forceAdmin=true also clears any // tombstone, so a tombstoned configured admin can come back. $pdo = $this->makeDb(); $users = new UserRepository($pdo); $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', true, true); $admin = $users->findByOid('local:admin@x'); $users->setTombstoned($admin->id, true); $r = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', false, true); $this->assertFalse($r['user']->isTombstoned()); $this->assertTrue($r['user']->isAdmin, 'forceAdmin path still re-promotes'); } }