## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later import json import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs from uuid import uuid4 from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from backend.app.main import app from backend.app.database import get_connection from backend.app.services.email_service import get_smtp_settings from backend.app.services.login_throttle import clear_login_failures from backend.app.services.otp_service import current_code from backend.app.services.password_reset import create_reset_token from backend.app.services.token_service import issue_token client = TestClient(app) def login_headers(username='alice'): token = client.post('/api/auth/login', json={ 'email': 'alice@example.com' if username == 'alice' else f'{username}@example.com', 'password': 'secret123', }).json()['access_token'] return {'Authorization': f'Bearer {token}'} def test_login_returns_token(): assert app.version == '0.1.1' response = client.post('/api/auth/login', json={ 'email': 'alice@example.com', 'password': 'secret123', }) assert response.status_code == 200 payload = response.json() assert 'access_token' in payload assert payload['token_type'] == 'bearer' admin_session = client.get('/api/auth/me', headers={'Authorization': f"Bearer {payload['access_token']}"}) assert admin_session.status_code == 200 assert admin_session.json()['is_admin'] is True user_token = client.post('/api/auth/login', json={ 'email': 'bob@example.com', 'password': 'secret123', }).json()['access_token'] user_session = client.get('/api/auth/me', headers={'Authorization': f"Bearer {user_token}"}) assert user_session.status_code == 200 assert user_session.json()['is_admin'] is False assert client.get('/api/auth/me', params={'token': payload['access_token']}).status_code == 401 def test_logout_requires_bearer_header_and_revokes_token_family(): login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json() token = login['access_token'] headers = {'Authorization': f'Bearer {token}'} assert client.post('/api/auth/logout', json={'token': token}).status_code == 401 assert client.get('/api/auth/me', headers=headers).status_code == 200 assert client.post('/api/auth/logout', headers=headers).status_code == 200 assert client.get('/api/auth/me', headers=headers).status_code == 401 assert client.post('/api/auth/refresh', json={'refresh_token': login['refresh_token'], 'device_id': login['device_id']}).status_code == 401 def test_login_rate_limit_locks_out_after_five_failures_and_resets_on_success(): email = f'unknown-{uuid4().hex}@example.com' for attempt in range(5): response = client.post('/api/auth/login', json={'email': email, 'password': 'wrong-password'}) assert response.status_code == 401, attempt locked = client.post('/api/auth/login', json={'email': email, 'password': 'wrong-password'}) assert locked.status_code == 429 assert int(locked.headers['Retry-After']) > 0 clear_login_failures('testclient', email) valid = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}) assert valid.status_code == 200 def test_refresh_token_rotates_and_reuse_revokes_family(): device_id = f'device-{uuid4().hex}' login = client.post('/api/auth/login', json={ 'email': 'alice@example.com', 'password': 'secret123', 'device_id': device_id, }) assert login.status_code == 200 first = login.json() rotated = client.post('/api/auth/refresh', json={ 'refresh_token': first['refresh_token'], 'device_id': device_id, }) assert rotated.status_code == 200 second = rotated.json() assert second['refresh_token'] != first['refresh_token'] assert client.get('/api/auth/me', headers={'Authorization': f"Bearer {second['access_token']}"}).status_code == 200 reused = client.post('/api/auth/refresh', json={ 'refresh_token': first['refresh_token'], 'device_id': device_id, }) assert reused.status_code == 401 family_revoked = client.post('/api/auth/refresh', json={ 'refresh_token': second['refresh_token'], 'device_id': device_id, }) assert family_revoked.status_code == 401 def test_password_hashes_are_salted_and_legacy_hashes_upgrade_on_login(): from hashlib import sha256 from backend.app.database import hash_password first = hash_password('same-password') second = hash_password('same-password') assert first.startswith('scrypt$16384$8$1$') assert first != second legacy_username = f'legacy-{uuid4().hex}' legacy_hash = sha256('legacy-password'.encode('utf-8')).hexdigest() with get_connection() as conn: conn.execute( '''INSERT INTO users (id, username, email, password_hash, is_admin, email_verified) VALUES (?, ?, ?, ?, 0, 1)''', (str(uuid4()), legacy_username, f'{legacy_username}@example.com', legacy_hash), ) conn.commit() response = client.post('/api/auth/login', json={'email': legacy_username + '@example.com', 'password': 'legacy-password'}) assert response.status_code == 200 with get_connection() as conn: upgraded = conn.execute('SELECT password_hash FROM users WHERE username = ?', (legacy_username,)).fetchone()['password_hash'] assert upgraded.startswith('scrypt$16384$8$1$') def test_configuration_requires_authentication_and_admin_role(): assert client.get('/api/user/me').status_code == 401 assert client.get('/api/admin/plugins').status_code == 401 assert client.get('/api/admin/users').status_code == 401 assert client.get('/api/admin/plugins', headers=login_headers('bob')).status_code == 403 assert client.get('/api/admin/users', headers=login_headers('bob')).status_code == 403 assert client.get('/api/admin/smtp').status_code == 401 assert client.get('/api/admin/smtp', headers=login_headers('bob')).status_code == 403 def test_admin_can_select_multiple_themes(): headers = login_headers() response = client.put('/api/admin/themes', headers=headers, json={ 'themes': ['plain-day', 'plain-night', 'latte', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'solarized'], }) assert response.status_code == 200 assert response.json()['enabled'] == ['plain-day', 'plain-night', 'latte', 'frappe', 'macchiato', 'mocha', 'dracula', 'nord', 'solarized'] public_response = client.get('/api/public/themes') assert public_response.status_code == 200 assert [theme['id'] for theme in public_response.json()] == response.json()['enabled'] assert client.put('/api/admin/themes', headers=headers, json={'themes': []}).status_code == 422 def test_admin_can_save_and_validate_smtp_settings(): headers = login_headers() original = get_smtp_settings() with get_connection() as conn: original_row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('smtp',)).fetchone() response = client.put('/api/admin/smtp', headers=headers, json={ 'smtp_host': 'smtp.example.com', 'smtp_port': 587, 'smtp_username': 'mailer', 'smtp_password': 'secret', 'smtp_from': 'LinkLog ', 'smtp_use_tls': True, }) assert response.status_code == 200 assert response.json()['smtp_host'] == 'smtp.example.com' assert response.json()['password_configured'] is True assert 'smtp_password' not in response.json() with patch('backend.app.api.admin.send_test_email') as send_test_email: validation = client.post('/api/admin/smtp/test', headers=headers, json={ 'smtp_host': 'smtp.unsaved.example.com', 'smtp_port': 2525, 'smtp_username': 'temporary-user', 'smtp_password': 'temporary-secret', 'smtp_from': 'Temporary ', 'smtp_use_tls': False, }) assert validation.status_code == 200 send_test_email.assert_called_once_with('alice@example.com', { 'smtp_host': 'smtp.unsaved.example.com', 'smtp_port': 2525, 'smtp_username': 'temporary-user', 'smtp_password': 'temporary-secret', 'smtp_from': 'Temporary ', 'smtp_use_tls': False, }) with get_connection() as conn: if original_row is None: conn.execute('DELETE FROM app_settings WHERE name = ?', ('smtp',)) else: conn.execute( 'UPDATE app_settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?', (original_row['value'], 'smtp'), ) conn.commit() with get_connection() as conn: conn.execute('DELETE FROM app_settings WHERE name = ?', ('admin_smtp_mail_rate',)) conn.commit() def test_admin_reports_smtp_validation_errors(): headers = login_headers() with get_connection() as conn: conn.execute('DELETE FROM app_settings WHERE name = ?', ('admin_smtp_mail_rate',)) conn.commit() with patch('backend.app.api.admin.send_test_email', side_effect=RuntimeError('connection refused')): failed_validation = client.post('/api/admin/smtp/test', headers=headers, json={ 'smtp_host': 'smtp.unsaved.example.com', 'smtp_port': 2525, 'smtp_username': 'temporary-user', 'smtp_password': 'temporary-secret', 'smtp_from': 'Temporary ', 'smtp_use_tls': False, }) assert failed_validation.status_code == 503 assert failed_validation.json()['detail'].startswith('SMTP validation failed. Reference: ') assert 'connection refused' not in failed_validation.json()['detail'] assert failed_validation.headers['X-Request-ID'] def test_request_id_is_preserved_and_sensitive_error_text_is_not_returned(): headers = login_headers() with get_connection() as conn: conn.execute('DELETE FROM app_settings WHERE name = ?', ('admin_smtp_mail_rate',)) conn.commit() with patch('backend.app.api.admin.send_test_email', side_effect=RuntimeError('password=super-secret token=abc123')): response = client.post( '/api/admin/smtp/test', headers={**headers, 'X-Request-ID': 'audit-test-123'}, json={ 'smtp_host': 'smtp.example.com', 'smtp_port': 2525, 'smtp_from': 'admin@example.com', }, ) assert response.status_code == 503 assert response.headers['X-Request-ID'] == 'audit-test-123' assert response.json()['detail'] == 'SMTP validation failed. Reference: audit-test-123' assert 'super-secret' not in response.text assert 'abc123' not in response.text def test_admin_can_add_list_and_remove_users(): headers = login_headers() create_response = client.post('/api/admin/users', headers=headers, json={ 'username': 'charlie', 'email': 'charlie@example.com', 'password': 'charlie-secret', }) assert create_response.status_code == 201 user = create_response.json() assert user['username'] == 'charlie' assert 'password_hash' not in user users = client.get('/api/admin/users', headers=headers).json() assert any(item['id'] == user['id'] for item in users) assert client.delete(f"/api/admin/users/{user['id']}", headers=headers).status_code == 200 assert all(item['id'] != user['id'] for item in client.get('/api/admin/users', headers=headers).json()) assert client.delete('/api/admin/users/user-1', headers=headers).status_code == 400 assert client.put('/api/admin/users/user-1', headers=headers, json={'is_admin': False}).status_code == 400 def test_admin_can_reset_another_users_otp(): admin_headers = login_headers() user_login = client.post('/api/auth/login', json={ 'email': 'bob@example.com', 'password': 'secret123', }).json() user_headers = {'Authorization': f"Bearer {user_login['access_token']}"} setup = client.post('/api/user/otp/setup', headers=user_headers) assert setup.status_code == 200 secret = setup.json()['secret'] recovery_code = setup.json()['recovery_codes'][0] assert client.post('/api/user/otp', headers=user_headers, json={ 'action': 'enable', 'code': current_code(secret), }).status_code == 200 assert client.post('/api/admin/users/user-2/otp/reset', headers=admin_headers).json() == { 'status': 'otp_reset', 'enabled': False, 'user_id': 'user-2', } assert client.get('/api/user/otp', headers=user_headers).json() == {'enabled': False} assert client.post('/api/user/otp/recover', headers=user_headers, json={ 'current_password': 'secret123', 'recovery_code': recovery_code, }).status_code == 400 assert client.post('/api/admin/users/user-2/otp/reset', headers=login_headers('bob')).status_code == 403 assert client.post('/api/admin/users/missing-user/otp/reset', headers=admin_headers).status_code == 404 def test_security_audit_events_are_append_only_and_do_not_store_secrets(): admin_headers = login_headers() response = client.put('/api/admin/themes', headers=admin_headers, json={'themes': ['plain-day']}) assert response.status_code == 200 with get_connection() as conn: event = conn.execute( '''SELECT actor_id, action, target_type, outcome, details FROM security_audit_events WHERE action = 'themes_updated' ORDER BY created_at DESC, rowid DESC LIMIT 1''', ).fetchone() assert event is not None assert event['actor_id'] == 'user-1' assert event['target_type'] == 'application' assert event['outcome'] == 'success' assert 'password' not in event['details'].lower() assert 'token' not in event['details'].lower() assert 'secret' not in event['details'].lower() def test_new_user_must_verify_email_before_login(): headers = login_headers() username = f'unverified-{uuid4().hex}' created = client.post('/api/admin/users', headers=headers, json={ 'username': username, 'email': f'{username}@example.com', 'password': 'secret123', }) assert created.status_code == 201 assert created.json()['email_verified'] is False login = client.post('/api/auth/login', json={'email': f'{username}@example.com', 'password': 'secret123'}) assert login.status_code == 403 assert login.json()['detail'] == 'Email address is not verified' def test_email_verification_link_enables_login(): headers = login_headers() username = f'verifiable-{uuid4().hex}' created = client.post('/api/admin/users', headers=headers, json={ 'username': username, 'email': f'{username}@example.com', 'password': 'secret123', }) assert created.status_code == 201 user_id = created.json()['id'] from backend.app.services.email_verification import create_verification_token verification_token = create_verification_token(user_id) verified = client.get('/api/auth/verify-email', params={'token': verification_token}) assert verified.status_code == 200 assert client.post('/api/auth/login', json={'email': f'{username}@example.com', 'password': 'secret123'}).status_code == 200 assert client.get('/api/auth/verify-email', params={'token': verification_token}).status_code == 400 def test_mistyped_password_sends_reset_link_without_changing_login_error(): username = f'mistyped-{uuid4().hex}' admin_headers = {'Authorization': f"Bearer {issue_token('user-1', 'alice')['access_token']}"} created = client.post('/api/admin/users', headers=admin_headers, json={ 'username': username, 'email': f'{username}@example.com', 'password': 'secret123', }) user_id = created.json()['id'] with get_connection() as conn: conn.execute('UPDATE users SET email_verified = 1 WHERE id = ?', (user_id,)) conn.commit() with patch('backend.app.api.auth.smtp_configured', return_value=True), \ patch('backend.app.api.auth.create_reset_token', return_value='reset-token') as create_token, \ patch('backend.app.api.auth.send_password_reset_email') as send_email: response = client.post('/api/auth/login', json={'email': f'{username}@example.com', 'password': 'wrong-password'}) assert response.status_code == 401 assert response.json()['detail'] == 'Invalid username or password' create_token.assert_called_once_with(user_id) send_email.assert_called_once() assert send_email.call_args.args[2].endswith('/reset-password?token=reset-token') def test_password_reset_is_single_use_and_revokes_sessions(): username = f'reset-owner-{uuid4().hex}' admin_headers = {'Authorization': f"Bearer {issue_token('user-1', 'alice')['access_token']}"} created = client.post('/api/admin/users', headers=admin_headers, json={ 'username': username, 'email': f'{username}@example.com', 'password': 'secret123', }) user_id = created.json()['id'] with get_connection() as conn: conn.execute('UPDATE users SET email_verified = 1 WHERE id = ?', (user_id,)) conn.commit() token = create_reset_token(user_id) reset = client.post('/api/auth/reset-password', json={'token': token, 'password': 'new-secret123'}) assert reset.status_code == 200 assert client.post('/api/auth/reset-password', json={'token': token, 'password': 'another-secret'}).status_code == 400 assert client.post('/api/auth/login', json={'email': f'{username}@example.com', 'password': 'new-secret123'}).status_code == 200 def test_admin_can_remove_user_with_owned_data(): headers = login_headers() username = f'data-owner-{uuid4().hex}' create_response = client.post('/api/admin/users', headers=headers, json={ 'username': username, 'email': f'{username}@example.com', 'password': 'secret123', }) assert create_response.status_code == 201 user_id = create_response.json()['id'] user_token = issue_token(user_id, username)['access_token'] user_headers = {'Authorization': f'Bearer {user_token}'} link_response = client.post('/api/links', headers=user_headers, json={ 'title': 'Owned link', 'url': 'https://example.com/owned', 'comment': 'Owned data', 'tags': ['owned'], }) assert link_response.status_code == 201 label_response = client.post('/api/user/labels', headers=user_headers, json={'name': f'{username}-label'}) assert label_response.status_code == 201 plugin_response = client.put('/api/user/plugins/mastodon', headers=user_headers, json={ 'instance': 'mastodon.social', }) assert plugin_response.status_code == 200 removed = client.delete(f'/api/admin/users/{user_id}', headers=headers) assert removed.status_code == 200 assert client.get('/api/auth/me', params={'token': user_token}).status_code == 401 def test_deleting_link_removes_all_mastodon_posts_first(): owner_headers = login_headers('alice') created = client.post('/api/links', headers=owner_headers, json={ 'title': 'Remote cleanup', 'url': 'https://example.com/remote-cleanup', }) assert created.status_code == 201 link_id = created.json()['id'] with get_connection() as conn: conn.execute( 'UPDATE links SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ? WHERE id = ?', ('post-2', json.dumps(['post-1', 'post-2']), link_id), ) conn.commit() with patch('backend.app.api.links.plugin_manager.delete_mastodon_posts', return_value={'status': 'deleted', 'count': 2}) as delete_posts: deleted = client.delete(f'/api/links/{link_id}', headers=owner_headers) assert deleted.status_code == 200 delete_posts.assert_called_once() assert delete_posts.call_args.args[0]['mastodon_post_ids'] == ['post-1', 'post-2'] assert client.delete(f'/api/links/{link_id}', headers=owner_headers).status_code == 404 def test_link_is_kept_when_mastodon_cleanup_fails(): owner_headers = login_headers('alice') created = client.post('/api/links', headers=owner_headers, json={ 'title': 'Failed remote cleanup', 'url': 'https://example.com/failed-remote-cleanup', }) link_id = created.json()['id'] with get_connection() as conn: conn.execute( 'UPDATE links SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ? WHERE id = ?', ('post-failed', json.dumps(['post-failed']), link_id), ) conn.commit() with patch('backend.app.api.links.plugin_manager.delete_mastodon_posts', return_value={'status': 'failed', 'reason': 'remote refused'}): deleted = client.delete(f'/api/links/{link_id}', headers=owner_headers) assert deleted.status_code == 502 assert 'remote refused' in deleted.json()['detail'] assert client.get('/api/links').json() def test_admin_can_toggle_privileges_without_removing_last_admin(): headers = login_headers() bob = next(user for user in client.get('/api/admin/users', headers=headers).json() if user['username'] == 'bob') promote = client.put(f"/api/admin/users/{bob['id']}", headers=headers, json={'is_admin': True}) assert promote.status_code == 200 assert promote.json()['is_admin'] is True demote = client.put(f"/api/admin/users/{bob['id']}", headers=headers, json={'is_admin': False}) assert demote.status_code == 200 assert demote.json()['is_admin'] is False last_admin = client.put('/api/admin/users/user-1', headers=headers, json={'is_admin': False}) assert last_admin.status_code == 400 def test_users_manage_owned_labels_and_admin_can_delete_any_label(): alice_headers = login_headers('alice') created = client.post('/api/user/labels', headers=alice_headers, json={'name': 'My Label'}) assert created.status_code == 201 label = created.json() assert label['name'] == '#My Label' edited = client.put(f"/api/user/labels/{label['id']}", headers=alice_headers, json={'name': '#Renamed'}) assert edited.status_code == 200 assert edited.json()['name'] == '#Renamed' denied = client.put(f"/api/user/labels/{label['id']}", headers=login_headers('bob'), json={'name': '#Nope'}) assert denied.status_code == 404 assert client.delete(f"/api/user/labels/{label['id']}", headers=login_headers('bob')).status_code == 404 admin_delete = client.delete(f"/api/admin/labels/{label['id']}", headers=alice_headers) assert admin_delete.status_code == 200 def test_labels_page_renders_authenticated_management_shell(): page = client.get('/labels') assert page.status_code == 200 assert 'Labels' in page.text assert 'labels.js?v=1' in page.text def test_submit_link_stores_cleaned_url_and_public_feed(): response = client.post('/api/links', headers=login_headers(), json={ 'title': 'Example page', 'url': 'https://example.com/path?utm_source=ad&utm_medium=email&keep=yes', 'comment': 'Interesting read', 'timestamp': '2026-08-24T12:00:00Z', }) assert response.status_code == 201 data = response.json() assert data['url'] == 'https://example.com/path?keep=yes' feed = client.get('/api/public/feed').json() matching = next(item for item in feed if item['id'] == data['id']) assert matching['comment'] == 'Interesting read' assert matching['user']['username'] == 'alice' assert 'avatar_url' in matching['user'] filtered_response = client.get('/api/public/feed/alice') assert filtered_response.status_code == 200 assert all(item['user']['username'] == 'alice' for item in filtered_response.json()) assert client.get('/api/public/feed/does-not-exist').json() == [] users_response = client.get('/api/public/users') assert users_response.status_code == 200 assert 'alice' in users_response.json() def test_duplicate_link_updates_comment_tags_and_retriggers_plugins(): headers = login_headers() payload = { 'title': 'Duplicate candidate', 'url': 'https://example.com/duplicate?utm_source=campaign', 'comment': 'first comment', 'tags': ['#First'], } first = client.post('/api/links', headers=headers, json=payload) assert first.status_code == 201 with patch('backend.app.api.links.plugin_manager.dispatch', return_value=[]) as dispatch: duplicate = client.post('/api/links', headers=headers, json={ **payload, 'comment': 'updated comment', 'tags': ['#Second'], }) assert duplicate.status_code == 200 assert duplicate.json()['duplicate'] is True assert duplicate.json()['id'] == first.json()['id'] dispatch.assert_called_once() assert dispatch.call_args.args[0]['comment'] == 'updated comment' assert dispatch.call_args.args[0]['tags'] == ['#Second'] feed_item = next(item for item in client.get('/api/public/feed').json() if item['id'] == first.json()['id']) assert feed_item['comment'] == 'updated comment' assert feed_item['tags'] == ['#Second'] def test_duplicate_link_check_is_authenticated_and_detects_existing_entry(): headers = login_headers() payload = {'title': 'Check candidate', 'url': 'https://example.com/check-candidate'} assert client.get('/api/links/check', params=payload).status_code == 401 created = client.post('/api/links', headers=headers, json=payload) assert created.status_code == 201 check = client.get('/api/links/check', headers=headers, params=payload) assert check.status_code == 200 assert check.json()['exists'] is True def test_link_save_reports_plugin_posting_errors(): with patch('backend.app.api.links.plugin_manager.dispatch', return_value=[ {'status': 'failed', 'plugin': 'mastodon', 'reason': 'HTTP 503: unavailable'}, ]): response = client.post('/api/links', headers=login_headers(), json={ 'title': 'Plugin error report', 'url': 'https://example.com/plugin-error-report', }) assert response.status_code == 201 assert response.json()['plugin_errors'] == [{'plugin': 'mastodon', 'reason': 'HTTP 503: unavailable'}] def test_links_support_tags_and_tag_filtering(): headers = login_headers() response = client.post('/api/links', headers=headers, json={ 'title': 'Tagged page', 'url': 'https://example.com/tagged', 'tags': ['#CasePreserved', '#web', 'casepreserved'], }) assert response.status_code == 201 link_id = response.json()['id'] assert response.json()['tags'] == ['#CasePreserved', '#web'] tags = client.get('/api/tags').json() assert any(tag.casefold() == '#casepreserved' for tag in tags) and '#web' in tags assert { '#Internet', '#Cybersecurity', '#Fediverse', '#Food', '#Photography', '#Music', '#AI' } <= set(tags) tagged_feed = client.get('/api/public/feed').json() tagged_item = next(item for item in tagged_feed if item['id'] == link_id) assert {tag.casefold() for tag in tagged_item['tags']} == {'#casepreserved', '#web'} too_many = client.post('/api/links', headers=headers, json={ 'title': 'Too many tags', 'url': 'https://example.com/too-many', 'tags': [f'tag-{index}' for index in range(11)], }) assert too_many.status_code == 422 edited = client.put(f'/api/links/{link_id}', headers=headers, json={ 'title': 'Tagged page', 'url': 'https://example.com/tagged', 'tags': ['edited'], }) assert edited.status_code == 200 assert edited.json()['tags'] == ['#edited'] def test_only_link_owner_can_edit_link(): owner_headers = login_headers('alice') response = client.post('/api/links', headers=owner_headers, json={ 'title': 'Editable link', 'url': 'https://example.com/editable?utm_source=test', 'comment': 'Before edit', }) assert response.status_code == 201 link_id = response.json()['id'] unauthenticated = client.put(f'/api/links/{link_id}', json={ 'title': 'Not allowed', 'url': 'https://example.com/not-allowed', }) assert unauthenticated.status_code == 401 anonymous_feed = client.get('/api/public/feed').json() anonymous_link = next(item for item in anonymous_feed if item['id'] == link_id) assert anonymous_link['is_owner'] is False assert anonymous_link['can_edit'] is False owner_feed = client.get('/api/public/feed', headers=owner_headers).json() owner_link = next(item for item in owner_feed if item['id'] == link_id) assert owner_link['is_owner'] is True assert owner_link['can_edit'] is True edited = client.put(f'/api/links/{link_id}', headers=owner_headers, json={ 'title': 'Edited link', 'url': 'https://example.com/edited', 'comment': 'After edit', }) assert edited.status_code == 200 assert edited.json()['title'] == 'Edited link' denied = client.put(f'/api/links/{link_id}', headers=login_headers('bob'), json={ 'title': 'Not allowed', 'url': 'https://example.com/not-allowed', }) assert denied.status_code == 404 deleted = client.delete(f'/api/links/{link_id}', headers=owner_headers) assert deleted.status_code == 200 assert client.delete(f'/api/links/{link_id}', headers=owner_headers).status_code == 404 assert client.delete(f'/api/links/{link_id}', headers=login_headers('bob')).status_code == 404 def test_logout_revokes_token_and_admin_can_list_plugins(): headers = login_headers() assert client.post('/api/auth/logout', headers=headers).status_code == 200 revoked_response = client.post('/api/links', headers=headers, json={ 'title': 'Should fail', 'url': 'https://example.com/path', 'comment': 'nope', }) assert revoked_response.status_code == 401 plugins_response = client.get('/api/admin/plugins', headers=login_headers()) assert plugins_response.status_code == 200 assert any(plugin['name'] == 'default_frontend' for plugin in plugins_response.json()) def test_public_and_admin_pages_render_html(): root_page = client.get('/') assert 'LinkLog' in root_page.text assert 'src="/static/logo.svg"' in root_page.text assert 'class="site-footer"' in root_page.text assert 'https://git.kolkman.org/' in root_page.text assert 'class="menu-toggle"' in root_page.text assert 'id="auth-menu" class="auth-menu hidden"' in root_page.text assert 'id="auth-home-link" href="/">Home' in root_page.text assert '' in root_page.text assert 'id="auth-avatar"' not in root_page.text assert 'id="auth-login-button" href="/login"' in root_page.text assert 'id="auth-profile-link" class="hidden"' in root_page.text assert 'id="auth-admin-link" class="hidden"' in root_page.text assert '