309 lines
8.7 KiB
Python
309 lines
8.7 KiB
Python
# -*- coding: utf-8 -*-
|
|
# pylint: disable=redefined-outer-name
|
|
# -*- coding: utf-8 -*-
|
|
"""Auth=true real-link integration tests — Sprint 3.4-A.
|
|
|
|
Spawns a dedicated subprocess with QWENPAW_AUTH_ENABLED=true and
|
|
seeded credentials, then exercises:
|
|
A1 GET /api/auth/status reflects auth enabled + has_users
|
|
A2 POST /api/auth/login with correct credentials returns token
|
|
A3 POST /api/auth/login with wrong password returns 401
|
|
A4 Protected endpoint without token returns 401
|
|
A5 Protected endpoint with valid Bearer token returns 200
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Iterator
|
|
|
|
import httpx
|
|
import pytest
|
|
from helpers import default_http_timeout
|
|
|
|
|
|
_HTTP_TIMEOUT = default_http_timeout(15.0)
|
|
_AUTH_USERNAME = "integ-admin"
|
|
_AUTH_PASSWORD = "integ-pass-12345"
|
|
|
|
|
|
@dataclass
|
|
class _AuthAppServer:
|
|
host: str
|
|
port: int
|
|
client: httpx.Client
|
|
logs: list[str]
|
|
|
|
@property
|
|
def base_url(self) -> str:
|
|
return f"http://{self.host}:{self.port}"
|
|
|
|
def get(self, path: str, **kwargs):
|
|
return self.client.get(f"{self.base_url}{path}", **kwargs)
|
|
|
|
def post(self, path: str, **kwargs):
|
|
return self.client.post(f"{self.base_url}{path}", **kwargs)
|
|
|
|
|
|
def _find_free_port(host: str = "127.0.0.1") -> int:
|
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
sock.bind((host, 0))
|
|
sock.listen(1)
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
def _tee(stream, buf: list[str]) -> None:
|
|
for line in stream:
|
|
buf.append(line)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def auth_app_server( # pylint: disable=too-many-statements
|
|
tmp_path_factory,
|
|
) -> Iterator[_AuthAppServer]:
|
|
"""Spawn a qwenpaw app subprocess with auth=true + seeded user."""
|
|
tmp_path = tmp_path_factory.mktemp("auth_app_server")
|
|
host = "127.0.0.1"
|
|
port = _find_free_port(host)
|
|
|
|
working_dir = tmp_path / "working"
|
|
secret_dir = tmp_path / "working.secret"
|
|
backups_dir = tmp_path / "working.backups"
|
|
working_dir.mkdir(parents=True, exist_ok=True)
|
|
secret_dir.mkdir(parents=True, exist_ok=True)
|
|
backups_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
env = os.environ.copy()
|
|
for key in (
|
|
"OPENAI_API_KEY",
|
|
"ANTHROPIC_API_KEY",
|
|
"DASHSCOPE_API_KEY",
|
|
):
|
|
env.pop(key, None)
|
|
env["QWENPAW_WORKING_DIR"] = str(working_dir)
|
|
env["QWENPAW_SECRET_DIR"] = str(secret_dir)
|
|
env["QWENPAW_BACKUP_DIR"] = str(backups_dir)
|
|
env["QWENPAW_AUTH_ENABLED"] = "true"
|
|
env["QWENPAW_AUTH_USERNAME"] = _AUTH_USERNAME
|
|
env["QWENPAW_AUTH_PASSWORD"] = _AUTH_PASSWORD
|
|
env["QWENPAW_UPLOAD_MAX_SIZE_MB"] = "10"
|
|
env["NO_PROXY"] = "*"
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
env["PYTHONIOENCODING"] = "utf-8"
|
|
|
|
logs: list[str] = []
|
|
with subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
"-m",
|
|
"qwenpaw",
|
|
"app",
|
|
"--host",
|
|
host,
|
|
"--port",
|
|
str(port),
|
|
"--log-level",
|
|
"info",
|
|
],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
env=env,
|
|
) as process:
|
|
assert process.stdout is not None
|
|
log_thread = threading.Thread(
|
|
target=_tee,
|
|
args=(process.stdout, logs),
|
|
daemon=True,
|
|
)
|
|
log_thread.start()
|
|
|
|
client = httpx.Client(timeout=_HTTP_TIMEOUT, trust_env=False)
|
|
try:
|
|
deadline = time.time() + 60.0
|
|
ready = False
|
|
while time.time() < deadline:
|
|
if process.poll() is not None:
|
|
raise AssertionError(
|
|
"qwenpaw app exited during startup\n"
|
|
f"logs:\n{''.join(logs)[-3000:]}",
|
|
)
|
|
try:
|
|
r = client.get(
|
|
f"http://{host}:{port}/api/version",
|
|
)
|
|
if r.status_code == 200:
|
|
ready = True
|
|
break
|
|
except (httpx.ConnectError, httpx.TimeoutException):
|
|
pass
|
|
time.sleep(0.5)
|
|
if not ready:
|
|
raise AssertionError(
|
|
f"app not ready in time:\n" f"{''.join(logs)[-3000:]}",
|
|
)
|
|
|
|
yield _AuthAppServer(
|
|
host=host,
|
|
port=port,
|
|
client=client,
|
|
logs=logs,
|
|
)
|
|
finally:
|
|
client.close()
|
|
try:
|
|
if sys.platform != "win32":
|
|
process.send_signal(2) # SIGINT
|
|
else:
|
|
process.terminate()
|
|
process.wait(timeout=15)
|
|
except Exception:
|
|
process.kill()
|
|
process.wait(timeout=5)
|
|
shutil.rmtree(tmp_path, ignore_errors=True)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# A. Auth=true tests
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.p1
|
|
def test_auth_status_reports_enabled_after_auto_register(
|
|
auth_app_server,
|
|
) -> None:
|
|
"""Test purpose:
|
|
- Verify GET /api/auth/status reports auth enabled and has_users=true
|
|
after auto_register_from_env() seeds the credentials.
|
|
|
|
API endpoints:
|
|
- GET /api/auth/status (public)
|
|
"""
|
|
resp = auth_app_server.get(
|
|
"/api/auth/status",
|
|
timeout=_HTTP_TIMEOUT,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body.get("enabled") is True, body
|
|
assert body.get("has_users") is True, body
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.p0
|
|
def test_auth_login_success_returns_token(auth_app_server) -> None:
|
|
"""Test purpose:
|
|
- Verify POST /api/auth/login with correct credentials returns a
|
|
non-empty token.
|
|
|
|
API endpoints:
|
|
- POST /api/auth/login (public)
|
|
"""
|
|
resp = auth_app_server.post(
|
|
"/api/auth/login",
|
|
json={
|
|
"username": _AUTH_USERNAME,
|
|
"password": _AUTH_PASSWORD,
|
|
},
|
|
timeout=_HTTP_TIMEOUT,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
token = body.get("token")
|
|
assert isinstance(token, str) and token, body
|
|
assert body.get("username") == _AUTH_USERNAME, body
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.p1
|
|
def test_auth_login_wrong_password_returns_401(auth_app_server) -> None:
|
|
"""Test purpose:
|
|
- Verify POST /api/auth/login with wrong password returns 401.
|
|
|
|
API endpoints:
|
|
- POST /api/auth/login
|
|
"""
|
|
resp = auth_app_server.post(
|
|
"/api/auth/login",
|
|
json={
|
|
"username": _AUTH_USERNAME,
|
|
"password": "definitely-wrong",
|
|
},
|
|
timeout=_HTTP_TIMEOUT,
|
|
)
|
|
assert resp.status_code == 401, resp.text
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.p0
|
|
def test_protected_endpoint_without_token_returns_401(
|
|
auth_app_server,
|
|
) -> None:
|
|
"""Test purpose:
|
|
- Verify GET /api/agents (protected) returns 401 without a token.
|
|
Use X-Forwarded-For to simulate a non-localhost client (default
|
|
allow_no_auth_hosts whitelists 127.0.0.1/::1).
|
|
|
|
API endpoints:
|
|
- GET /api/agents
|
|
"""
|
|
resp = auth_app_server.get(
|
|
"/api/agents",
|
|
headers={"X-Forwarded-For": "203.0.113.7"},
|
|
timeout=_HTTP_TIMEOUT,
|
|
)
|
|
assert resp.status_code == 401, resp.text
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.p0
|
|
def test_protected_endpoint_with_valid_token_returns_200(
|
|
auth_app_server,
|
|
) -> None:
|
|
"""Test purpose:
|
|
- Verify GET /api/agents with a valid Bearer token returns 200.
|
|
Uses X-Forwarded-For so the no-auth-hosts whitelist does not
|
|
mask the test (otherwise localhost would pass without a token).
|
|
|
|
Test flow:
|
|
1. POST /api/auth/login → obtain token.
|
|
2. GET /api/agents with Authorization: Bearer <token> → 200.
|
|
|
|
API endpoints:
|
|
- POST /api/auth/login
|
|
- GET /api/agents
|
|
"""
|
|
login = auth_app_server.post(
|
|
"/api/auth/login",
|
|
json={
|
|
"username": _AUTH_USERNAME,
|
|
"password": _AUTH_PASSWORD,
|
|
},
|
|
timeout=_HTTP_TIMEOUT,
|
|
)
|
|
assert login.status_code == 200, login.text
|
|
token = login.json()["token"]
|
|
|
|
resp = auth_app_server.get(
|
|
"/api/agents",
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"X-Forwarded-For": "203.0.113.7",
|
|
},
|
|
timeout=_HTTP_TIMEOUT,
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
agents = body if isinstance(body, list) else body.get("agents", [])
|
|
assert isinstance(agents, list), body
|