Clean up tests for query param conversion and remove auth
- Deleted test_auth.py (auth no longer exists) - Rewrote TestOrderDelayRoute -> TestRootRedirectWithOrderDelay using query params - Updated TestHashPageWithRefresh to use ?order=...&delay=... URLs - Added play button query param assertion in TestHashPage - Removed password=None from test_navigation.py seeded_indexers fixture - Formatted with black, all 59 tests passing
This commit is contained in:
parent
22bf57f896
commit
f856c92394
BIN
2024_seasonal_wallpapers_pack.zip
Normal file
BIN
2024_seasonal_wallpapers_pack.zip
Normal file
Binary file not shown.
14
TODO.md
14
TODO.md
@ -26,19 +26,19 @@ Remove all authentication from the server and convert `order`/`delay` from path
|
|||||||
- [x] Remove `password` field from `args_directory`/`args_zip` fixtures (or keep as None if still in argparse)
|
- [x] Remove `password` field from `args_directory`/`args_zip` fixtures (or keep as None if still in argparse)
|
||||||
|
|
||||||
### 4. Update `test_auth.py`
|
### 4. Update `test_auth.py`
|
||||||
- [ ] Delete the entire `test_auth.py` file (all auth tests are no longer relevant)
|
- [x] Delete the entire `test_auth.py` file (all auth tests are no longer relevant)
|
||||||
|
|
||||||
### 5. Update `test_endpoints.py`
|
### 5. Update `test_endpoints.py`
|
||||||
- [ ] Update `TestOrderDelayRoute` tests — remove or rewrite for query param routes
|
- [x] Update `TestOrderDelayRoute` tests — remove or rewrite for query param routes
|
||||||
- [ ] Update `TestHashPageWithRefresh` tests to use query param URLs (`/{hash}?order=next&delay=5`)
|
- [x] Update `TestHashPageWithRefresh` tests to use query param URLs (`/{hash}?order=next&delay=5`)
|
||||||
- [ ] Update `TestHashPage` tests if needed (play button URLs changed)
|
- [x] Update `TestHashPage` tests if needed (play button URLs changed)
|
||||||
|
|
||||||
### 6. Update `test_navigation.py`
|
### 6. Update `test_navigation.py`
|
||||||
- [ ] Remove `password=None` from `seeded_indexers` fixture args (if `--password` arg is removed)
|
- [x] Remove `password=None` from `seeded_indexers` fixture args (if `--password` arg is removed)
|
||||||
|
|
||||||
### 7. Format and verify
|
### 7. Format and verify
|
||||||
- [ ] Run `uv run black .` to format all code
|
- [x] Run `uv run black .` to format all code
|
||||||
- [ ] Run `uv run pytest` to verify all tests pass
|
- [x] Run `uv run pytest` to verify all tests pass
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- After removing auth, the `--password` CLI arg is gone entirely
|
- After removing auth, the `--password` CLI arg is gone entirely
|
||||||
|
|||||||
14
main.py
14
main.py
@ -253,9 +253,7 @@ def _render_page(
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/{file_hash}")
|
@app.get("/{file_hash}")
|
||||||
async def hash_page(
|
async def hash_page(file_hash: str, order: str | None = None, delay: int | None = None):
|
||||||
file_hash: str, order: str | None = None, delay: int | None = None
|
|
||||||
):
|
|
||||||
"""Serve a page for a specific file hash with optional auto-refresh navigation.
|
"""Serve a page for a specific file hash with optional auto-refresh navigation.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -275,16 +273,18 @@ async def hash_page(
|
|||||||
|
|
||||||
if order is not None and delay is not None:
|
if order is not None and delay is not None:
|
||||||
# Timer mode: auto-refresh with query params
|
# Timer mode: auto-refresh with query params
|
||||||
refresh_url = _build_url(
|
refresh_url = _build_url(navigation_data["next_hash"], order=order, delay=delay)
|
||||||
navigation_data["next_hash"], order=order, delay=delay
|
refresh_meta = (
|
||||||
|
f'<meta http-equiv="refresh" content="{delay};url={refresh_url}">'
|
||||||
)
|
)
|
||||||
refresh_meta = f'<meta http-equiv="refresh" content="{delay};url={refresh_url}">'
|
|
||||||
image_click_url = _build_url(file_hash)
|
image_click_url = _build_url(file_hash)
|
||||||
|
|
||||||
# Create pause button to stop auto-refresh
|
# Create pause button to stop auto-refresh
|
||||||
pause_button = '<a href="{file_hash}" class="play-btn" title="Pause">⏸</a>'.format(
|
pause_button = (
|
||||||
|
'<a href="{file_hash}" class="play-btn" title="Pause">⏸</a>'.format(
|
||||||
file_hash=file_hash
|
file_hash=file_hash
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return _render_page(
|
return _render_page(
|
||||||
navigation_data,
|
navigation_data,
|
||||||
|
|||||||
@ -1,189 +0,0 @@
|
|||||||
"""Tests for authentication."""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
|
|
||||||
import main
|
|
||||||
|
|
||||||
|
|
||||||
def _basic_auth_header(username: str, password: str) -> str:
|
|
||||||
"""Create a Basic Auth header value."""
|
|
||||||
creds = f"{username}:{password}"
|
|
||||||
return f"Basic {base64.b64encode(creds.encode()).decode()}"
|
|
||||||
|
|
||||||
|
|
||||||
def _make_args(tmp_path: Path) -> argparse.Namespace:
|
|
||||||
"""Create an argparse.Namespace for the given path."""
|
|
||||||
return argparse.Namespace(
|
|
||||||
source=str(tmp_path),
|
|
||||||
host="127.0.0.1",
|
|
||||||
port=0,
|
|
||||||
salt="auth-salt",
|
|
||||||
password=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def auth_setup(tmp_path: Path) -> tuple[str, str]:
|
|
||||||
"""Set up server with sample files and password protection.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (username, password).
|
|
||||||
"""
|
|
||||||
(tmp_path / "test.txt").write_text("hello")
|
|
||||||
main.initialize_server(_make_args(tmp_path))
|
|
||||||
main.set_auth_password("secret123")
|
|
||||||
return ("user", "secret123")
|
|
||||||
|
|
||||||
|
|
||||||
class TestNoPasswordSet:
|
|
||||||
"""Tests when no password is configured.
|
|
||||||
|
|
||||||
Note: HTTPBasic() always requires an Authorization header.
|
|
||||||
When expected_password is None, any credentials pass.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def test_health_always_open(self, client_dir: AsyncClient) -> None:
|
|
||||||
"""Health check has no auth dependency — always accessible."""
|
|
||||||
response = await client_dir.get("/api/health")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
async def test_protected_endpoint_requires_auth_header(
|
|
||||||
self, initialized_dir: None
|
|
||||||
) -> None:
|
|
||||||
"""Even with no password, HTTPBasic requires an auth header."""
|
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
|
||||||
# No auth header → 401 from HTTPBasic
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
response = await ac.get(f"/api/{file_hash}/data")
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
async def test_any_credentials_pass_when_no_password(
|
|
||||||
self, client_dir: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
"""Any credentials pass when no password is set."""
|
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
ac.headers["Authorization"] = _basic_auth_header("any", "thing")
|
|
||||||
response = await ac.get(f"/api/{file_hash}/data")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
async def test_root_requires_auth_header(self, initialized_dir: None) -> None:
|
|
||||||
"""Root endpoint requires auth header even with no password."""
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
response = await ac.get("/", follow_redirects=False)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
class TestCorrectPassword:
|
|
||||||
"""Tests with correct password."""
|
|
||||||
|
|
||||||
async def test_health_with_correct_password(
|
|
||||||
self, auth_setup: tuple[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""Health check works (it has no auth, always 200)."""
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
response = await ac.get("/api/health")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
async def test_file_access_with_correct_password(
|
|
||||||
self, auth_setup: tuple[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""File access works with correct password."""
|
|
||||||
username, password = auth_setup
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
ac.headers["Authorization"] = _basic_auth_header(username, password)
|
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
|
||||||
response = await ac.get(f"/api/{file_hash}/data")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
async def test_root_with_correct_password(
|
|
||||||
self, auth_setup: tuple[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""Root redirect works with correct password."""
|
|
||||||
username, password = auth_setup
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
ac.headers["Authorization"] = _basic_auth_header(username, password)
|
|
||||||
response = await ac.get("/", follow_redirects=False)
|
|
||||||
assert response.status_code in (307, 302, 301)
|
|
||||||
|
|
||||||
async def test_hash_page_with_correct_password(
|
|
||||||
self, auth_setup: tuple[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""Hash page works with correct password."""
|
|
||||||
username, password = auth_setup
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
ac.headers["Authorization"] = _basic_auth_header(username, password)
|
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
|
||||||
response = await ac.get(f"/{file_hash}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
class TestWrongPassword:
|
|
||||||
"""Tests with incorrect password."""
|
|
||||||
|
|
||||||
async def test_file_access_with_wrong_password(
|
|
||||||
self, auth_setup: tuple[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""File access returns 401 with wrong password."""
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
ac.headers["Authorization"] = _basic_auth_header("user", "wrong")
|
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
|
||||||
response = await ac.get(f"/api/{file_hash}/data")
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
async def test_root_with_wrong_password(self, auth_setup: tuple[str, str]) -> None:
|
|
||||||
"""Root redirect returns 401 with wrong password."""
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
ac.headers["Authorization"] = _basic_auth_header("user", "wrong")
|
|
||||||
response = await ac.get("/", follow_redirects=False)
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
async def test_no_auth_header_returns_401(
|
|
||||||
self, auth_setup: tuple[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""Missing auth header returns 401."""
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
|
||||||
response = await ac.get(f"/api/{file_hash}/data")
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
async def test_includes_www_authenticate_header(
|
|
||||||
self, auth_setup: tuple[str, str]
|
|
||||||
) -> None:
|
|
||||||
"""401 response includes WWW-Authenticate header."""
|
|
||||||
transport = ASGITransport(app=main.app)
|
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
|
||||||
response = await ac.get(f"/api/{file_hash}/data")
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert "www-authenticate" in response.headers
|
|
||||||
|
|
||||||
|
|
||||||
class TestSetAuthPassword:
|
|
||||||
"""Tests for set_auth_password function."""
|
|
||||||
|
|
||||||
def test_sets_password(self) -> None:
|
|
||||||
"""Password is set correctly."""
|
|
||||||
main.set_auth_password("newpass")
|
|
||||||
assert main.expected_password == "newpass"
|
|
||||||
|
|
||||||
def test_clears_password_with_none(self) -> None:
|
|
||||||
"""Passing None clears the password."""
|
|
||||||
main.set_auth_password("something")
|
|
||||||
main.set_auth_password(None)
|
|
||||||
assert main.expected_password is None
|
|
||||||
@ -101,20 +101,28 @@ class TestRootRedirect:
|
|||||||
assert hash_from_url in main.file_mapping
|
assert hash_from_url in main.file_mapping
|
||||||
|
|
||||||
|
|
||||||
class TestOrderDelayRoute:
|
class TestRootRedirectWithOrderDelay:
|
||||||
"""Tests for GET /{order}/{delay}."""
|
"""Tests for GET / with order/delay query parameters."""
|
||||||
|
|
||||||
async def test_next_order_redirects(self, client_dir: AsyncClient) -> None:
|
async def test_next_order_redirects(self, client_dir: AsyncClient) -> None:
|
||||||
"""/next/5 redirects to /next/5/{hash}."""
|
"""/?order=next&delay=5 redirects to /{hash}?order=next&delay=5."""
|
||||||
response = await client_dir.get("/next/5", follow_redirects=False)
|
response = await client_dir.get(
|
||||||
|
"/", params={"order": "next", "delay": 5}, follow_redirects=False
|
||||||
|
)
|
||||||
assert response.status_code in (307, 302, 301)
|
assert response.status_code in (307, 302, 301)
|
||||||
assert "/next/5/" in response.headers["location"]
|
location = response.headers["location"]
|
||||||
|
assert "order=next" in location
|
||||||
|
assert "delay=5" in location
|
||||||
|
|
||||||
async def test_random_order_redirects(self, client_dir: AsyncClient) -> None:
|
async def test_random_order_redirects(self, client_dir: AsyncClient) -> None:
|
||||||
"""/random/3 redirects to /random/3/{hash}."""
|
"""/?order=random&delay=3 redirects to /{hash}?order=random&delay=3."""
|
||||||
response = await client_dir.get("/random/3", follow_redirects=False)
|
response = await client_dir.get(
|
||||||
|
"/", params={"order": "random", "delay": 3}, follow_redirects=False
|
||||||
|
)
|
||||||
assert response.status_code in (307, 302, 301)
|
assert response.status_code in (307, 302, 301)
|
||||||
assert "/random/3/" in response.headers["location"]
|
location = response.headers["location"]
|
||||||
|
assert "order=random" in location
|
||||||
|
assert "delay=3" in location
|
||||||
|
|
||||||
|
|
||||||
class TestHashPage:
|
class TestHashPage:
|
||||||
@ -142,6 +150,12 @@ class TestHashPage:
|
|||||||
assert 'class="chevron left"' in response.text
|
assert 'class="chevron left"' in response.text
|
||||||
assert 'class="chevron right"' in response.text
|
assert 'class="chevron right"' in response.text
|
||||||
|
|
||||||
|
async def test_page_contains_play_button(self, client_dir: AsyncClient) -> None:
|
||||||
|
"""HTML page contains play button with query param URL."""
|
||||||
|
file_hash = list(main.file_mapping.keys())[0]
|
||||||
|
response = await client_dir.get(f"/{file_hash}")
|
||||||
|
assert "?order=next&delay=5" in response.text
|
||||||
|
|
||||||
async def test_returns_404_for_invalid_hash(self, client_dir: AsyncClient) -> None:
|
async def test_returns_404_for_invalid_hash(self, client_dir: AsyncClient) -> None:
|
||||||
"""Returns 404 for a hash that doesn't exist."""
|
"""Returns 404 for a hash that doesn't exist."""
|
||||||
response = await client_dir.get("/nonexistent-hash")
|
response = await client_dir.get("/nonexistent-hash")
|
||||||
@ -149,37 +163,48 @@ class TestHashPage:
|
|||||||
|
|
||||||
|
|
||||||
class TestHashPageWithRefresh:
|
class TestHashPageWithRefresh:
|
||||||
"""Tests for GET /{order}/{delay}/{file_hash}."""
|
"""Tests for GET /{file_hash}?order=...&delay=... (auto-refresh mode)."""
|
||||||
|
|
||||||
async def test_next_order_returns_html(self, client_dir: AsyncClient) -> None:
|
async def test_next_order_returns_html(self, client_dir: AsyncClient) -> None:
|
||||||
"""Next order returns HTML with refresh meta tag."""
|
"""Next order returns HTML with refresh meta tag."""
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
file_hash = list(main.file_mapping.keys())[0]
|
||||||
response = await client_dir.get(f"/next/5/{file_hash}")
|
response = await client_dir.get(
|
||||||
|
f"/{file_hash}", params={"order": "next", "delay": 5}
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'http-equiv="refresh"' in response.text
|
assert 'http-equiv="refresh"' in response.text
|
||||||
|
|
||||||
async def test_random_order_returns_html(self, client_dir: AsyncClient) -> None:
|
async def test_random_order_returns_html(self, client_dir: AsyncClient) -> None:
|
||||||
"""Random order returns HTML with refresh meta tag."""
|
"""Random order returns HTML with refresh meta tag."""
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
file_hash = list(main.file_mapping.keys())[0]
|
||||||
response = await client_dir.get(f"/random/3/{file_hash}")
|
response = await client_dir.get(
|
||||||
|
f"/{file_hash}", params={"order": "random", "delay": 3}
|
||||||
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'http-equiv="refresh"' in response.text
|
assert 'http-equiv="refresh"' in response.text
|
||||||
|
|
||||||
async def test_invalid_order_returns_400(self, client_dir: AsyncClient) -> None:
|
async def test_invalid_order_returns_400(self, client_dir: AsyncClient) -> None:
|
||||||
"""Invalid order parameter returns 400."""
|
"""Invalid order parameter returns 400."""
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
file_hash = list(main.file_mapping.keys())[0]
|
||||||
response = await client_dir.get(f"/shuffle/5/{file_hash}")
|
response = await client_dir.get(
|
||||||
|
f"/{file_hash}", params={"order": "shuffle", "delay": 5}
|
||||||
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
async def test_returns_404_for_invalid_hash(self, client_dir: AsyncClient) -> None:
|
async def test_returns_404_for_invalid_hash(self, client_dir: AsyncClient) -> None:
|
||||||
"""Returns 404 for a hash that doesn't exist."""
|
"""Returns 404 for a hash that doesn't exist."""
|
||||||
response = await client_dir.get("/next/5/nonexistent-hash")
|
response = await client_dir.get(
|
||||||
|
"/nonexistent-hash", params={"order": "next", "delay": 5}
|
||||||
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
async def test_refresh_url_points_to_next_file(
|
async def test_refresh_url_points_to_next_file(
|
||||||
self, client_dir: AsyncClient
|
self, client_dir: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Refresh meta tag points to the next file in sequence."""
|
"""Refresh meta tag points to the next file in sequence with query params."""
|
||||||
file_hash = list(main.file_mapping.keys())[0]
|
file_hash = list(main.file_mapping.keys())[0]
|
||||||
response = await client_dir.get(f"/next/5/{file_hash}")
|
response = await client_dir.get(
|
||||||
assert "url=/next/5/" in response.text
|
f"/{file_hash}", params={"order": "next", "delay": 5}
|
||||||
|
)
|
||||||
|
assert "order=next" in response.text
|
||||||
|
assert "delay=5" in response.text
|
||||||
|
|||||||
@ -16,7 +16,6 @@ def seeded_indexers(sample_files: dict[str, Path], tmp_path: Path) -> None:
|
|||||||
host="127.0.0.1",
|
host="127.0.0.1",
|
||||||
port=0,
|
port=0,
|
||||||
salt="nav-test-salt",
|
salt="nav-test-salt",
|
||||||
password=None,
|
|
||||||
)
|
)
|
||||||
main.initialize_server(args)
|
main.initialize_server(args)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user