mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 06:02:30 +01:00
Compare commits
3
Commits
d7ecdf113b
...
d6f72acc93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6f72acc93 | ||
|
|
bcd5596e17 | ||
|
|
808e9c8631 |
@@ -130,6 +130,27 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
# FASTEMBED_MODEL=sentence-transformers/all-MiniLM-L6-v2
|
||||||
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
|
# FASTEMBED_CACHE_PATH= # defaults to ~/.cache/fastembed
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Google OAuth2 (Google Workspace / .edu email accounts)
|
||||||
|
# ============================================================
|
||||||
|
# Required to use the "Connect with Google" OAuth flow in email account setup.
|
||||||
|
# Create credentials at: console.cloud.google.com → APIs & Services → Credentials
|
||||||
|
# 1. Enable the Gmail API for your project.
|
||||||
|
# 2. Configure the OAuth consent screen (User Type: Internal for Workspace orgs).
|
||||||
|
# Add scopes: https://mail.google.com/ and email.
|
||||||
|
# 3. Create an OAuth 2.0 Client ID (type: Web application).
|
||||||
|
# Add your redirect URI: http://localhost:7000/api/email/oauth/google/callback
|
||||||
|
# (replace host/port for hosted installs).
|
||||||
|
# 4. Copy the Client ID and Client Secret below.
|
||||||
|
#
|
||||||
|
# GOOGLE_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
|
# GOOGLE_OAUTH_CLIENT_SECRET=replace-with-client-secret
|
||||||
|
#
|
||||||
|
# Set this explicitly for HTTPS, reverse-proxy, or hosted deployments. The
|
||||||
|
# value must exactly match an authorized redirect URI in the Google client.
|
||||||
|
# Local HTTP setups may use the callback URL inferred by the application.
|
||||||
|
# GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Misc
|
# Misc
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ services:
|
|||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ services:
|
|||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ services:
|
|||||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
|
||||||
|
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_OAUTH_REDIRECT_URI=${GOOGLE_OAUTH_REDIRECT_URI:-}
|
||||||
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
- TAVILY_API_KEY=${TAVILY_API_KEY:-}
|
||||||
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
- SERPER_API_KEY=${SERPER_API_KEY:-}
|
||||||
# PUID / PGID — the user/group the container drops to before
|
# PUID / PGID — the user/group the container drops to before
|
||||||
|
|||||||
+34
-1
@@ -4699,6 +4699,13 @@ def setup_email_routes():
|
|||||||
cfg = _get_email_config(owner=owner)
|
cfg = _get_email_config(owner=owner)
|
||||||
cfg["smtp_password"] = "***" if cfg["smtp_password"] else ""
|
cfg["smtp_password"] = "***" if cfg["smtp_password"] else ""
|
||||||
cfg["imap_password"] = "***" if cfg["imap_password"] else ""
|
cfg["imap_password"] = "***" if cfg["imap_password"] else ""
|
||||||
|
# `_get_email_config` includes encrypted OAuth fields for the server's
|
||||||
|
# IMAP/SMTP helpers. They are implementation secrets, not client
|
||||||
|
# configuration, so keep them out of this browser-facing response just
|
||||||
|
# as the account-list endpoint does.
|
||||||
|
cfg.pop("oauth_access_token", None)
|
||||||
|
cfg.pop("oauth_refresh_token", None)
|
||||||
|
cfg.pop("oauth_token_expiry", None)
|
||||||
# Include preferences from settings.json
|
# Include preferences from settings.json
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
cfg["email_auto_summarize"] = bool(settings.get("email_auto_summarize", False))
|
cfg["email_auto_summarize"] = bool(settings.get("email_auto_summarize", False))
|
||||||
@@ -5170,6 +5177,9 @@ def setup_email_routes():
|
|||||||
return _RR("/?section=integrations&email_oauth_error=token_exchange_failed")
|
return _RR("/?section=integrations&email_oauth_error=token_exchange_failed")
|
||||||
access_token = data.get("access_token", "")
|
access_token = data.get("access_token", "")
|
||||||
refresh_token = data.get("refresh_token", "")
|
refresh_token = data.get("refresh_token", "")
|
||||||
|
if not access_token or not refresh_token:
|
||||||
|
logger.warning("Google token exchange omitted required offline credentials")
|
||||||
|
return _RR("/?section=integrations&email_oauth_error=token_exchange_failed")
|
||||||
expiry = str(int(time.time()) + data.get("expires_in", 3600))
|
expiry = str(int(time.time()) + data.get("expires_in", 3600))
|
||||||
# Fetch the email address from userinfo so we can auto-fill imap_user.
|
# Fetch the email address from userinfo so we can auto-fill imap_user.
|
||||||
email_addr = ""
|
email_addr = ""
|
||||||
@@ -5194,9 +5204,32 @@ def setup_email_routes():
|
|||||||
if owner and row.owner and row.owner != owner:
|
if owner and row.owner and row.owner != owner:
|
||||||
logger.warning("OAuth callback owner mismatch — rejecting token write")
|
logger.warning("OAuth callback owner mismatch — rejecting token write")
|
||||||
return _RR("/?section=integrations&email_oauth_error=ownership_error")
|
return _RR("/?section=integrations&email_oauth_error=ownership_error")
|
||||||
|
|
||||||
|
# A reconnect must prove that the token belongs to the mailbox
|
||||||
|
# already configured on this row. Otherwise authenticating a
|
||||||
|
# different Google account leaves the saved IMAP/SMTP usernames
|
||||||
|
# paired with credentials for another identity.
|
||||||
|
verified_email = (
|
||||||
|
email_addr.strip().casefold()
|
||||||
|
if isinstance(email_addr, str)
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
configured_logins = {
|
||||||
|
value.strip().casefold()
|
||||||
|
for value in (row.imap_user or "", row.smtp_user or "")
|
||||||
|
if value.strip()
|
||||||
|
}
|
||||||
|
if not verified_email or any(
|
||||||
|
login != verified_email for login in configured_logins
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Google OAuth mailbox identity verification failed for account %s",
|
||||||
|
account_id,
|
||||||
|
)
|
||||||
|
return _RR("/?section=integrations&email_oauth_error=identity_verification_failed")
|
||||||
|
|
||||||
row.oauth_provider = "google"
|
row.oauth_provider = "google"
|
||||||
row.oauth_access_token = _enc(access_token)
|
row.oauth_access_token = _enc(access_token)
|
||||||
if refresh_token:
|
|
||||||
row.oauth_refresh_token = _enc(refresh_token)
|
row.oauth_refresh_token = _enc(refresh_token)
|
||||||
row.oauth_token_expiry = expiry
|
row.oauth_token_expiry = expiry
|
||||||
# Auto-fill Google IMAP/SMTP settings if not already configured.
|
# Auto-fill Google IMAP/SMTP settings if not already configured.
|
||||||
|
|||||||
+219
-1
@@ -372,7 +372,15 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
|
|||||||
from core.database import EmailAccount
|
from core.database import EmailAccount
|
||||||
|
|
||||||
db, Factory = _make_db()
|
db, Factory = _make_db()
|
||||||
_make_account(db, account_id="acct-v", owner="alice", imap_host="", smtp_host="")
|
_make_account(
|
||||||
|
db,
|
||||||
|
account_id="acct-v",
|
||||||
|
owner="alice",
|
||||||
|
imap_host="",
|
||||||
|
smtp_host="",
|
||||||
|
imap_user="alice@nyu.edu",
|
||||||
|
smtp_user="ALICE@NYU.EDU",
|
||||||
|
)
|
||||||
_make_account(db, account_id="acct-other", owner="alice") # must stay untouched
|
_make_account(db, account_id="acct-other", owner="alice") # must stay untouched
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -407,6 +415,166 @@ async def test_callback_valid_owner_writes_encrypted_tokens_to_intended_account(
|
|||||||
assert other.oauth_access_token is None, "tokens must only touch the intended account"
|
assert other.oauth_access_token is None, "tokens must only touch the intended account"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_rejects_token_for_a_different_mailbox_identity():
|
||||||
|
"""Reconnecting with another Google identity must not replace the token
|
||||||
|
while retaining the original IMAP/SMTP login names."""
|
||||||
|
from routes.email_helpers import make_oauth_state
|
||||||
|
from src.secret_storage import encrypt as _enc, decrypt as _dec
|
||||||
|
from core.database import EmailAccount
|
||||||
|
|
||||||
|
db, Factory = _make_db()
|
||||||
|
_make_account(
|
||||||
|
db,
|
||||||
|
account_id="acct-reconnect",
|
||||||
|
owner="alice",
|
||||||
|
imap_user="alice@example.edu",
|
||||||
|
smtp_user="alice@example.edu",
|
||||||
|
oauth_provider="google",
|
||||||
|
oauth_access_token=_enc("ya29.existing_access"),
|
||||||
|
oauth_refresh_token=_enc("1//existing_refresh"),
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
token_resp = mock.MagicMock()
|
||||||
|
token_resp.raise_for_status = mock.MagicMock()
|
||||||
|
token_resp.json.return_value = {
|
||||||
|
"access_token": "ya29.other_access",
|
||||||
|
"refresh_token": "1//other_refresh",
|
||||||
|
"expires_in": 3600,
|
||||||
|
}
|
||||||
|
userinfo_resp = mock.MagicMock()
|
||||||
|
userinfo_resp.is_success = True
|
||||||
|
userinfo_resp.json.return_value = {
|
||||||
|
"email": "other@example.edu",
|
||||||
|
"name": "Other User",
|
||||||
|
}
|
||||||
|
|
||||||
|
state = make_oauth_state("acct-reconnect", "alice")
|
||||||
|
with mock.patch("httpx.post", return_value=token_resp), \
|
||||||
|
mock.patch("httpx.get", return_value=userinfo_resp), \
|
||||||
|
mock.patch("core.database.SessionLocal", Factory):
|
||||||
|
resp = await _callback_endpoint()(
|
||||||
|
code="4/code",
|
||||||
|
state=state,
|
||||||
|
error=None,
|
||||||
|
request=_FakeRequest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "email_oauth_error=identity_verification_failed" in _location(resp)
|
||||||
|
verify_db = Factory()
|
||||||
|
row = verify_db.query(EmailAccount).filter(
|
||||||
|
EmailAccount.id == "acct-reconnect"
|
||||||
|
).first()
|
||||||
|
verify_db.close()
|
||||||
|
assert _dec(row.oauth_access_token) == "ya29.existing_access"
|
||||||
|
assert _dec(row.oauth_refresh_token) == "1//existing_refresh"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_callback_rejects_reconnect_without_a_fresh_refresh_token():
|
||||||
|
"""A same-identity access token cannot be paired with an unproven refresh
|
||||||
|
token retained from a previously mixed row."""
|
||||||
|
from routes.email_helpers import make_oauth_state
|
||||||
|
from src.secret_storage import encrypt as _enc, decrypt as _dec
|
||||||
|
from core.database import EmailAccount
|
||||||
|
|
||||||
|
db, Factory = _make_db()
|
||||||
|
_make_account(
|
||||||
|
db,
|
||||||
|
account_id="acct-refresh-proof",
|
||||||
|
owner="alice",
|
||||||
|
imap_user="alice@example.edu",
|
||||||
|
smtp_user="alice@example.edu",
|
||||||
|
oauth_provider="google",
|
||||||
|
oauth_access_token=_enc("ya29.existing_access"),
|
||||||
|
oauth_refresh_token=_enc("1//refresh_for_other_identity"),
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
token_resp = mock.MagicMock()
|
||||||
|
token_resp.raise_for_status = mock.MagicMock()
|
||||||
|
token_resp.json.return_value = {
|
||||||
|
"access_token": "ya29.same_identity_access",
|
||||||
|
"expires_in": 3600,
|
||||||
|
}
|
||||||
|
|
||||||
|
state = make_oauth_state("acct-refresh-proof", "alice")
|
||||||
|
with mock.patch("httpx.post", return_value=token_resp), \
|
||||||
|
mock.patch("httpx.get") as userinfo_get, \
|
||||||
|
mock.patch("core.database.SessionLocal", Factory):
|
||||||
|
resp = await _callback_endpoint()(
|
||||||
|
code="4/code",
|
||||||
|
state=state,
|
||||||
|
error=None,
|
||||||
|
request=_FakeRequest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "email_oauth_error=token_exchange_failed" in _location(resp)
|
||||||
|
userinfo_get.assert_not_called()
|
||||||
|
verify_db = Factory()
|
||||||
|
row = verify_db.query(EmailAccount).filter(
|
||||||
|
EmailAccount.id == "acct-refresh-proof"
|
||||||
|
).first()
|
||||||
|
verify_db.close()
|
||||||
|
assert _dec(row.oauth_access_token) == "ya29.existing_access"
|
||||||
|
assert _dec(row.oauth_refresh_token) == "1//refresh_for_other_identity"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("userinfo_result", [None, {}, {"email": None}])
|
||||||
|
async def test_callback_requires_verified_mailbox_identity(userinfo_result):
|
||||||
|
"""A failed or incomplete userinfo lookup must not persist fresh tokens."""
|
||||||
|
from routes.email_helpers import make_oauth_state
|
||||||
|
from core.database import EmailAccount
|
||||||
|
|
||||||
|
db, Factory = _make_db()
|
||||||
|
_make_account(
|
||||||
|
db,
|
||||||
|
account_id="acct-no-identity",
|
||||||
|
owner="alice",
|
||||||
|
imap_user="alice@example.edu",
|
||||||
|
smtp_user="alice@example.edu",
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
token_resp = mock.MagicMock()
|
||||||
|
token_resp.raise_for_status = mock.MagicMock()
|
||||||
|
token_resp.json.return_value = {
|
||||||
|
"access_token": "ya29.unverified_access",
|
||||||
|
"refresh_token": "1//unverified_refresh",
|
||||||
|
"expires_in": 3600,
|
||||||
|
}
|
||||||
|
if userinfo_result is None:
|
||||||
|
userinfo_call = mock.Mock(side_effect=RuntimeError("userinfo unavailable"))
|
||||||
|
else:
|
||||||
|
userinfo_resp = mock.MagicMock()
|
||||||
|
userinfo_resp.is_success = True
|
||||||
|
userinfo_resp.json.return_value = userinfo_result
|
||||||
|
userinfo_call = mock.Mock(return_value=userinfo_resp)
|
||||||
|
|
||||||
|
state = make_oauth_state("acct-no-identity", "alice")
|
||||||
|
with mock.patch("httpx.post", return_value=token_resp), \
|
||||||
|
mock.patch("httpx.get", userinfo_call), \
|
||||||
|
mock.patch("core.database.SessionLocal", Factory):
|
||||||
|
resp = await _callback_endpoint()(
|
||||||
|
code="4/code",
|
||||||
|
state=state,
|
||||||
|
error=None,
|
||||||
|
request=_FakeRequest(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "email_oauth_error=identity_verification_failed" in _location(resp)
|
||||||
|
verify_db = Factory()
|
||||||
|
row = verify_db.query(EmailAccount).filter(
|
||||||
|
EmailAccount.id == "acct-no-identity"
|
||||||
|
).first()
|
||||||
|
verify_db.close()
|
||||||
|
assert row.oauth_provider is None
|
||||||
|
assert row.oauth_access_token is None
|
||||||
|
assert row.oauth_refresh_token is None
|
||||||
|
|
||||||
|
|
||||||
# ── Token refresh scenarios ───────────────────────────────────────
|
# ── Token refresh scenarios ───────────────────────────────────────
|
||||||
|
|
||||||
def test_get_valid_google_token_uses_cached_when_fresh():
|
def test_get_valid_google_token_uses_cached_when_fresh():
|
||||||
@@ -578,3 +746,53 @@ async def test_account_list_response_does_not_expose_token_values():
|
|||||||
assert acct["oauth_provider"] == "google" # status is exposed
|
assert acct["oauth_provider"] == "google" # status is exposed
|
||||||
assert "oauth_access_token" not in acct # token value is not
|
assert "oauth_access_token" not in acct # token value is not
|
||||||
assert "oauth_refresh_token" not in acct
|
assert "oauth_refresh_token" not in acct
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_config_response_does_not_expose_oauth_storage_fields():
|
||||||
|
"""The client-facing config route must not serialize the encrypted token
|
||||||
|
fields returned by the internal transport helper."""
|
||||||
|
from routes.email_routes import setup_email_routes
|
||||||
|
from src.secret_storage import encrypt as _enc
|
||||||
|
|
||||||
|
encrypted_access = _enc("ya29.internal_access_token")
|
||||||
|
encrypted_refresh = _enc("1//internal_refresh_token")
|
||||||
|
internal_cfg = {
|
||||||
|
"account_id": "acct-config",
|
||||||
|
"account_name": "Google Workspace",
|
||||||
|
"smtp_host": "smtp.gmail.com",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_security": "starttls",
|
||||||
|
"smtp_user": "alice@example.edu",
|
||||||
|
"smtp_password": "",
|
||||||
|
"imap_host": "imap.gmail.com",
|
||||||
|
"imap_port": 993,
|
||||||
|
"imap_user": "alice@example.edu",
|
||||||
|
"imap_password": "",
|
||||||
|
"imap_starttls": False,
|
||||||
|
"from_address": "alice@example.edu",
|
||||||
|
"oauth_provider": "google",
|
||||||
|
"oauth_access_token": encrypted_access,
|
||||||
|
"oauth_refresh_token": encrypted_refresh,
|
||||||
|
"oauth_token_expiry": "4102444800",
|
||||||
|
"display_name": "Alice",
|
||||||
|
}
|
||||||
|
|
||||||
|
router = setup_email_routes()
|
||||||
|
get_config = None
|
||||||
|
for route in router.routes:
|
||||||
|
if route.path == "/api/email/config" and "GET" in getattr(route, "methods", set()):
|
||||||
|
get_config = route.endpoint
|
||||||
|
break
|
||||||
|
assert get_config is not None, "email config route not found"
|
||||||
|
|
||||||
|
with mock.patch("routes.email_routes._get_email_config", return_value=internal_cfg.copy()), \
|
||||||
|
mock.patch("routes.email_routes._load_settings", return_value={}):
|
||||||
|
result = await get_config(owner="alice")
|
||||||
|
|
||||||
|
assert result["oauth_provider"] == "google"
|
||||||
|
assert "oauth_access_token" not in result
|
||||||
|
assert "oauth_refresh_token" not in result
|
||||||
|
assert "oauth_token_expiry" not in result
|
||||||
|
assert encrypted_access not in json.dumps(result)
|
||||||
|
assert encrypted_refresh not in json.dumps(result)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Regression coverage for Google OAuth configuration in Docker Compose."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
COMPOSE_PATHS = tuple(
|
||||||
|
ROOT / name
|
||||||
|
for name in (
|
||||||
|
"docker-compose.yml",
|
||||||
|
"docker-compose.gpu-nvidia.yml",
|
||||||
|
"docker-compose.gpu-amd.yml",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ENV_EXAMPLE_PATH = ROOT / ".env.example"
|
||||||
|
|
||||||
|
|
||||||
|
def _env_example():
|
||||||
|
if not ENV_EXAMPLE_PATH.exists():
|
||||||
|
pytest.skip("this checkout does not include the optional .env.example file")
|
||||||
|
return ENV_EXAMPLE_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _odysseus_environment(path):
|
||||||
|
compose = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
return set(compose["services"]["odysseus"]["environment"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"key",
|
||||||
|
(
|
||||||
|
"GOOGLE_OAUTH_CLIENT_ID",
|
||||||
|
"GOOGLE_OAUTH_CLIENT_SECRET",
|
||||||
|
"GOOGLE_OAUTH_REDIRECT_URI",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_google_oauth_setting_is_forwarded(key):
|
||||||
|
expected = f"{key}=${{{key}:-}}"
|
||||||
|
for path in COMPOSE_PATHS:
|
||||||
|
assert expected in _odysseus_environment(path), path.name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"key",
|
||||||
|
(
|
||||||
|
"GOOGLE_OAUTH_CLIENT_ID",
|
||||||
|
"GOOGLE_OAUTH_CLIENT_SECRET",
|
||||||
|
"GOOGLE_OAUTH_REDIRECT_URI",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_google_oauth_setting_is_documented(key):
|
||||||
|
assert f"# {key}=" in _env_example()
|
||||||
|
|
||||||
|
|
||||||
|
def test_google_oauth_example_uses_a_neutral_secret_placeholder():
|
||||||
|
env_example = _env_example()
|
||||||
|
assert "GOOGLE_OAUTH_CLIENT_SECRET=replace-with-client-secret" in env_example
|
||||||
|
assert "GOCSPX-" not in env_example
|
||||||
|
|
||||||
|
|
||||||
|
def test_redirect_documentation_covers_https_and_reverse_proxies():
|
||||||
|
oauth_section = _env_example().split("# Google OAuth2", 1)[1].split("# Misc", 1)[0]
|
||||||
|
assert "HTTPS" in oauth_section
|
||||||
|
assert "reverse-proxy" in oauth_section
|
||||||
|
assert "exactly match an authorized redirect URI" in oauth_section
|
||||||
Reference in New Issue
Block a user