1
0

UserRepositoryTest.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Tests\Repositories;
  12. use App\Repositories\UserRepository;
  13. use App\Tests\TestCase;
  14. /**
  15. * Covers the upsert + admin-promotion contract used by the auth flows.
  16. * Caller-side gating (R01-N03's BOOTSTRAP_ADMIN_* env-bootstrap) lives in
  17. * `AuthController` + `BootstrapAdmin`; this suite only pins the repo's
  18. * mechanical promoteToAdmin / forceAdmin behaviour.
  19. */
  20. final class UserRepositoryTest extends TestCase
  21. {
  22. public function testFirstUserBecomesAdminWhenPromoted(): void
  23. {
  24. $pdo = $this->makeDb();
  25. $users = new UserRepository($pdo);
  26. $this->assertSame(0, $users->count());
  27. $r = $users->upsertFromOidc(
  28. oid: 'oid-alice',
  29. email: 'alice@example.com',
  30. name: 'Alice',
  31. promoteToAdmin: true, // caller's decision — see BootstrapAdmin::matches
  32. );
  33. $this->assertTrue($r['user']->isAdmin);
  34. $this->assertNull($r['before']);
  35. }
  36. public function testSecondUserDoesNotBecomeAdmin(): void
  37. {
  38. $pdo = $this->makeDb();
  39. $users = new UserRepository($pdo);
  40. $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', true);
  41. // Caller passes promoteToAdmin=false because an admin already exists.
  42. $r2 = $users->upsertFromOidc('oid-bob', 'bob@x', 'Bob', false);
  43. $this->assertFalse($r2['user']->isAdmin);
  44. }
  45. public function testReLoginDoesNotRegressAdminStatus(): void
  46. {
  47. $pdo = $this->makeDb();
  48. $users = new UserRepository($pdo);
  49. $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', true);
  50. // simulate a later login: count > 0 so promoteToAdmin=false
  51. $r = $users->upsertFromOidc('oid-alice', 'alice@x', 'Alice', false);
  52. $this->assertTrue($r['user']->isAdmin, 're-login must not demote admin');
  53. $this->assertNotNull($r['before']);
  54. }
  55. public function testForceAdminPromotesEvenOnUpdate(): void
  56. {
  57. // Local-admin login path sets forceAdmin=true so a demoted user gets
  58. // promoted back on next sign-in.
  59. $pdo = $this->makeDb();
  60. $users = new UserRepository($pdo);
  61. $r1 = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', true, true);
  62. $this->assertTrue($r1['user']->isAdmin);
  63. // Manually demote.
  64. $pdo->exec('UPDATE users SET is_admin = 0 WHERE id = ' . $r1['user']->id);
  65. $r2 = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', false, true);
  66. $this->assertTrue($r2['user']->isAdmin, 'forceAdmin must re-promote on update');
  67. }
  68. public function testUpsertUpdatesEmailAndName(): void
  69. {
  70. $pdo = $this->makeDb();
  71. $users = new UserRepository($pdo);
  72. $users->upsertFromOidc('oid-alice', 'old@x', 'Old Name', true);
  73. $r = $users->upsertFromOidc('oid-alice', 'new@x', 'New Name', false);
  74. $this->assertSame('new@x', $r['user']->email);
  75. $this->assertSame('New Name', $r['user']->displayName);
  76. }
  77. public function testCountReflectsInsertedUsers(): void
  78. {
  79. $pdo = $this->makeDb();
  80. $users = new UserRepository($pdo);
  81. $this->assertSame(0, $users->count());
  82. $users->upsertFromOidc('oid-1', 'a@x', 'A', true);
  83. $this->assertSame(1, $users->count());
  84. $users->upsertFromOidc('oid-2', 'b@x', 'B', false);
  85. $this->assertSame(2, $users->count());
  86. // Re-upsert existing user shouldn't add a row.
  87. $users->upsertFromOidc('oid-1', 'a@x', 'A', false);
  88. $this->assertSame(2, $users->count());
  89. }
  90. // ------------------------------------------------------------------
  91. // Phase 9: users management page helpers
  92. // ------------------------------------------------------------------
  93. public function testAllReturnsEveryUserOrderedByEmail(): void
  94. {
  95. $pdo = $this->makeDb();
  96. $users = new UserRepository($pdo);
  97. $users->upsertFromOidc('oid-c', 'carol@x', 'Carol', true);
  98. $users->upsertFromOidc('oid-a', 'alice@x', 'Alice', false);
  99. $users->upsertFromOidc('oid-b', 'BOB@x', 'Bob', false);
  100. $all = $users->all();
  101. $this->assertCount(3, $all);
  102. $this->assertSame(['alice@x', 'BOB@x', 'carol@x'], array_map(fn($u) => $u->email, $all));
  103. }
  104. public function testCountAdmins(): void
  105. {
  106. $pdo = $this->makeDb();
  107. $users = new UserRepository($pdo);
  108. $this->assertSame(0, $users->countAdmins());
  109. $users->upsertFromOidc('oid-a', 'a@x', 'A', true);
  110. $this->assertSame(1, $users->countAdmins());
  111. $users->upsertFromOidc('oid-b', 'b@x', 'B', false);
  112. $this->assertSame(1, $users->countAdmins());
  113. $users->upsertFromOidc('oid-c', 'c@x', 'C', false, true); // forceAdmin
  114. $this->assertSame(2, $users->countAdmins());
  115. }
  116. public function testSetAdminTogglesAndReportsDiff(): void
  117. {
  118. $pdo = $this->makeDb();
  119. $users = new UserRepository($pdo);
  120. $users->upsertFromOidc('oid-a', 'a@x', 'A', true);
  121. $users->upsertFromOidc('oid-b', 'b@x', 'B', false);
  122. $bob = $users->findByOid('oid-b');
  123. $r = $users->setAdmin($bob->id, true);
  124. $this->assertFalse($r['before']->isAdmin);
  125. $this->assertTrue ($r['after']->isAdmin);
  126. $this->assertSame(2, $users->countAdmins());
  127. $r = $users->setAdmin($bob->id, false);
  128. $this->assertTrue ($r['before']->isAdmin);
  129. $this->assertFalse($r['after']->isAdmin);
  130. $this->assertSame(1, $users->countAdmins());
  131. }
  132. // ------------------------------------------------------------------
  133. // R01-N23: tombstone (soft-delete for privacy)
  134. // ------------------------------------------------------------------
  135. public function testFreshUserIsNotTombstoned(): void
  136. {
  137. $pdo = $this->makeDb();
  138. $users = new UserRepository($pdo);
  139. $r = $users->upsertFromOidc('oid-a', 'a@x', 'Alice', true);
  140. $this->assertFalse($r['user']->isTombstoned());
  141. $this->assertNull($r['user']->tombstonedAt);
  142. }
  143. public function testSetTombstonedStampsAndForcesDemote(): void
  144. {
  145. $pdo = $this->makeDb();
  146. $users = new UserRepository($pdo);
  147. $users->upsertFromOidc('oid-a', 'a@x', 'A', true); // first admin
  148. $users->upsertFromOidc('oid-b', 'b@x', 'B', false);
  149. $users->upsertFromOidc('oid-c', 'c@x', 'C', false, true); // forceAdmin → second admin
  150. $bob = $users->findByOid('oid-b');
  151. $r = $users->setTombstoned($bob->id, true);
  152. $this->assertFalse($r['before']->isTombstoned());
  153. $this->assertTrue ($r['after']->isTombstoned());
  154. $this->assertNotNull($r['after']->tombstonedAt);
  155. $this->assertSame('(former user)', $r['after']->publicEmail());
  156. $this->assertSame('(former user)', $r['after']->publicDisplayName());
  157. $this->assertSame('b@x', $r['after']->email, 'audit-snapshot email is preserved');
  158. $this->assertSame('B', $r['after']->displayName, 'audit-snapshot display name is preserved');
  159. $this->assertFalse($r['after']->isAdmin, 'tombstone forces is_admin=0');
  160. }
  161. public function testSetTombstonedClearsAdminEvenForCurrentlyAdminUsers(): void
  162. {
  163. $pdo = $this->makeDb();
  164. $users = new UserRepository($pdo);
  165. $users->upsertFromOidc('oid-a', 'a@x', 'A', true); // first admin (kept)
  166. $users->upsertFromOidc('oid-b', 'b@x', 'B', false, true); // forceAdmin → second admin
  167. $bob = $users->findByOid('oid-b');
  168. $this->assertTrue($bob->isAdmin);
  169. $users->setTombstoned($bob->id, true);
  170. $bob = $users->findByOid('oid-b');
  171. $this->assertFalse($bob->isAdmin, 'tombstoning an admin must demote them');
  172. $this->assertSame(1, $users->countAdmins());
  173. }
  174. public function testRestoreClearsTombstoneButDoesNotPromote(): void
  175. {
  176. $pdo = $this->makeDb();
  177. $users = new UserRepository($pdo);
  178. $users->upsertFromOidc('oid-a', 'a@x', 'A', true);
  179. $users->upsertFromOidc('oid-b', 'b@x', 'B', false, true);
  180. $bob = $users->findByOid('oid-b');
  181. $users->setTombstoned($bob->id, true);
  182. $bob = $users->findByOid('oid-b');
  183. $this->assertTrue($bob->isTombstoned());
  184. $this->assertFalse($bob->isAdmin, 'precondition: tombstone demoted bob');
  185. $r = $users->setTombstoned($bob->id, false);
  186. $this->assertFalse($r['after']->isTombstoned());
  187. $this->assertNull($r['after']->tombstonedAt);
  188. $this->assertFalse($r['after']->isAdmin, 'restore must NOT auto-promote');
  189. }
  190. public function testOidcSignInUntombstones(): void
  191. {
  192. $pdo = $this->makeDb();
  193. $users = new UserRepository($pdo);
  194. $users->upsertFromOidc('oid-a', 'a@x', 'A', true);
  195. $alice = $users->findByOid('oid-a');
  196. $users->setTombstoned($alice->id, true);
  197. $this->assertTrue($users->findByOid('oid-a')->isTombstoned());
  198. // A successful OIDC sign-in (the upsert path the AuthController calls)
  199. // must clear the tombstone — the user has demonstrated they still hold
  200. // the identity, so the `(former user)` label would lie.
  201. $r = $users->upsertFromOidc('oid-a', 'a@x', 'A', false);
  202. $this->assertFalse($r['user']->isTombstoned());
  203. }
  204. public function testForceAdminAlsoUntombstones(): void
  205. {
  206. // Local-admin login path: forceAdmin=true also clears any
  207. // tombstone, so a tombstoned configured admin can come back.
  208. $pdo = $this->makeDb();
  209. $users = new UserRepository($pdo);
  210. $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', true, true);
  211. $admin = $users->findByOid('local:admin@x');
  212. $users->setTombstoned($admin->id, true);
  213. $r = $users->upsertFromOidc('local:admin@x', 'admin@x', 'Admin', false, true);
  214. $this->assertFalse($r['user']->isTombstoned());
  215. $this->assertTrue($r['user']->isAdmin, 'forceAdmin path still re-promotes');
  216. }
  217. }