mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 18:23:20 +02:00
Add basic auth
This commit is contained in:
@@ -80,3 +80,41 @@ class TestBLEPinRequirement:
|
||||
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
|
||||
assert s.ble_address == "AA:BB:CC:DD:EE:FF"
|
||||
assert s.ble_pin == "123456"
|
||||
|
||||
|
||||
class TestBasicAuthConfiguration:
|
||||
"""Ensure basic auth credentials are configured as a pair."""
|
||||
|
||||
def test_basic_auth_disabled_by_default(self):
|
||||
s = Settings(serial_port="", tcp_host="", ble_address="")
|
||||
assert s.basic_auth_enabled is False
|
||||
|
||||
def test_basic_auth_enabled_when_both_credentials_are_set(self):
|
||||
s = Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="mesh",
|
||||
basic_auth_password="secret",
|
||||
)
|
||||
assert s.basic_auth_enabled is True
|
||||
|
||||
def test_basic_auth_requires_password_with_username(self):
|
||||
with pytest.raises(ValidationError, match="MESHCORE_BASIC_AUTH_USERNAME"):
|
||||
Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="mesh",
|
||||
basic_auth_password="",
|
||||
)
|
||||
|
||||
def test_basic_auth_requires_username_with_password(self):
|
||||
with pytest.raises(ValidationError, match="MESHCORE_BASIC_AUTH_USERNAME"):
|
||||
Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="",
|
||||
basic_auth_password="secret",
|
||||
)
|
||||
|
||||
@@ -3,7 +3,13 @@ import logging
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.frontend_static import (
|
||||
ASSET_CACHE_CONTROL,
|
||||
INDEX_CACHE_CONTROL,
|
||||
STATIC_FILE_CACHE_CONTROL,
|
||||
register_frontend_missing_fallback,
|
||||
register_frontend_static_routes,
|
||||
)
|
||||
|
||||
|
||||
def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog):
|
||||
@@ -57,10 +63,12 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
|
||||
root_response = client.get("/")
|
||||
assert root_response.status_code == 200
|
||||
assert "index page" in root_response.text
|
||||
assert root_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
manifest_response = client.get("/site.webmanifest")
|
||||
assert manifest_response.status_code == 200
|
||||
assert manifest_response.headers["content-type"].startswith("application/manifest+json")
|
||||
assert manifest_response.headers["cache-control"] == "no-store"
|
||||
manifest = manifest_response.json()
|
||||
assert manifest["start_url"] == "http://testserver/"
|
||||
assert manifest["scope"] == "http://testserver/"
|
||||
@@ -71,14 +79,22 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
|
||||
file_response = client.get("/robots.txt")
|
||||
assert file_response.status_code == 200
|
||||
assert file_response.text == "User-agent: *"
|
||||
assert file_response.headers["cache-control"] == STATIC_FILE_CACHE_CONTROL
|
||||
|
||||
explicit_index_response = client.get("/index.html")
|
||||
assert explicit_index_response.status_code == 200
|
||||
assert "index page" in explicit_index_response.text
|
||||
assert explicit_index_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
missing_response = client.get("/channel/some-route")
|
||||
assert missing_response.status_code == 200
|
||||
assert "index page" in missing_response.text
|
||||
assert missing_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
asset_response = client.get("/assets/app.js")
|
||||
assert asset_response.status_code == 200
|
||||
assert "console.log('ok');" in asset_response.text
|
||||
assert asset_response.headers["cache-control"] == ASSET_CACHE_CONTROL
|
||||
|
||||
|
||||
def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Tests for direct-serve HTTP quality features such as gzip compression."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_openapi_json_is_gzipped_when_client_accepts_gzip():
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/openapi.json", headers={"Accept-Encoding": "gzip"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for optional app-wide HTTP Basic authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.testclient import WebSocketDenialResponse
|
||||
|
||||
from app.config import Settings
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
|
||||
|
||||
def _auth_header(username: str, password: str) -> dict[str, str]:
|
||||
token = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
|
||||
|
||||
def _build_app(*, username: str = "", password: str = "") -> FastAPI:
|
||||
settings = Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username=username,
|
||||
basic_auth_password=password,
|
||||
)
|
||||
app = FastAPI()
|
||||
add_optional_basic_auth_middleware(app, settings)
|
||||
|
||||
@app.get("/protected")
|
||||
async def protected():
|
||||
return {"ok": True}
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(websocket: WebSocket) -> None:
|
||||
await websocket.accept()
|
||||
await websocket.send_json({"ok": True})
|
||||
await websocket.close()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_http_request_is_denied_without_basic_auth_credentials():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Unauthorized"}
|
||||
assert response.headers["www-authenticate"] == 'Basic realm="RemoteTerm", charset="UTF-8"'
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_http_request_is_allowed_with_valid_basic_auth_credentials():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected", headers=_auth_header("mesh", "secret"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_http_request_accepts_case_insensitive_basic_auth_scheme():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
header = _auth_header("mesh", "secret")
|
||||
header["Authorization"] = header["Authorization"].replace("Basic", "basic")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/protected", headers=header)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_websocket_handshake_is_denied_without_basic_auth_credentials():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
|
||||
with TestClient(app) as client:
|
||||
with pytest.raises(WebSocketDenialResponse) as exc_info:
|
||||
with client.websocket_connect("/ws"):
|
||||
pass
|
||||
|
||||
response = exc_info.value
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Unauthorized"}
|
||||
assert response.headers["www-authenticate"] == 'Basic realm="RemoteTerm", charset="UTF-8"'
|
||||
|
||||
|
||||
def test_websocket_handshake_is_allowed_with_valid_basic_auth_credentials():
|
||||
app = _build_app(username="mesh", password="secret")
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws", headers=_auth_header("mesh", "secret")) as websocket:
|
||||
assert websocket.receive_json() == {"ok": True}
|
||||
Reference in New Issue
Block a user