import base64 import hashlib import hmac import re import struct from pathlib import Path from time import time from uuid import uuid4 import jwt import pytest import pytest_asyncio from sanic import Sanic from cista import auth, config, session, watching from cista.app import use_session from cista.fileserver import bp as fileserver_bp def _basic_auth(username: str, password: str) -> dict[str, str]: creds = base64.b64encode(f"{username}:{password}".encode()).decode() return {"Authorization": f"Basic {creds}"} def _ntlm_type1() -> dict[str, str]: msg = b"NTLMSSP\x00" + struct.pack(" dict[str, str]: """Build an NTLMv2 Type 3 message for testing.""" from Crypto.Hash import MD4 # NT hash nt_hash = MD4.new(password.encode("utf-16le")).digest() # NTLMv2 hash ntlmv2_hash = hmac.new(nt_hash, (username.upper() + domain).encode("utf-16le"), hashlib.md5).digest() # Build a minimal blob timestamp = struct.pack(" dict[str, str]: token = jwt.encode( {"exp": int(time()) + session.max_age, "username": username}, session.session_secret(), algorithm="HS256", ) return {"Cookie": f"s={token}"} @pytest.fixture() def setup_storage(tmp_path: Path): user = config.User() auth.set_password(user, "secret") token = config.Token(key="test_token_123", username="alice") config.config = config.Config( path=tmp_path, listen=":0", public=False, users={"alice": user}, tokens={"test_token_123": token}, ) watching.state.root = [] watching.rootpath = tmp_path (tmp_path / "hello.txt").write_text("hello", encoding="utf-8") yield tmp_path watching.state.root = [] @pytest_asyncio.fixture() async def client(setup_storage: Path): app = Sanic(f"files-auth-test-{uuid4().hex}", strict_slashes=True) app.router.ALLOWED_METHODS = ( *app.router.ALLOWED_METHODS, "MKCOL", "MOVE", "COPY", "PROPFIND", ) @app.on_request async def load_auth_context(request): await use_session(request) app.blueprint(fileserver_bp) yield app.asgi_client @pytest.mark.asyncio async def test_basic_auth_allows_private_file_access(client): _, res = await client.get("/files/hello.txt", headers=_basic_auth("alice", "secret")) assert res.status_code == 200 assert res.body == b"hello" assert "set-cookie" not in res.headers @pytest.mark.asyncio async def test_basic_auth_with_invalid_creds_falls_back_to_session_cookie(client): _, res = await client.get( "/files/hello.txt", headers={**_basic_auth("alice", "wrong"), **_session_cookie_header("alice")}, ) assert res.status_code == 200 @pytest.mark.asyncio async def test_options_unauthenticated_allowed(client): _, res = await client.options("/files/") assert res.status_code == 200 @pytest.mark.asyncio async def test_unauthenticated_sends_no_auth_challenge(client): _, res = await client.request("PROPFIND", "/files/") assert res.status_code == 401 assert "www-authenticate" not in res.headers @pytest.mark.asyncio async def test_basic_auth_with_token(client): _, res = await client.get("/files/hello.txt", headers=_basic_auth("token", "test_token_123")) assert res.status_code == 200 assert res.body == b"hello" @pytest.mark.asyncio async def test_browser_unauthenticated_sends_cookie_challenge(client): _, res = await client.get("/files/", headers={"Accept": "text/html,application/xhtml+xml"}) assert res.status_code == 401 assert res.headers.get("www-authenticate", "").lower().startswith("cookie") @pytest.mark.asyncio async def test_ntlm_auth_with_token(client): # Step 1: request without auth should NOT advertise NTLM # (we prefer clients use BASIC; NTLM still works if client initiates it) _, res1 = await client.get("/files/hello.txt") assert res1.status_code == 401 assert "ntlm" not in res1.headers.get("www-authenticate", "").lower() # Step 2: client proactively sends Type 1, gets Type 2 challenge _, res2 = await client.get("/files/hello.txt", headers=_ntlm_type1()) assert res2.status_code == 401 auth_hdr = res2.headers.get("www-authenticate", "") assert auth_hdr.lower().startswith("ntlm ") type2_data = base64.b64decode(auth_hdr.split(" ", 1)[1]) challenge = type2_data[24:32] # Step 3: send Type 3 with token as password _, res3 = await client.get( "/files/hello.txt", headers=_ntlm_type3("anyuser", "test_token_123", "WORKGROUP", challenge), ) assert res3.status_code == 200 assert res3.body == b"hello"