test_auth_api.py 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136
  1. """Integration tests for Authentication API endpoints.
  2. Tests the full request/response cycle for /api/v1/auth/ and /api/v1/users/ endpoints.
  3. """
  4. import pytest
  5. from httpx import AsyncClient
  6. class TestAuthStatusAPI:
  7. """Integration tests for /api/v1/auth/status endpoint."""
  8. @pytest.mark.asyncio
  9. @pytest.mark.integration
  10. async def test_get_auth_status_disabled(self, async_client: AsyncClient):
  11. """Verify auth status returns disabled when not configured."""
  12. response = await async_client.get("/api/v1/auth/status")
  13. assert response.status_code == 200
  14. result = response.json()
  15. assert "auth_enabled" in result
  16. assert result["auth_enabled"] is False
  17. assert result["requires_setup"] is True
  18. class TestAuthSetupAPI:
  19. """Integration tests for /api/v1/auth/setup endpoint."""
  20. @pytest.mark.asyncio
  21. @pytest.mark.integration
  22. async def test_setup_auth_disabled(self, async_client: AsyncClient):
  23. """Verify auth can be set up with auth disabled (no password required)."""
  24. response = await async_client.post(
  25. "/api/v1/auth/setup",
  26. json={"auth_enabled": False},
  27. )
  28. assert response.status_code == 200
  29. result = response.json()
  30. assert result["auth_enabled"] is False
  31. assert result["admin_created"] is False
  32. @pytest.mark.asyncio
  33. @pytest.mark.integration
  34. async def test_setup_auth_enabled_requires_credentials(self, async_client: AsyncClient):
  35. """Verify enabling auth requires admin username and password."""
  36. response = await async_client.post(
  37. "/api/v1/auth/setup",
  38. json={"auth_enabled": True},
  39. )
  40. assert response.status_code == 400
  41. assert "Admin username and password are required" in response.json()["detail"]
  42. @pytest.mark.asyncio
  43. @pytest.mark.integration
  44. async def test_setup_auth_enabled_with_credentials(self, async_client: AsyncClient):
  45. """Verify auth can be enabled with admin credentials."""
  46. response = await async_client.post(
  47. "/api/v1/auth/setup",
  48. json={
  49. "auth_enabled": True,
  50. "admin_username": "testadmin",
  51. "admin_password": "TestPass1!",
  52. },
  53. )
  54. assert response.status_code == 200
  55. result = response.json()
  56. assert result["auth_enabled"] is True
  57. assert result["admin_created"] is True
  58. @pytest.mark.asyncio
  59. @pytest.mark.integration
  60. async def test_setup_weak_password_rejected_when_creating_new_admin(self, async_client: AsyncClient):
  61. """Complexity is enforced only when a new admin is being created."""
  62. response = await async_client.post(
  63. "/api/v1/auth/setup",
  64. json={
  65. "auth_enabled": True,
  66. "admin_username": "weakpw_admin",
  67. "admin_password": "NoSpecial1",
  68. },
  69. )
  70. assert response.status_code == 400
  71. assert "special character" in response.json()["detail"].lower()
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_setup_reenable_with_existing_admin_ignores_password(self, async_client: AsyncClient, db_session):
  75. """Re-enabling auth when an admin already exists must not reject the placeholder
  76. password the frontend still sends. Regression for the LDAP re-enable flow that
  77. previously 422'd because the Pydantic schema enforced complexity unconditionally.
  78. """
  79. from backend.app.core.auth import get_password_hash
  80. from backend.app.models.user import User
  81. existing = User(
  82. username="existing_admin",
  83. # pragma: allowlist secret — test fixture only, not a real credential
  84. password_hash=get_password_hash("DoesNotMatter1!"), # noqa: S106
  85. role="admin",
  86. is_active=True,
  87. )
  88. db_session.add(existing)
  89. await db_session.commit()
  90. response = await async_client.post(
  91. "/api/v1/auth/setup",
  92. json={
  93. "auth_enabled": True,
  94. "admin_username": "irrelevant",
  95. "admin_password": "NoSpecial1",
  96. },
  97. )
  98. assert response.status_code == 200
  99. result = response.json()
  100. assert result["auth_enabled"] is True
  101. assert result["admin_created"] is False
  102. class TestAuthLoginAPI:
  103. """Integration tests for /api/v1/auth/login endpoint."""
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_login_auth_disabled(self, async_client: AsyncClient):
  107. """Verify login fails when auth is not enabled."""
  108. response = await async_client.post(
  109. "/api/v1/auth/login",
  110. json={"username": "admin", "password": "password"},
  111. )
  112. assert response.status_code == 400
  113. assert "Authentication is not enabled" in response.json()["detail"]
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_login_success(self, async_client: AsyncClient):
  117. """Verify login succeeds with valid credentials after setup."""
  118. # First enable auth
  119. await async_client.post(
  120. "/api/v1/auth/setup",
  121. json={
  122. "auth_enabled": True,
  123. "admin_username": "logintest",
  124. "admin_password": "LoginPass1!",
  125. },
  126. )
  127. # Now login
  128. response = await async_client.post(
  129. "/api/v1/auth/login",
  130. json={"username": "logintest", "password": "LoginPass1!"},
  131. )
  132. assert response.status_code == 200
  133. result = response.json()
  134. assert "access_token" in result
  135. assert result["token_type"] == "bearer"
  136. assert result["user"]["username"] == "logintest"
  137. assert result["user"]["role"] == "admin"
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_login_invalid_credentials(self, async_client: AsyncClient):
  141. """Verify login fails with invalid credentials."""
  142. # First enable auth
  143. await async_client.post(
  144. "/api/v1/auth/setup",
  145. json={
  146. "auth_enabled": True,
  147. "admin_username": "invalidtest",
  148. "admin_password": "CorrectPass1!",
  149. },
  150. )
  151. # Try login with wrong password
  152. response = await async_client.post(
  153. "/api/v1/auth/login",
  154. json={"username": "invalidtest", "password": "wrongpassword"},
  155. )
  156. assert response.status_code == 401
  157. assert "Incorrect username or password" in response.json()["detail"]
  158. class TestAuthMeAPI:
  159. """Integration tests for /api/v1/auth/me endpoint."""
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_me_without_token(self, async_client: AsyncClient):
  163. """Verify /me fails without authentication token."""
  164. response = await async_client.get("/api/v1/auth/me")
  165. assert response.status_code == 401
  166. @pytest.mark.asyncio
  167. @pytest.mark.integration
  168. async def test_me_with_valid_token(self, async_client: AsyncClient):
  169. """Verify /me returns user info with valid token."""
  170. # Setup and login
  171. await async_client.post(
  172. "/api/v1/auth/setup",
  173. json={
  174. "auth_enabled": True,
  175. "admin_username": "metest",
  176. "admin_password": "MePass1!",
  177. },
  178. )
  179. login_response = await async_client.post(
  180. "/api/v1/auth/login",
  181. json={"username": "metest", "password": "MePass1!"},
  182. )
  183. token = login_response.json()["access_token"]
  184. # Get current user
  185. response = await async_client.get(
  186. "/api/v1/auth/me",
  187. headers={"Authorization": f"Bearer {token}"},
  188. )
  189. assert response.status_code == 200
  190. result = response.json()
  191. assert result["username"] == "metest"
  192. assert result["role"] == "admin"
  193. assert result["is_active"] is True
  194. @pytest.mark.asyncio
  195. @pytest.mark.integration
  196. async def test_me_with_ownerless_api_key_bearer(self, async_client: AsyncClient, db_session):
  197. """A legacy key has no identity to report, but no longer claims admin (#1894)."""
  198. from backend.app.core.auth import generate_api_key
  199. from backend.app.models.api_key import APIKey
  200. # Create an API key directly in the database
  201. full_key, key_hash, key_prefix = generate_api_key()
  202. api_key = APIKey(name="test-kiosk", key_hash=key_hash, key_prefix=key_prefix, enabled=True)
  203. db_session.add(api_key)
  204. await db_session.commit()
  205. # Call /me with the API key as Bearer token
  206. response = await async_client.get(
  207. "/api/v1/auth/me",
  208. headers={"Authorization": f"Bearer {full_key}"},
  209. )
  210. assert response.status_code == 200
  211. result = response.json()
  212. assert result["id"] == 0
  213. assert result["username"].startswith("api-key:")
  214. assert result["role"] != "admin"
  215. assert result["is_admin"] is False
  216. assert result["is_active"] is True
  217. # can_read_status defaults True, so the scope-derived set is non-empty
  218. # -- but it is a set, not "every permission there is".
  219. assert len(result["permissions"]) > 0
  220. assert "users:create" not in result["permissions"]
  221. @pytest.mark.asyncio
  222. @pytest.mark.integration
  223. async def test_me_with_ownerless_api_key_header(self, async_client: AsyncClient, db_session):
  224. """Same as above via the X-API-Key header rather than Bearer."""
  225. from backend.app.core.auth import generate_api_key
  226. from backend.app.models.api_key import APIKey
  227. full_key, key_hash, key_prefix = generate_api_key()
  228. api_key = APIKey(name="test-kiosk-header", key_hash=key_hash, key_prefix=key_prefix, enabled=True)
  229. db_session.add(api_key)
  230. await db_session.commit()
  231. response = await async_client.get(
  232. "/api/v1/auth/me",
  233. headers={"X-API-Key": full_key},
  234. )
  235. assert response.status_code == 200
  236. result = response.json()
  237. assert result["id"] == 0
  238. assert result["username"].startswith("api-key:")
  239. assert result["is_admin"] is False
  240. @pytest.mark.asyncio
  241. @pytest.mark.integration
  242. async def test_me_with_invalid_api_key(self, async_client: AsyncClient):
  243. """Verify /me rejects invalid API key."""
  244. response = await async_client.get(
  245. "/api/v1/auth/me",
  246. headers={"Authorization": "Bearer bb_invalid_key_value"},
  247. )
  248. assert response.status_code == 401
  249. async def _owned_key(self, async_client: AsyncClient, db_session, **scopes):
  250. """Set up auth and return (owner, full_key) for a key with ``scopes``.
  251. The owner is given an email and a group explicitly rather than relying
  252. on what /auth/setup happens to seed, so the assertions about what /me
  253. withholds cannot pass vacuously.
  254. """
  255. from sqlalchemy import select
  256. from sqlalchemy.orm import selectinload
  257. from backend.app.core.auth import generate_api_key
  258. from backend.app.models.api_key import APIKey
  259. from backend.app.models.group import Group
  260. from backend.app.models.user import User
  261. await async_client.post(
  262. "/api/v1/auth/setup",
  263. json={
  264. "auth_enabled": True,
  265. "admin_username": "keyowner",
  266. "admin_password": "KeyPass1!",
  267. },
  268. )
  269. owner = (
  270. await db_session.execute(select(User).where(User.username == "keyowner").options(selectinload(User.groups)))
  271. ).scalar_one()
  272. owner.email = "keyowner@example.invalid"
  273. group = Group(name="key-owner-group", description="t", permissions=["printers:read"], is_system=False)
  274. db_session.add(group)
  275. await db_session.flush()
  276. owner.groups.append(group)
  277. full_key, key_hash, key_prefix = generate_api_key()
  278. db_session.add(
  279. APIKey(name="owned", key_hash=key_hash, key_prefix=key_prefix, enabled=True, user_id=owner.id, **scopes)
  280. )
  281. await db_session.commit()
  282. return owner, full_key
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_me_reports_the_key_owner_not_a_synthetic_admin(self, async_client: AsyncClient, db_session):
  286. """The id is the point of #1894 -- it is what created_by_id filters on."""
  287. owner, full_key = await self._owned_key(async_client, db_session)
  288. response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})
  289. assert response.status_code == 200
  290. result = response.json()
  291. assert result["id"] == owner.id
  292. assert result["username"] == "keyowner"
  293. # The owner is an admin; the key still is not, because no key reaches
  294. # an administrative route regardless of who owns it.
  295. assert result["is_admin"] is False
  296. assert result["role"] != "admin"
  297. @pytest.mark.asyncio
  298. @pytest.mark.integration
  299. async def test_me_withholds_owner_email_and_groups(self, async_client: AsyncClient, db_session):
  300. """Identity, not the owner's profile -- anyone holding the key sees this."""
  301. owner, full_key = await self._owned_key(async_client, db_session)
  302. assert owner.email is not None and owner.groups # the helper made both non-empty
  303. result = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()
  304. assert result["email"] is None
  305. assert result["groups"] == []
  306. @pytest.mark.asyncio
  307. @pytest.mark.integration
  308. async def test_me_permissions_track_the_key_scopes_not_the_owner(self, async_client: AsyncClient, db_session):
  309. """A key owned by an admin still reports only what its flags allow."""
  310. _, full_key = await self._owned_key(
  311. async_client,
  312. db_session,
  313. can_read_status=True,
  314. can_control_printer=False,
  315. can_queue=False,
  316. )
  317. perms = (await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})).json()["permissions"]
  318. assert "printers:read" in perms # can_read_status
  319. assert "printers:control" not in perms # can_control_printer is off
  320. assert "queue:create" not in perms # can_queue is off
  321. assert "users:create" not in perms # administrative: unmapped for keys
  322. @pytest.mark.asyncio
  323. @pytest.mark.integration
  324. async def test_me_permissions_are_exactly_what_the_gate_admits(self, async_client: AsyncClient, db_session):
  325. """/me must not drift from _check_apikey_permissions.
  326. The whole defect in #1894 was a /me response that described a different
  327. credential than the one the gate enforces, so pin them to each other
  328. rather than to a hand-written list that can rot. The owner is threaded
  329. through both sides for the same reason -- the gate narrows to the
  330. owner's permissions, so a check that skipped the owner would stop
  331. catching drift the moment the owner is not an administrator.
  332. """
  333. from fastapi import HTTPException
  334. from sqlalchemy import select
  335. from backend.app.core.auth import _check_apikey_permissions, resolve_apikey_owner
  336. from backend.app.core.permissions import ALL_PERMISSIONS
  337. from backend.app.models.api_key import APIKey
  338. _, full_key = await self._owned_key(async_client, db_session, can_read_status=True, can_control_printer=False)
  339. response = await async_client.get("/api/v1/auth/me", headers={"X-API-Key": full_key})
  340. reported = set(response.json()["permissions"])
  341. key = (await db_session.execute(select(APIKey).where(APIKey.name == "owned"))).scalar_one()
  342. owner = await resolve_apikey_owner(db_session, key)
  343. for perm in ALL_PERMISSIONS:
  344. try:
  345. _check_apikey_permissions(key, [perm], owner=owner)
  346. except HTTPException:
  347. assert perm not in reported, f"/me reports '{perm}' but the gate denies it"
  348. else:
  349. assert perm in reported, f"the gate admits '{perm}' but /me omits it"
  350. class TestUsersAPI:
  351. """Integration tests for /api/v1/users/ endpoints."""
  352. @pytest.fixture
  353. async def auth_token(self, async_client: AsyncClient):
  354. """Setup auth and return admin token."""
  355. await async_client.post(
  356. "/api/v1/auth/setup",
  357. json={
  358. "auth_enabled": True,
  359. "admin_username": "usersadmin",
  360. "admin_password": "AdminPass1!",
  361. },
  362. )
  363. login_response = await async_client.post(
  364. "/api/v1/auth/login",
  365. json={"username": "usersadmin", "password": "AdminPass1!"},
  366. )
  367. return login_response.json()["access_token"]
  368. @pytest.mark.asyncio
  369. @pytest.mark.integration
  370. async def test_list_users_requires_auth(self, async_client: AsyncClient):
  371. """Verify listing users requires authentication when auth is enabled."""
  372. # First enable auth
  373. await async_client.post(
  374. "/api/v1/auth/setup",
  375. json={
  376. "auth_enabled": True,
  377. "admin_username": "authreqadmin",
  378. "admin_password": "AdminPass1!",
  379. },
  380. )
  381. # Now try to list users without a token
  382. response = await async_client.get("/api/v1/users/")
  383. assert response.status_code == 401
  384. @pytest.mark.asyncio
  385. @pytest.mark.integration
  386. async def test_list_users_as_admin(self, async_client: AsyncClient, auth_token: str):
  387. """Verify admin can list users."""
  388. response = await async_client.get(
  389. "/api/v1/users/",
  390. headers={"Authorization": f"Bearer {auth_token}"},
  391. )
  392. assert response.status_code == 200
  393. result = response.json()
  394. assert isinstance(result, list)
  395. assert len(result) >= 1 # At least the admin user
  396. @pytest.mark.asyncio
  397. @pytest.mark.integration
  398. async def test_create_user(self, async_client: AsyncClient, auth_token: str):
  399. """Verify admin can create a new user."""
  400. response = await async_client.post(
  401. "/api/v1/users/",
  402. headers={"Authorization": f"Bearer {auth_token}"},
  403. json={
  404. "username": "newuser",
  405. "password": "Newuserpass1!",
  406. "role": "user",
  407. },
  408. )
  409. assert response.status_code == 201
  410. result = response.json()
  411. assert result["username"] == "newuser"
  412. assert result["role"] == "user"
  413. assert result["is_active"] is True
  414. @pytest.mark.asyncio
  415. @pytest.mark.integration
  416. async def test_create_user_duplicate_username(self, async_client: AsyncClient, auth_token: str):
  417. """Verify creating user with duplicate username fails."""
  418. # Create first user
  419. await async_client.post(
  420. "/api/v1/users/",
  421. headers={"Authorization": f"Bearer {auth_token}"},
  422. json={
  423. "username": "duplicateuser",
  424. "password": "Password123!",
  425. "role": "user",
  426. },
  427. )
  428. # Try to create duplicate
  429. response = await async_client.post(
  430. "/api/v1/users/",
  431. headers={"Authorization": f"Bearer {auth_token}"},
  432. json={
  433. "username": "duplicateuser",
  434. "password": "Password456!",
  435. "role": "user",
  436. },
  437. )
  438. assert response.status_code == 400
  439. assert "Username already exists" in response.json()["detail"]
  440. @pytest.mark.asyncio
  441. @pytest.mark.integration
  442. async def test_update_user(self, async_client: AsyncClient, auth_token: str):
  443. """Verify admin can update a user."""
  444. # Create user
  445. create_response = await async_client.post(
  446. "/api/v1/users/",
  447. headers={"Authorization": f"Bearer {auth_token}"},
  448. json={
  449. "username": "updateuser",
  450. "password": "Password123!",
  451. "role": "user",
  452. },
  453. )
  454. user_id = create_response.json()["id"]
  455. # Update user
  456. response = await async_client.patch(
  457. f"/api/v1/users/{user_id}",
  458. headers={"Authorization": f"Bearer {auth_token}"},
  459. json={"role": "admin"},
  460. )
  461. assert response.status_code == 200
  462. assert response.json()["role"] == "admin"
  463. @pytest.mark.asyncio
  464. @pytest.mark.integration
  465. async def test_delete_user(self, async_client: AsyncClient, auth_token: str, db_session):
  466. """Verify admin can delete a user and that all auth-table side effects cascade.
  467. The auth-cleanup side effects matter on SQLite (FK enforcement off by default):
  468. without explicit DELETEs in the endpoint, deleting a user leaves orphan rows
  469. in user_oidc_links / user_totp / user_otp_codes / api_keys — which would
  470. block SSO re-login and leak MFA secrets (#1285).
  471. """
  472. from sqlalchemy import select
  473. from backend.app.models.api_key import APIKey
  474. from backend.app.models.long_lived_token import LongLivedToken
  475. from backend.app.models.oidc_provider import UserOIDCLink
  476. from backend.app.models.user import User
  477. from backend.app.models.user_otp_code import UserOTPCode
  478. from backend.app.models.user_totp import UserTOTP
  479. # Create user
  480. create_response = await async_client.post(
  481. "/api/v1/users/",
  482. headers={"Authorization": f"Bearer {auth_token}"},
  483. json={
  484. "username": "deleteuser",
  485. "password": "Password123!",
  486. "role": "user",
  487. },
  488. )
  489. user_id = create_response.json()["id"]
  490. # Delete user
  491. response = await async_client.delete(
  492. f"/api/v1/users/{user_id}",
  493. headers={"Authorization": f"Bearer {auth_token}"},
  494. )
  495. assert response.status_code == 204
  496. # All auth-related rows for this user must be gone — see #1285.
  497. await db_session.commit()
  498. user_row = await db_session.execute(select(User).where(User.id == user_id))
  499. assert user_row.scalar_one_or_none() is None, "User row not deleted"
  500. for model in (UserOIDCLink, UserTOTP, UserOTPCode, APIKey, LongLivedToken):
  501. rows = await db_session.execute(select(model).where(model.user_id == user_id))
  502. assert rows.scalars().all() == [], f"Orphan {model.__name__} rows left after user delete"
  503. class TestAuthDisableAPI:
  504. """Integration tests for /api/v1/auth/disable endpoint."""
  505. @pytest.mark.asyncio
  506. @pytest.mark.integration
  507. async def test_disable_auth(self, async_client: AsyncClient):
  508. """Verify admin can disable authentication."""
  509. # Setup auth
  510. await async_client.post(
  511. "/api/v1/auth/setup",
  512. json={
  513. "auth_enabled": True,
  514. "admin_username": "disableadmin",
  515. "admin_password": "AdminPass1!",
  516. },
  517. )
  518. # Login to get token
  519. login_response = await async_client.post(
  520. "/api/v1/auth/login",
  521. json={"username": "disableadmin", "password": "AdminPass1!"},
  522. )
  523. token = login_response.json()["access_token"]
  524. # Disable auth
  525. response = await async_client.post(
  526. "/api/v1/auth/disable",
  527. headers={"Authorization": f"Bearer {token}"},
  528. )
  529. assert response.status_code == 200
  530. assert response.json()["auth_enabled"] is False
  531. # Verify auth is now disabled
  532. status_response = await async_client.get("/api/v1/auth/status")
  533. assert status_response.json()["auth_enabled"] is False
  534. class TestGroupsAPI:
  535. """Integration tests for /api/v1/groups/ endpoints."""
  536. @pytest.fixture
  537. async def auth_token(self, async_client: AsyncClient):
  538. """Setup auth and return admin token."""
  539. await async_client.post(
  540. "/api/v1/auth/setup",
  541. json={
  542. "auth_enabled": True,
  543. "admin_username": "groupsadmin",
  544. "admin_password": "AdminPass1!",
  545. },
  546. )
  547. login_response = await async_client.post(
  548. "/api/v1/auth/login",
  549. json={"username": "groupsadmin", "password": "AdminPass1!"},
  550. )
  551. return login_response.json()["access_token"]
  552. @pytest.mark.asyncio
  553. @pytest.mark.integration
  554. async def test_list_groups(self, async_client: AsyncClient, auth_token: str):
  555. """Verify listing groups returns default groups."""
  556. response = await async_client.get(
  557. "/api/v1/groups/",
  558. headers={"Authorization": f"Bearer {auth_token}"},
  559. )
  560. assert response.status_code == 200
  561. groups = response.json()
  562. assert isinstance(groups, list)
  563. # Should have default groups: Administrators, Operators, Viewers
  564. group_names = [g["name"] for g in groups]
  565. assert "Administrators" in group_names
  566. assert "Operators" in group_names
  567. assert "Viewers" in group_names
  568. @pytest.mark.asyncio
  569. @pytest.mark.integration
  570. async def test_get_permissions(self, async_client: AsyncClient, auth_token: str):
  571. """Verify getting available permissions."""
  572. response = await async_client.get(
  573. "/api/v1/groups/permissions",
  574. headers={"Authorization": f"Bearer {auth_token}"},
  575. )
  576. assert response.status_code == 200
  577. permissions = response.json()
  578. assert isinstance(permissions, dict)
  579. # Should have permission categories
  580. assert "Printers" in permissions or len(permissions) > 0
  581. @pytest.mark.asyncio
  582. @pytest.mark.integration
  583. async def test_create_group(self, async_client: AsyncClient, auth_token: str):
  584. """Verify creating a new group."""
  585. response = await async_client.post(
  586. "/api/v1/groups/",
  587. headers={"Authorization": f"Bearer {auth_token}"},
  588. json={
  589. "name": "Custom Group",
  590. "description": "A custom test group",
  591. "permissions": ["printers:read", "archives:read"],
  592. },
  593. )
  594. assert response.status_code == 201
  595. group = response.json()
  596. assert group["name"] == "Custom Group"
  597. assert group["description"] == "A custom test group"
  598. assert "printers:read" in group["permissions"]
  599. assert group["is_system"] is False
  600. @pytest.mark.asyncio
  601. @pytest.mark.integration
  602. async def test_update_group(self, async_client: AsyncClient, auth_token: str):
  603. """Verify updating a group."""
  604. # Create a group first
  605. create_response = await async_client.post(
  606. "/api/v1/groups/",
  607. headers={"Authorization": f"Bearer {auth_token}"},
  608. json={
  609. "name": "Update Test Group",
  610. "permissions": ["printers:read"],
  611. },
  612. )
  613. group_id = create_response.json()["id"]
  614. # Update the group
  615. response = await async_client.patch(
  616. f"/api/v1/groups/{group_id}",
  617. headers={"Authorization": f"Bearer {auth_token}"},
  618. json={
  619. "description": "Updated description",
  620. "permissions": ["printers:read", "printers:control"],
  621. },
  622. )
  623. assert response.status_code == 200
  624. group = response.json()
  625. assert group["description"] == "Updated description"
  626. assert "printers:control" in group["permissions"]
  627. @pytest.mark.asyncio
  628. @pytest.mark.integration
  629. async def test_cannot_delete_system_group(self, async_client: AsyncClient, auth_token: str):
  630. """Verify system groups cannot be deleted."""
  631. # Get the Administrators group
  632. list_response = await async_client.get(
  633. "/api/v1/groups/",
  634. headers={"Authorization": f"Bearer {auth_token}"},
  635. )
  636. admin_group = next(g for g in list_response.json() if g["name"] == "Administrators")
  637. # Try to delete it
  638. response = await async_client.delete(
  639. f"/api/v1/groups/{admin_group['id']}",
  640. headers={"Authorization": f"Bearer {auth_token}"},
  641. )
  642. assert response.status_code == 400
  643. assert "system group" in response.json()["detail"].lower()
  644. @pytest.mark.asyncio
  645. @pytest.mark.integration
  646. async def test_delete_custom_group(self, async_client: AsyncClient, auth_token: str):
  647. """Verify custom groups can be deleted."""
  648. # Create a group
  649. create_response = await async_client.post(
  650. "/api/v1/groups/",
  651. headers={"Authorization": f"Bearer {auth_token}"},
  652. json={"name": "Delete Test Group"},
  653. )
  654. group_id = create_response.json()["id"]
  655. # Delete it
  656. response = await async_client.delete(
  657. f"/api/v1/groups/{group_id}",
  658. headers={"Authorization": f"Bearer {auth_token}"},
  659. )
  660. assert response.status_code == 204
  661. class TestUserGroupsAPI:
  662. """Integration tests for user-group assignments."""
  663. @pytest.fixture
  664. async def auth_token(self, async_client: AsyncClient):
  665. """Setup auth and return admin token."""
  666. await async_client.post(
  667. "/api/v1/auth/setup",
  668. json={
  669. "auth_enabled": True,
  670. "admin_username": "usergroupadmin",
  671. "admin_password": "AdminPass1!",
  672. },
  673. )
  674. login_response = await async_client.post(
  675. "/api/v1/auth/login",
  676. json={"username": "usergroupadmin", "password": "AdminPass1!"},
  677. )
  678. return login_response.json()["access_token"]
  679. @pytest.mark.asyncio
  680. @pytest.mark.integration
  681. async def test_create_user_with_groups(self, async_client: AsyncClient, auth_token: str):
  682. """Verify creating a user with group assignments."""
  683. # Get Operators group ID
  684. groups_response = await async_client.get(
  685. "/api/v1/groups/",
  686. headers={"Authorization": f"Bearer {auth_token}"},
  687. )
  688. operators_group = next(g for g in groups_response.json() if g["name"] == "Operators")
  689. # Create user with group
  690. response = await async_client.post(
  691. "/api/v1/users/",
  692. headers={"Authorization": f"Bearer {auth_token}"},
  693. json={
  694. "username": "groupuser",
  695. "password": "Password123!",
  696. "group_ids": [operators_group["id"]],
  697. },
  698. )
  699. assert response.status_code == 201
  700. user = response.json()
  701. assert any(g["name"] == "Operators" for g in user["groups"])
  702. @pytest.mark.asyncio
  703. @pytest.mark.integration
  704. async def test_add_user_to_group(self, async_client: AsyncClient, auth_token: str):
  705. """Verify adding a user to a group."""
  706. # Create a user
  707. user_response = await async_client.post(
  708. "/api/v1/users/",
  709. headers={"Authorization": f"Bearer {auth_token}"},
  710. json={"username": "addtogroup", "password": "Password123!"},
  711. )
  712. user_id = user_response.json()["id"]
  713. # Get Viewers group
  714. groups_response = await async_client.get(
  715. "/api/v1/groups/",
  716. headers={"Authorization": f"Bearer {auth_token}"},
  717. )
  718. viewers_group = next(g for g in groups_response.json() if g["name"] == "Viewers")
  719. # Add user to group
  720. response = await async_client.post(
  721. f"/api/v1/groups/{viewers_group['id']}/users/{user_id}",
  722. headers={"Authorization": f"Bearer {auth_token}"},
  723. )
  724. assert response.status_code == 204
  725. # Verify user is in group
  726. user_check = await async_client.get(
  727. f"/api/v1/users/{user_id}",
  728. headers={"Authorization": f"Bearer {auth_token}"},
  729. )
  730. assert any(g["name"] == "Viewers" for g in user_check.json()["groups"])
  731. class TestChangePasswordAPI:
  732. """Integration tests for /api/v1/users/me/change-password endpoint."""
  733. @pytest.fixture
  734. async def user_token(self, async_client: AsyncClient):
  735. """Setup auth and return regular user token."""
  736. # Enable auth with admin
  737. await async_client.post(
  738. "/api/v1/auth/setup",
  739. json={
  740. "auth_enabled": True,
  741. "admin_username": "pwchangeadmin",
  742. "admin_password": "AdminPass1!",
  743. },
  744. )
  745. admin_login = await async_client.post(
  746. "/api/v1/auth/login",
  747. json={"username": "pwchangeadmin", "password": "AdminPass1!"},
  748. )
  749. admin_token = admin_login.json()["access_token"]
  750. # Create a regular user
  751. await async_client.post(
  752. "/api/v1/users/",
  753. headers={"Authorization": f"Bearer {admin_token}"},
  754. json={"username": "pwchangeuser", "password": "Oldpassword123!"},
  755. )
  756. # Login as regular user
  757. user_login = await async_client.post(
  758. "/api/v1/auth/login",
  759. json={"username": "pwchangeuser", "password": "Oldpassword123!"},
  760. )
  761. return user_login.json()["access_token"]
  762. @pytest.mark.asyncio
  763. @pytest.mark.integration
  764. async def test_change_password_success(self, async_client: AsyncClient, user_token: str):
  765. """Verify user can change their own password."""
  766. response = await async_client.post(
  767. "/api/v1/users/me/change-password",
  768. headers={"Authorization": f"Bearer {user_token}"},
  769. json={
  770. "current_password": "Oldpassword123!",
  771. "new_password": "Newpassword456!",
  772. },
  773. )
  774. assert response.status_code == 200
  775. assert "success" in response.json()["message"].lower()
  776. # Verify can login with new password
  777. login_response = await async_client.post(
  778. "/api/v1/auth/login",
  779. json={"username": "pwchangeuser", "password": "Newpassword456!"},
  780. )
  781. assert login_response.status_code == 200
  782. @pytest.mark.asyncio
  783. @pytest.mark.integration
  784. async def test_change_password_wrong_current(self, async_client: AsyncClient, user_token: str):
  785. """Verify changing password fails with wrong current password."""
  786. response = await async_client.post(
  787. "/api/v1/users/me/change-password",
  788. headers={"Authorization": f"Bearer {user_token}"},
  789. json={
  790. "current_password": "wrongpassword",
  791. "new_password": "Newpassword456!",
  792. },
  793. )
  794. assert response.status_code == 400
  795. assert "incorrect" in response.json()["detail"].lower()
  796. @pytest.mark.asyncio
  797. @pytest.mark.integration
  798. async def test_change_password_requires_auth(self, async_client: AsyncClient):
  799. """Verify changing password requires authentication."""
  800. response = await async_client.post(
  801. "/api/v1/users/me/change-password",
  802. json={
  803. "current_password": "oldpassword",
  804. "new_password": "Strongpass456!",
  805. },
  806. )
  807. assert response.status_code == 401
  808. class TestAuthMiddlewarePublicRoutes:
  809. """Tests for auth middleware public route configuration.
  810. These routes must be accessible without authentication, even when auth is enabled,
  811. because browser elements like <img src> and <video src> don't send Authorization headers.
  812. """
  813. @pytest.fixture
  814. async def enabled_auth(self, async_client: AsyncClient):
  815. """Enable auth for testing middleware behavior."""
  816. await async_client.post(
  817. "/api/v1/auth/setup",
  818. json={
  819. "auth_enabled": True,
  820. "admin_username": "middlewareadmin",
  821. "admin_password": "AdminPass1!",
  822. },
  823. )
  824. @pytest.mark.asyncio
  825. @pytest.mark.integration
  826. async def test_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):
  827. """Verify /api/v1/auth/status is accessible without auth."""
  828. response = await async_client.get("/api/v1/auth/status")
  829. assert response.status_code == 200
  830. assert "auth_enabled" in response.json()
  831. @pytest.mark.asyncio
  832. @pytest.mark.integration
  833. async def test_system_appliance_is_public(self, async_client: AsyncClient, enabled_auth):
  834. """Verify /api/v1/system/appliance is reachable without a JWT.
  835. The SPA's i18n bootstrap fetches this BEFORE login to seed locale,
  836. hostname, timezone, and NTP-gate state. The route handler has no
  837. auth dependency, but the global auth_middleware blocks every
  838. /api/ path not in PUBLIC_API_ROUTES — so without an explicit
  839. allowlist entry the user sees a 401 in the browser console on
  840. every page load.
  841. """
  842. response = await async_client.get("/api/v1/system/appliance")
  843. assert response.status_code == 200, response.text
  844. body = response.json()
  845. # Shape contract (no-auth surface):
  846. for key in ("hostname", "timezone", "locale", "time_synced"):
  847. assert key in body
  848. @pytest.mark.asyncio
  849. @pytest.mark.integration
  850. async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth):
  851. """Verify /api/v1/auth/login is accessible without auth."""
  852. response = await async_client.post(
  853. "/api/v1/auth/login",
  854. json={"username": "middlewareadmin", "password": "AdminPass1!"},
  855. )
  856. # Should not return 401 (unauthorized) - it should either succeed or return
  857. # a different error (like 400 for wrong credentials)
  858. assert response.status_code != 401 or "token" in response.json()
  859. @pytest.mark.asyncio
  860. @pytest.mark.integration
  861. async def test_auth_setup_is_public(self, async_client: AsyncClient):
  862. """Verify /api/v1/auth/setup is accessible without auth (needed for setup/recovery)."""
  863. # Don't enable auth first - test that setup endpoint itself is accessible
  864. response = await async_client.post(
  865. "/api/v1/auth/setup",
  866. json={"auth_enabled": False},
  867. )
  868. # Should not be 401
  869. assert response.status_code != 401
  870. @pytest.mark.asyncio
  871. @pytest.mark.integration
  872. async def test_updates_version_is_public(self, async_client: AsyncClient, enabled_auth):
  873. """Verify /api/v1/updates/version is accessible without auth."""
  874. response = await async_client.get("/api/v1/updates/version")
  875. # Should not be 401
  876. assert response.status_code != 401
  877. @pytest.mark.asyncio
  878. @pytest.mark.integration
  879. async def test_protected_route_requires_auth(self, async_client: AsyncClient, enabled_auth):
  880. """Verify non-public routes return 401 without token."""
  881. response = await async_client.get("/api/v1/printers/")
  882. assert response.status_code == 401
  883. @pytest.mark.asyncio
  884. @pytest.mark.integration
  885. async def test_protected_route_works_with_token(self, async_client: AsyncClient, enabled_auth):
  886. """Verify non-public routes work with valid token."""
  887. # Login to get token
  888. login_response = await async_client.post(
  889. "/api/v1/auth/login",
  890. json={"username": "middlewareadmin", "password": "AdminPass1!"},
  891. )
  892. token = login_response.json()["access_token"]
  893. # Access protected route
  894. response = await async_client.get(
  895. "/api/v1/printers/",
  896. headers={"Authorization": f"Bearer {token}"},
  897. )
  898. assert response.status_code == 200
  899. @pytest.mark.asyncio
  900. @pytest.mark.integration
  901. async def test_advanced_auth_status_is_public(self, async_client: AsyncClient, enabled_auth):
  902. """Verify /api/v1/auth/advanced-auth/status is accessible without auth."""
  903. response = await async_client.get("/api/v1/auth/advanced-auth/status")
  904. # Should not be 401 (must be accessible for login page)
  905. assert response.status_code != 401
  906. # Should return valid response (200 with auth status)
  907. if response.status_code == 200:
  908. result = response.json()
  909. assert "advanced_auth_enabled" in result
  910. assert "smtp_configured" in result
  911. @pytest.mark.asyncio
  912. @pytest.mark.integration
  913. async def test_forgot_password_is_public(self, async_client: AsyncClient, enabled_auth):
  914. """Verify /api/v1/auth/forgot-password is accessible without auth."""
  915. response = await async_client.post(
  916. "/api/v1/auth/forgot-password",
  917. json={"email": "test@example.com"},
  918. )
  919. # Should not be 401 (must be accessible for password reset from login page)
  920. assert response.status_code != 401
  921. # Will likely be 400 (advanced auth not enabled) but that's okay -
  922. # the important thing is it's not blocked by auth middleware
  923. assert response.status_code in [200, 400]
  924. # ===========================================================================
  925. # H-1: Input length validation
  926. # ===========================================================================
  927. class TestInputLengthValidation:
  928. """LoginRequest and SetupRequest must reject oversized inputs (H-1)."""
  929. @pytest.mark.asyncio
  930. @pytest.mark.integration
  931. async def test_login_password_too_long_rejected(self, async_client: AsyncClient):
  932. """Password exceeding 256 characters must be rejected with 422."""
  933. response = await async_client.post(
  934. "/api/v1/auth/login",
  935. json={"username": "admin", "password": "x" * 257},
  936. )
  937. assert response.status_code == 422
  938. @pytest.mark.asyncio
  939. @pytest.mark.integration
  940. async def test_login_username_too_long_rejected(self, async_client: AsyncClient):
  941. """Username exceeding 150 characters must be rejected with 422."""
  942. response = await async_client.post(
  943. "/api/v1/auth/login",
  944. json={"username": "u" * 151, "password": "password"},
  945. )
  946. assert response.status_code == 422
  947. @pytest.mark.asyncio
  948. @pytest.mark.integration
  949. async def test_setup_password_too_long_rejected(self, async_client: AsyncClient):
  950. """SetupRequest admin_password exceeding 256 characters must be rejected with 422."""
  951. response = await async_client.post(
  952. "/api/v1/auth/setup",
  953. json={
  954. "auth_enabled": True,
  955. "admin_username": "admin",
  956. "admin_password": "x" * 257,
  957. },
  958. )
  959. assert response.status_code == 422
  960. @pytest.mark.asyncio
  961. @pytest.mark.integration
  962. async def test_login_password_at_limit_accepted(self, async_client: AsyncClient):
  963. """Password of exactly 256 characters must pass schema validation (may fail auth)."""
  964. response = await async_client.post(
  965. "/api/v1/auth/login",
  966. json={"username": "admin", "password": "x" * 256},
  967. )
  968. # Schema accepts it; auth may reject with 401 (auth disabled) or 400
  969. assert response.status_code != 422