2013 lines
76 KiB
Python
2013 lines
76 KiB
Python
"""Tests for the ``accounts`` auth provider.
|
|
|
|
Covers the four layers of the stack:
|
|
|
|
1. ``omnigent.server.passwords`` — argon2 wrapper (hash / verify
|
|
round-trip, constant-time-equalized failures).
|
|
2. ``omnigent.server.accounts_config.AccountsConfig`` — env-var
|
|
parsing, fail-loud validation, secure-cookie derivation.
|
|
3. ``omnigent.server.auth.UnifiedAuthProvider`` with
|
|
``source='accounts'`` — cookie validation, reserved-name
|
|
rejection, ``create_auth_provider`` factory gating.
|
|
4. ``omnigent.server.accounts_bootstrap.bootstrap_admin`` matrix
|
|
(auto-gen / pre-seed / idempotent / loopback handoff).
|
|
5. ``omnigent.server.routes.accounts_auth`` — login / logout /
|
|
me / invite / register / magic / members admin endpoints via
|
|
FastAPI TestClient, including cross-user (Alice/Bob)
|
|
permission isolation and reserved-name guards.
|
|
|
|
Tests mirror the conventions in ``tests/server/test_oidc.py``:
|
|
real types over MagicMock for data objects, content assertions
|
|
not just structure, each test fails if its feature is deleted.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from omnigent.server.accounts_bootstrap import (
|
|
bootstrap_admin,
|
|
resolve_admin_username,
|
|
)
|
|
from omnigent.server.accounts_config import AccountsConfig
|
|
from omnigent.server.accounts_store import SqlAlchemyAccountStore
|
|
from omnigent.server.auth import (
|
|
AuthProvider,
|
|
UnifiedAuthProvider,
|
|
create_auth_provider,
|
|
resolve_auth_source,
|
|
)
|
|
from omnigent.server.passwords import (
|
|
InvalidPasswordError,
|
|
hash_password,
|
|
needs_rehash,
|
|
verify_password,
|
|
)
|
|
from omnigent.stores.permission_store.sqlalchemy_store import (
|
|
SqlAlchemyPermissionStore,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_ambient_oidc_issuer(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Strip an ambient ``OMNIGENT_OIDC_ISSUER`` for the accounts suite.
|
|
|
|
With auth enabled, the presence of an issuer selects ``oidc`` over
|
|
``accounts`` (see :func:`resolve_auth_source`). A developer who
|
|
tests OIDC locally may have the issuer exported; without clearing
|
|
it, the enable-switch → accounts assertions below would resolve to
|
|
``oidc`` and fail. Accounts mode never reads the issuer, so clearing
|
|
it is safe for every test in this file. Tests that need an issuer
|
|
set it explicitly *after* this fixture runs.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_OIDC_ISSUER", raising=False)
|
|
|
|
|
|
# ── Password helper (unit) ────────────────────────────────────────
|
|
|
|
|
|
def test_hash_password_round_trip() -> None:
|
|
"""hash_password + verify_password accept the original plaintext.
|
|
|
|
If this breaks, every login becomes impossible: the stored hash
|
|
of a freshly-set password no longer matches when verified.
|
|
"""
|
|
h = hash_password("hunter2")
|
|
verify_password("hunter2", h) # no exception → OK
|
|
|
|
|
|
def test_hash_password_emits_argon2id_envelope() -> None:
|
|
"""The hash uses argon2id (modern OWASP-recommended variant).
|
|
|
|
Argon2 is self-describing — the encoded prefix declares the
|
|
variant. If a future refactor accidentally switched to argon2i
|
|
or argon2d, this test fires before any user logs in with the
|
|
wrong algorithm.
|
|
"""
|
|
h = hash_password("hunter2")
|
|
assert h.startswith("$argon2id$"), f"expected argon2id envelope, got {h[:20]!r}"
|
|
|
|
|
|
def test_verify_password_rejects_wrong_password() -> None:
|
|
"""verify_password raises InvalidPasswordError on mismatch.
|
|
|
|
Routes rely on this exception to map every failure mode to
|
|
the same 401 response, so a real mismatch must raise
|
|
InvalidPasswordError specifically (not RuntimeError, not None).
|
|
"""
|
|
h = hash_password("hunter2")
|
|
with pytest.raises(InvalidPasswordError):
|
|
verify_password("wrong-password", h)
|
|
|
|
|
|
def test_verify_password_rejects_malformed_hash() -> None:
|
|
"""A corrupted stored hash collapses to InvalidPasswordError.
|
|
|
|
Same exception class as wrong-password — the route's 401
|
|
response can't accidentally reveal whether the user's DB row
|
|
is corrupted vs whether they typed the wrong password.
|
|
"""
|
|
with pytest.raises(InvalidPasswordError):
|
|
verify_password("anything", "not-a-real-argon2-hash")
|
|
|
|
|
|
def test_needs_rehash_false_on_fresh_hash() -> None:
|
|
"""A hash just produced by hash_password does NOT need rehash.
|
|
|
|
The login route opportunistically rehashes on success when
|
|
parameters have been upgraded; if this returned True for a
|
|
fresh hash, every login would silently rewrite the row,
|
|
burning argon2 cost for no benefit.
|
|
"""
|
|
assert needs_rehash(hash_password("hunter2")) is False
|
|
|
|
|
|
# ── AccountsConfig.from_env (unit) ────────────────────────────────
|
|
|
|
|
|
def _set_required_accounts_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
base_url: str = "https://omnigent.example.com",
|
|
) -> None:
|
|
"""Populate every required env var so from_env() doesn't fail loud."""
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_COOKIE_SECRET", secrets.token_hex(32))
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_BASE_URL", base_url)
|
|
|
|
|
|
def test_accounts_config_round_trips_required_env(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""from_env() parses every required var into the dataclass."""
|
|
secret_hex = secrets.token_hex(32)
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_COOKIE_SECRET", secret_hex)
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_BASE_URL", "https://omnigent.example.com")
|
|
|
|
cfg = AccountsConfig.from_env()
|
|
|
|
assert cfg.cookie_secret == bytes.fromhex(secret_hex)
|
|
assert cfg.base_url == "https://omnigent.example.com"
|
|
assert cfg.secure_cookies is True
|
|
assert cfg.session_cookie_name == "__Host-ap_session"
|
|
|
|
|
|
def test_accounts_config_missing_cookie_secret_fails_loud(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A missing COOKIE_SECRET raises with a remediation message."""
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_COOKIE_SECRET", raising=False)
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_BASE_URL", "http://localhost:8000")
|
|
|
|
with pytest.raises(RuntimeError, match="OMNIGENT_ACCOUNTS_COOKIE_SECRET"):
|
|
AccountsConfig.from_env()
|
|
|
|
|
|
def test_accounts_config_short_cookie_secret_fails_loud(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""COOKIE_SECRET shorter than 32 bytes is rejected.
|
|
|
|
HS256 with a key shorter than the digest size is a real
|
|
weakness; matching OIDCConfig's stance.
|
|
"""
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_COOKIE_SECRET", "00" * 16) # only 16 bytes
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_BASE_URL", "http://localhost:8000")
|
|
|
|
with pytest.raises(RuntimeError, match="at least 32 bytes"):
|
|
AccountsConfig.from_env()
|
|
|
|
|
|
def test_accounts_config_non_hex_cookie_secret_fails_loud(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A non-hex COOKIE_SECRET raises with a clear message."""
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_COOKIE_SECRET", "not-hex-at-all")
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_BASE_URL", "http://localhost:8000")
|
|
|
|
with pytest.raises(RuntimeError, match="valid hex string"):
|
|
AccountsConfig.from_env()
|
|
|
|
|
|
def test_accounts_config_http_base_url_uses_plain_cookie(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An http:// base URL disables Secure cookies + ``__Host-`` prefix.
|
|
|
|
Browsers silently drop ``__Host-`` cookies on plain HTTP,
|
|
which would cause an infinite login redirect. The cookie
|
|
name MUST switch to a non-prefixed form for local dev.
|
|
"""
|
|
_set_required_accounts_env(monkeypatch, base_url="http://localhost:8000")
|
|
|
|
cfg = AccountsConfig.from_env()
|
|
|
|
assert cfg.secure_cookies is False
|
|
assert cfg.session_cookie_name == "ap_session"
|
|
|
|
|
|
def test_accounts_config_rejects_non_http_scheme(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""BASE_URL must start with http(s):// — fail loud otherwise."""
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_COOKIE_SECRET", secrets.token_hex(32))
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_BASE_URL", "ftp://omnigent.example.com")
|
|
|
|
with pytest.raises(RuntimeError, match="http://"):
|
|
AccountsConfig.from_env()
|
|
|
|
|
|
def test_accounts_config_init_admin_empty_string_is_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""INIT_ADMIN_PASSWORD="" is treated as unset, not as a literal empty password.
|
|
|
|
Same docker-compose ``${VAR:-}`` pattern that motivated the
|
|
OIDC SCOPES fix — passing an empty string would
|
|
silently set a zero-length admin password if not guarded.
|
|
"""
|
|
_set_required_accounts_env(monkeypatch)
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD", "")
|
|
|
|
cfg = AccountsConfig.from_env()
|
|
|
|
assert cfg.init_admin_password is None
|
|
|
|
|
|
# ── UnifiedAuthProvider accounts source (unit) ────────────────────
|
|
|
|
|
|
_TEST_COOKIE_SECRET = secrets.token_bytes(32)
|
|
|
|
|
|
def _make_accounts_config(base_url: str = "http://localhost:8000") -> AccountsConfig:
|
|
"""Build an AccountsConfig with the test secret + a configurable URL."""
|
|
return AccountsConfig(
|
|
cookie_secret=_TEST_COOKIE_SECRET,
|
|
session_ttl_hours=8,
|
|
base_url=base_url,
|
|
init_admin_password=None,
|
|
invite_ttl_seconds=3600,
|
|
magic_ttl_seconds=600,
|
|
)
|
|
|
|
|
|
class _FakeReq:
|
|
"""Minimal HTTPConnection stand-in for cookie/header tests.
|
|
|
|
Used over MagicMock because the auth code reads ``.cookies``
|
|
and ``.headers`` as dicts — a real dict-shaped object is
|
|
safer than letting MagicMock fall through to a default that
|
|
silently coerces.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
cookies: dict[str, str] | None = None,
|
|
headers: dict[str, str] | None = None,
|
|
) -> None:
|
|
self.cookies = cookies or {}
|
|
self.headers = headers or {}
|
|
|
|
|
|
def test_accounts_source_reads_valid_cookie() -> None:
|
|
"""The accounts source extracts a user_id from a valid session JWT."""
|
|
from omnigent.server.oidc import mint_session_cookie
|
|
|
|
cfg = _make_accounts_config()
|
|
provider = UnifiedAuthProvider(source="accounts", accounts_config=cfg)
|
|
token = mint_session_cookie(
|
|
user_id="admin", cookie_secret=_TEST_COOKIE_SECRET, ttl_hours=8, provider="accounts"
|
|
)
|
|
|
|
request = _FakeReq(cookies={cfg.session_cookie_name: token})
|
|
|
|
assert provider.get_user_id(request) == "admin"
|
|
|
|
|
|
def test_accounts_source_rejects_reserved_user_in_cookie() -> None:
|
|
"""Reserved usernames in a cookie's sub claim are rejected.
|
|
|
|
Belt-and-suspenders for the registration-time guard: even if a
|
|
malicious admin somehow gets a session JWT with sub=local
|
|
minted, the auth provider refuses to honor it.
|
|
"""
|
|
from omnigent.server.oidc import mint_session_cookie
|
|
|
|
cfg = _make_accounts_config()
|
|
provider = UnifiedAuthProvider(source="accounts", accounts_config=cfg)
|
|
token = mint_session_cookie(
|
|
user_id="local", cookie_secret=_TEST_COOKIE_SECRET, ttl_hours=8, provider="accounts"
|
|
)
|
|
|
|
request = _FakeReq(cookies={cfg.session_cookie_name: token})
|
|
|
|
assert provider.get_user_id(request) is None
|
|
|
|
|
|
def test_accounts_source_rejects_cookie_signed_with_wrong_secret() -> None:
|
|
"""A cookie signed by a different key is rejected.
|
|
|
|
Cross-deployment cookie reuse — stealing a cookie from one
|
|
server and presenting it to another with a different secret
|
|
must not authenticate.
|
|
"""
|
|
from omnigent.server.oidc import mint_session_cookie
|
|
|
|
cfg = _make_accounts_config()
|
|
provider = UnifiedAuthProvider(source="accounts", accounts_config=cfg)
|
|
other_secret = secrets.token_bytes(32)
|
|
token = mint_session_cookie(
|
|
user_id="admin", cookie_secret=other_secret, ttl_hours=8, provider="accounts"
|
|
)
|
|
|
|
request = _FakeReq(cookies={cfg.session_cookie_name: token})
|
|
|
|
assert provider.get_user_id(request) is None
|
|
|
|
|
|
def test_accounts_source_accepts_bearer_token_for_cli() -> None:
|
|
"""CLI bearer tokens (no cookie) also authenticate against accounts.
|
|
|
|
The runner / CLI use Authorization: Bearer <jwt> after picking
|
|
the token up from ~/.omnigent/auth_tokens.json — the same
|
|
code path the OIDC mode supports.
|
|
"""
|
|
from omnigent.server.oidc import mint_session_cookie
|
|
|
|
cfg = _make_accounts_config()
|
|
provider = UnifiedAuthProvider(source="accounts", accounts_config=cfg)
|
|
token = mint_session_cookie(
|
|
user_id="admin", cookie_secret=_TEST_COOKIE_SECRET, ttl_hours=8, provider="accounts"
|
|
)
|
|
|
|
request = _FakeReq(headers={"Authorization": f"Bearer {token}"})
|
|
|
|
assert provider.get_user_id(request) == "admin"
|
|
|
|
|
|
def test_accounts_source_login_url_points_at_spa() -> None:
|
|
"""In accounts mode, login_url is the SPA route, not the API route.
|
|
|
|
The frontend redirects to this URL on 401; pointing at the
|
|
API endpoint (which is a POST handler, not a page) would
|
|
bounce the user to a blank/error response.
|
|
"""
|
|
cfg = _make_accounts_config()
|
|
provider = UnifiedAuthProvider(source="accounts", accounts_config=cfg)
|
|
|
|
assert provider.login_url == "/login"
|
|
|
|
|
|
def test_mint_runner_token_round_trips_to_owner() -> None:
|
|
"""A managed runner's minted owner token resolves back to the owner.
|
|
|
|
The sandbox runner has no login of its own, so the server mints an
|
|
owner JWT it presents as ``Authorization: Bearer`` on its HTTP
|
|
callbacks — ``get_user_id`` (the same check ``require_user`` applies)
|
|
must resolve it to the owner, else every callback 401s.
|
|
"""
|
|
cfg = _make_accounts_config()
|
|
provider = UnifiedAuthProvider(source="accounts", accounts_config=cfg)
|
|
|
|
token = provider.mint_runner_token("alice@example.com", 1800)
|
|
assert token is not None
|
|
|
|
request = _FakeReq(headers={"Authorization": f"Bearer {token}"})
|
|
assert provider.get_user_id(request) == "alice@example.com"
|
|
|
|
|
|
def test_mint_runner_token_rejects_empty_and_reserved_owner() -> None:
|
|
"""No token for an empty or reserved owner — never mint reserved-identity creds."""
|
|
cfg = _make_accounts_config()
|
|
provider = UnifiedAuthProvider(source="accounts", accounts_config=cfg)
|
|
assert provider.mint_runner_token("", 1800) is None
|
|
assert provider.mint_runner_token("local", 1800) is None
|
|
|
|
|
|
def test_mint_runner_token_returns_none_for_header_source() -> None:
|
|
"""Header/proxy auth can't be minted server-side, so it returns None.
|
|
|
|
Identity there is asserted by the upstream proxy; a managed runner
|
|
can't synthesize it. The base ``AuthProvider`` default is also None.
|
|
"""
|
|
header_provider = UnifiedAuthProvider(source="header")
|
|
assert header_provider.mint_runner_token("alice@example.com", 1800) is None
|
|
|
|
class _Base(AuthProvider):
|
|
def get_user_id(self, request: object) -> str | None: # type: ignore[override]
|
|
return None
|
|
|
|
assert _Base().mint_runner_token("alice@example.com", 1800) is None
|
|
|
|
|
|
def test_mint_runner_token_expired_resolves_to_none() -> None:
|
|
"""A short TTL genuinely expires: past its exp, get_user_id returns None.
|
|
|
|
This is what makes the managed-runner auth refreshable rather than a
|
|
fixed cap — the token expires and the runner re-mints, so there is no
|
|
static long-lived credential.
|
|
"""
|
|
cfg = _make_accounts_config()
|
|
provider = UnifiedAuthProvider(source="accounts", accounts_config=cfg)
|
|
|
|
token = provider.mint_runner_token("alice@example.com", -1)
|
|
assert token is not None
|
|
|
|
request = _FakeReq(headers={"Authorization": f"Bearer {token}"})
|
|
assert provider.get_user_id(request) is None
|
|
|
|
|
|
# ── resolve_auth_source (shared resolver used by every spawn path) ──
|
|
|
|
|
|
def test_resolve_auth_source_defaults_to_header(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Env-unset resolves to header — the shared resolver's baseline.
|
|
|
|
This is the contract the daemon-owned server, the per-command server,
|
|
and the config-signature all rely on, so a regression here would
|
|
desync them.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.delenv("OMNIGENT_AUTH_ENABLED", raising=False)
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_ENABLED", raising=False)
|
|
assert resolve_auth_source() == "header"
|
|
|
|
|
|
def test_resolve_auth_source_opt_in_selects_accounts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""``OMNIGENT_AUTH_ENABLED=1`` (no OIDC config) opts into accounts mode."""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "1")
|
|
# _clear_ambient_oidc_issuer (autouse) guarantees no issuer is set,
|
|
# so the enable switch resolves to the built-in accounts flow.
|
|
assert resolve_auth_source() == "accounts"
|
|
|
|
|
|
def test_resolve_auth_source_oidc_issuer_selects_oidc(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""``OMNIGENT_AUTH_ENABLED=1`` + an OIDC issuer selects oidc, not accounts.
|
|
|
|
This is the unified-switch contract: the same enable flag turns on
|
|
accounts by default, but flips to the native OIDC flow the moment
|
|
the operator supplies an issuer. If this asserted ``"accounts"`` the
|
|
issuer-based mode selection (resolve_auth_source's OIDC branch)
|
|
would be dead — an operator who set the OIDC vars would silently get
|
|
the built-in login form instead of their IdP.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "1")
|
|
monkeypatch.setenv("OMNIGENT_OIDC_ISSUER", "https://accounts.google.com")
|
|
assert resolve_auth_source() == "oidc"
|
|
|
|
|
|
def test_resolve_auth_source_oidc_issuer_ignored_when_auth_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An OIDC issuer alone (auth switch off) does NOT enable oidc.
|
|
|
|
The issuer only chooses *which* multi-user mode runs once auth is
|
|
enabled — it is not itself an enable switch. A stray issuer in the
|
|
environment must not silently turn a single-user header deploy into
|
|
an OIDC one. If this resolved to ``"oidc"`` the switch would have
|
|
been bypassed.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.delenv("OMNIGENT_AUTH_ENABLED", raising=False)
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_ENABLED", raising=False)
|
|
monkeypatch.setenv("OMNIGENT_OIDC_ISSUER", "https://accounts.google.com")
|
|
assert resolve_auth_source() == "header"
|
|
|
|
|
|
def test_resolve_auth_source_deprecated_alias_still_selects_accounts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The pre-rename ``OMNIGENT_ACCOUNTS_ENABLED`` alias still works.
|
|
|
|
Existing deploys that set the old name must keep booting in accounts
|
|
mode after the rename. If this regressed, an upgrade would silently
|
|
drop those deploys back to single-user header mode (no login).
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.delenv("OMNIGENT_AUTH_ENABLED", raising=False)
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_ENABLED", "1")
|
|
assert resolve_auth_source() == "accounts"
|
|
|
|
|
|
def test_resolve_auth_source_new_var_wins_over_deprecated_alias(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The current name wins when both names are set.
|
|
|
|
A deploy migrating to ``OMNIGENT_AUTH_ENABLED`` can leave the old
|
|
``OMNIGENT_ACCOUNTS_ENABLED`` in place: an explicit ``=0`` on the
|
|
new name disables auth even though the old name is truthy. If the
|
|
alias took precedence the new value would be unsettable while the
|
|
old one lingered.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "0")
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_ENABLED", "1")
|
|
assert resolve_auth_source() == "header"
|
|
|
|
|
|
def test_resolve_auth_source_explicit_passthrough_lowercased(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""An explicit provider wins and is returned lower-cased, verbatim.
|
|
|
|
The resolver returns the raw explicit value (validation/rejection of
|
|
unknown values is the factory's job); the signature folds whatever
|
|
string it returns, so the passthrough must be stable.
|
|
"""
|
|
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "OIDC")
|
|
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "0")
|
|
assert resolve_auth_source() == "oidc"
|
|
|
|
|
|
# ── create_auth_provider factory (default + explicit overrides) ──
|
|
|
|
|
|
def test_factory_defaults_to_header_when_env_unset(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unset OMNIGENT_AUTH_PROVIDER (+ no enable switch) → header mode.
|
|
|
|
The shipped default is single-user, no-login: a bare
|
|
``omnigent server`` on a laptop should pop open with no
|
|
multi-user wiring. Multi-user (accounts) is opt-in via
|
|
``OMNIGENT_AUTH_ENABLED=1`` (see
|
|
:func:`test_factory_accounts_enabled_truthy_enables_accounts`).
|
|
No accounts env is set here — header mode must not require it.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.delenv("OMNIGENT_AUTH_ENABLED", raising=False)
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_ENABLED", raising=False)
|
|
|
|
provider = create_auth_provider()
|
|
|
|
assert isinstance(provider, UnifiedAuthProvider)
|
|
assert provider._source == "header"
|
|
|
|
|
|
def test_factory_explicit_header_beats_enable_switch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Explicit ``OMNIGENT_AUTH_PROVIDER=header`` wins over the enable switch.
|
|
|
|
An explicit provider always wins, so a stale
|
|
``OMNIGENT_AUTH_ENABLED=1`` in a shell can't silently turn
|
|
accounts on for a deploy that pinned header (e.g. the internal
|
|
hosted product, which sets header via ``setdefault`` in its
|
|
entrypoint).
|
|
"""
|
|
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "header")
|
|
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "1")
|
|
|
|
provider = create_auth_provider()
|
|
|
|
assert isinstance(provider, UnifiedAuthProvider)
|
|
assert provider._source == "header"
|
|
|
|
|
|
def test_factory_accepts_accounts_explicit(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Explicit accounts setting still works the same way."""
|
|
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "accounts")
|
|
_set_required_accounts_env(monkeypatch)
|
|
|
|
provider = create_auth_provider()
|
|
|
|
assert isinstance(provider, UnifiedAuthProvider)
|
|
assert provider._source == "accounts"
|
|
|
|
|
|
def test_factory_rejects_unknown_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A bogus AUTH_PROVIDER value fails loud, doesn't fall through."""
|
|
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "bogus")
|
|
|
|
with pytest.raises(RuntimeError, match="bogus"):
|
|
create_auth_provider()
|
|
|
|
|
|
@pytest.mark.parametrize("disable_value", ["0", "false", "no", "FALSE"])
|
|
def test_factory_accounts_enabled_falsy_stays_header(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
disable_value: str,
|
|
) -> None:
|
|
"""An explicitly falsy ``OMNIGENT_AUTH_ENABLED`` → header mode.
|
|
|
|
Header is already the env-unset default, but a falsy value must
|
|
be treated the same as unset (not as "set, therefore truthy") —
|
|
selecting header mode, which falls back to the ``local`` user
|
|
when no proxy header is present. No accounts env needed — header
|
|
mode doesn't build AccountsConfig.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", disable_value)
|
|
|
|
provider = create_auth_provider()
|
|
|
|
assert isinstance(provider, UnifiedAuthProvider)
|
|
assert provider._source == "header"
|
|
|
|
|
|
@pytest.mark.parametrize("enable_value", ["1", "true", "yes", "YES"])
|
|
def test_factory_accounts_enabled_truthy_enables_accounts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
enable_value: str,
|
|
) -> None:
|
|
"""A truthy ``OMNIGENT_AUTH_ENABLED`` (no OIDC) opts INTO accounts mode.
|
|
|
|
This is the multi-user opt-in: with no explicit
|
|
``OMNIGENT_AUTH_PROVIDER`` and no OIDC issuer, a truthy enable
|
|
switch turns on the accounts login flow (the inverse of the
|
|
env-unset header default).
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_AUTH_PROVIDER", raising=False)
|
|
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", enable_value)
|
|
_set_required_accounts_env(monkeypatch)
|
|
|
|
provider = create_auth_provider()
|
|
|
|
assert isinstance(provider, UnifiedAuthProvider)
|
|
assert provider._source == "accounts"
|
|
|
|
|
|
def test_factory_explicit_accounts_beats_disabled_switch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Explicit ``AUTH_PROVIDER=accounts`` wins over ``AUTH_ENABLED=0``.
|
|
|
|
The enable switch only governs the env-unset default path, so a
|
|
stale ``AUTH_ENABLED=0`` in a shell can't silently downgrade
|
|
an operator who explicitly opted into accounts.
|
|
"""
|
|
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "accounts")
|
|
monkeypatch.setenv("OMNIGENT_AUTH_ENABLED", "0")
|
|
_set_required_accounts_env(monkeypatch)
|
|
|
|
provider = create_auth_provider()
|
|
|
|
assert isinstance(provider, UnifiedAuthProvider)
|
|
assert provider._source == "accounts"
|
|
|
|
|
|
# ── bootstrap_admin (unit) ────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_store(tmp_path: Path) -> SqlAlchemyAccountStore:
|
|
"""Build a fresh accounts store on a temp sqlite DB.
|
|
|
|
Goes through the real migration path so the schema is
|
|
exactly what the production code sees; a unit test that
|
|
invented its own table layout would mask migration drift.
|
|
|
|
Named ``fresh_store`` (not ``fresh_account_store``) so the
|
|
bootstrap test signatures stay terse — there's only one store
|
|
in play at this layer of the suite.
|
|
"""
|
|
db_url = f"sqlite:///{tmp_path}/test.db"
|
|
from omnigent.db.utils import get_or_create_engine
|
|
|
|
get_or_create_engine(db_url) # runs alembic upgrade
|
|
return SqlAlchemyAccountStore(db_url)
|
|
|
|
|
|
@pytest.fixture
|
|
def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Redirect $HOME so cli_auth.store_token writes to a temp file.
|
|
|
|
Without this, the test could write into the developer's real
|
|
``~/.omnigent/auth_tokens.json`` — fine, but noisy. The
|
|
fixture also pins OMNIGENT_ADMIN_CREDENTIALS_PATH so the
|
|
bootstrap's 0600 file lands inside the tmp dir too.
|
|
"""
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("OMNIGENT_ADMIN_CREDENTIALS_PATH", str(tmp_path / "admin-credentials"))
|
|
# Pin the admin username to "admin" so the existing test
|
|
# assertions (which were written against the old hardcoded
|
|
# "admin" constant) don't depend on whatever
|
|
# getpass.getuser() happens to return in CI. The OS-username
|
|
# resolution path is exercised by its own dedicated tests
|
|
# below.
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_USERNAME", "admin")
|
|
return tmp_path
|
|
|
|
|
|
def test_bootstrap_with_password_creates_admin(
|
|
fresh_store: SqlAlchemyAccountStore, isolated_home: Path
|
|
) -> None:
|
|
"""A supplied password creates the admin on first boot.
|
|
|
|
The flag/env path (``--admin-password`` /
|
|
``OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD``) is the one
|
|
bootstrap path that creates an admin directly — for headless /
|
|
CI deploys. The password the caller supplied must be the one
|
|
that authenticates.
|
|
"""
|
|
result = bootstrap_admin(fresh_store, init_admin_password="explicit-pw-12345")
|
|
|
|
assert result.fresh_boot is True
|
|
assert result.needs_setup is False
|
|
admin = fresh_store.get_user("admin")
|
|
assert admin is not None
|
|
assert admin.is_admin is True
|
|
assert admin.has_password is True
|
|
# verify_password is verify-or-raise: no raise == the supplied
|
|
# password is what's stored.
|
|
verify_password("explicit-pw-12345", fresh_store.get_password_hash("admin"))
|
|
|
|
|
|
def test_bootstrap_without_password_creates_nothing_and_needs_setup(
|
|
fresh_store: SqlAlchemyAccountStore, isolated_home: Path
|
|
) -> None:
|
|
"""No supplied password → NO admin, NO default credential, needs_setup.
|
|
|
|
The core "never auto-generate" invariant. With no
|
|
``--admin-password`` / ``INIT_ADMIN_PASSWORD``, bootstrap must
|
|
create no account at all and report needs_setup, deferring the
|
|
first-admin claim to the terminal prompt or the web Create-admin
|
|
form. A created-but-random admin would be the buried-credential
|
|
footgun we're eliminating.
|
|
"""
|
|
result = bootstrap_admin(fresh_store)
|
|
|
|
assert result.fresh_boot is False
|
|
assert result.needs_setup is True
|
|
# Nothing was created — no admin, no password-having user anywhere.
|
|
assert fresh_store.get_user("admin") is None
|
|
assert not any(u.has_password for u in fresh_store.list_users())
|
|
|
|
|
|
def test_bootstrap_with_password_is_idempotent_on_reboot(
|
|
fresh_store: SqlAlchemyAccountStore, isolated_home: Path
|
|
) -> None:
|
|
"""Re-running bootstrap is a no-op once the admin exists.
|
|
|
|
A re-bootstrap MUST NOT rotate the password — that would lock
|
|
out anyone using the original. Rotation is an explicit action.
|
|
"""
|
|
bootstrap_admin(fresh_store, init_admin_password="pw-12345")
|
|
original_hash = fresh_store.get_password_hash("admin")
|
|
|
|
result = bootstrap_admin(fresh_store, init_admin_password="pw-12345")
|
|
|
|
assert result.fresh_boot is False
|
|
assert result.needs_setup is False
|
|
assert fresh_store.get_password_hash("admin") == original_hash
|
|
|
|
|
|
def test_bootstrap_ignores_supplied_password_once_admin_exists(
|
|
fresh_store: SqlAlchemyAccountStore, isolated_home: Path
|
|
) -> None:
|
|
"""A second boot with a new password is a no-op — the first wins.
|
|
|
|
The admin password is set exactly once, on the first boot of a
|
|
machine's accounts DB. A later ``--admin-password`` /
|
|
``INIT_ADMIN_PASSWORD`` (e.g. a stale shell var, or someone trying
|
|
to "re-set" it) must NOT silently rotate the live credential — that
|
|
would be a footgun and a privilege surprise. It's ignored (with a
|
|
warning, surfaced in the logs); rotation goes through the web UI's
|
|
admin reset instead.
|
|
"""
|
|
bootstrap_admin(fresh_store, init_admin_password="first-pw-12345")
|
|
result = bootstrap_admin(fresh_store, init_admin_password="second-pw-67890")
|
|
|
|
# Second call recognized the existing admin and did nothing.
|
|
assert result.fresh_boot is False
|
|
# The original password still authenticates (verify_password is
|
|
# verify-or-raise: returns None on match, raises on mismatch); the
|
|
# second password was dropped, so it must NOT verify.
|
|
hash_ = fresh_store.get_password_hash("admin")
|
|
assert hash_ is not None
|
|
verify_password("first-pw-12345", hash_) # no raise == still the original
|
|
with pytest.raises(InvalidPasswordError):
|
|
verify_password("second-pw-67890", hash_)
|
|
|
|
|
|
def test_bootstrap_remote_no_password_needs_setup_no_token(
|
|
fresh_store: SqlAlchemyAccountStore, isolated_home: Path
|
|
) -> None:
|
|
"""Remote (non-loopback) + no password → needs_setup, no token, no auto-open.
|
|
|
|
On a Docker / Render / Railway deploy the first admin is claimed
|
|
via the web Create-admin form (needs_setup). Bootstrap creates
|
|
nothing, writes no CLI token (the operator is on a different
|
|
machine), and requests no browser auto-open (open_url None — the
|
|
server has no display).
|
|
"""
|
|
result = bootstrap_admin(
|
|
fresh_store,
|
|
base_url="https://omnigent.example.com",
|
|
cookie_secret=secrets.token_bytes(32),
|
|
)
|
|
|
|
assert result.needs_setup is True
|
|
assert result.open_url is None
|
|
assert result.tui_token_written is False
|
|
assert not (isolated_home / ".omnigent" / "auth_tokens.json").exists()
|
|
|
|
|
|
def test_bootstrap_loopback_no_password_needs_setup_opens_form(
|
|
fresh_store: SqlAlchemyAccountStore, isolated_home: Path
|
|
) -> None:
|
|
"""Loopback + no password → needs_setup, browser auto-opens to the form.
|
|
|
|
Local first run with no flag: no admin is created (no defaults),
|
|
needs_setup is reported, and open_url is the loopback base URL so
|
|
the lifespan opens the browser straight to the Create-admin form.
|
|
No CLI token yet — there's no admin to mint one for.
|
|
"""
|
|
base_url = "http://localhost:8000"
|
|
result = bootstrap_admin(fresh_store, base_url=base_url, cookie_secret=secrets.token_bytes(32))
|
|
|
|
assert result.needs_setup is True
|
|
assert result.open_url == base_url
|
|
assert result.tui_token_written is False
|
|
assert fresh_store.get_user("admin") is None
|
|
|
|
|
|
def test_bootstrap_init_password_loopback_writes_cli_token_no_autoopen(
|
|
fresh_store: SqlAlchemyAccountStore, isolated_home: Path
|
|
) -> None:
|
|
"""Supplied password on loopback → admin created, CLI token written, no auto-open.
|
|
|
|
The flag path creates the admin and mints the loopback CLI token
|
|
(so ``omnigent run`` is signed in), but does NOT auto-open the
|
|
browser — the operator chose the password and will log in when
|
|
they want.
|
|
"""
|
|
base_url = "http://localhost:8000"
|
|
result = bootstrap_admin(
|
|
fresh_store,
|
|
init_admin_password="my-supplied-pw",
|
|
base_url=base_url,
|
|
cookie_secret=secrets.token_bytes(32),
|
|
)
|
|
|
|
assert result.fresh_boot is True
|
|
assert result.needs_setup is False
|
|
assert result.open_url is None
|
|
assert result.tui_token_written is True
|
|
from omnigent import cli_auth
|
|
|
|
assert cli_auth.load_token(base_url) is not None
|
|
|
|
|
|
def test_bootstrap_refreshes_cli_token_on_returning_loopback_boot(
|
|
fresh_store: SqlAlchemyAccountStore, isolated_home: Path
|
|
) -> None:
|
|
"""A returning boot (admin already exists) re-mints the CLI token for this spawn.
|
|
|
|
The daemon spawns the loopback server on a fresh port each time and
|
|
the first-boot token is port-keyed + one-time, so a returning boot
|
|
must re-mint a token for the current URL — otherwise ``omnigent
|
|
run`` 401s against its own server once an admin exists (the Bug B
|
|
that motivated this). Here the second boot uses a *different* base
|
|
URL (new port) and must still produce a usable token for it.
|
|
"""
|
|
from omnigent import cli_auth
|
|
|
|
first = bootstrap_admin(
|
|
fresh_store,
|
|
init_admin_password="pw-12345",
|
|
base_url="http://127.0.0.1:8000",
|
|
cookie_secret=secrets.token_bytes(32),
|
|
)
|
|
assert first.fresh_boot is True
|
|
|
|
# Second spawn: admin already exists, new port.
|
|
new_url = "http://127.0.0.1:54312"
|
|
second = bootstrap_admin(
|
|
fresh_store,
|
|
base_url=new_url,
|
|
cookie_secret=secrets.token_bytes(32),
|
|
)
|
|
|
|
assert second.fresh_boot is False
|
|
assert second.tui_token_written is True
|
|
assert cli_auth.load_token(new_url) is not None, (
|
|
"returning boot must mint a CLI token for the new spawn URL so "
|
|
"`omnigent run` authenticates"
|
|
)
|
|
|
|
|
|
# ── resolve_admin_username (unit) ─────────────────────────────────
|
|
|
|
|
|
def test_resolve_admin_username_uses_env_override(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""OMNIGENT_ACCOUNTS_INIT_ADMIN_USERNAME wins over the OS user.
|
|
|
|
The override is the right knob for headless / Docker deploys
|
|
where ``getpass.getuser()`` returns ``"root"`` (not great
|
|
semantically) or for any deploy that wants a stable account
|
|
name regardless of who launches the process.
|
|
"""
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_USERNAME", "operator")
|
|
assert resolve_admin_username() == "operator"
|
|
|
|
|
|
def test_resolve_admin_username_falls_back_to_os_user(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""With no env override, the OS user (via getpass) is the admin name.
|
|
|
|
This is the laptop-UX win — running ``omnigent server`` as
|
|
``dhruv.gupta`` creates a ``dhruv.gupta`` admin, so the CLI
|
|
and the web UI share one identity from the start (no
|
|
separate "local" / "admin" split).
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_USERNAME", raising=False)
|
|
# Mock getpass.getuser to a known value so the test is deterministic
|
|
# across CI runners with different $USER values.
|
|
import getpass
|
|
|
|
monkeypatch.setattr(getpass, "getuser", lambda: "dhruv.gupta")
|
|
|
|
assert resolve_admin_username() == "dhruv.gupta"
|
|
|
|
|
|
def test_resolve_admin_username_falls_back_to_admin_on_reserved_name(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""OS user matching a reserved sentinel (``local`` / ``__public__``)
|
|
falls back to the literal ``"admin"``.
|
|
|
|
Without this, a deploy launched as the (admittedly weird) OS
|
|
user "local" would create an account named "local" and
|
|
immediately have it rejected by the auth provider's
|
|
reserved-name guard — silent breakage.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_USERNAME", raising=False)
|
|
import getpass
|
|
|
|
monkeypatch.setattr(getpass, "getuser", lambda: "local")
|
|
|
|
assert resolve_admin_username() == "admin"
|
|
|
|
|
|
def test_resolve_admin_username_falls_back_on_regex_mismatch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Names that don't match the username regex fall back to "admin".
|
|
|
|
Covers OS users with uppercase letters (Windows ``Administrator``),
|
|
spaces, or other characters the route layer would reject at
|
|
registration time anyway.
|
|
"""
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_USERNAME", raising=False)
|
|
import getpass
|
|
|
|
monkeypatch.setattr(getpass, "getuser", lambda: "Administrator")
|
|
# Lowercased "administrator" actually IS valid — the regex
|
|
# accepts lowercase letters. Use a name that breaks the regex
|
|
# outright: leading dash, since the regex requires [a-z0-9]
|
|
# as the first char.
|
|
monkeypatch.setattr(getpass, "getuser", lambda: "-dashleading")
|
|
assert resolve_admin_username() == "admin"
|
|
|
|
|
|
# ── Routes (integration via TestClient) ───────────────────────────
|
|
|
|
|
|
def _build_accounts_app(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
init_admin_password: str | None,
|
|
) -> Iterator[TestClient]:
|
|
"""Build a production-shaped accounts-mode app + TestClient.
|
|
|
|
Shared by the :func:`accounts_app` (admin pre-seeded) and
|
|
:func:`accounts_app_needs_setup` (no admin → first-run setup
|
|
pending) fixtures. Wires every store + router + provider exactly
|
|
like ``create_app`` does in production.
|
|
|
|
:param tmp_path: Per-test temp dir (HOME, sqlite, artifacts).
|
|
:param monkeypatch: Pytest monkeypatch for env vars.
|
|
:param init_admin_password: When set, bootstrap creates the admin
|
|
with it (admin exists, no setup pending). When ``None``, no
|
|
admin is created and ``/v1/info`` reports ``needs_setup``.
|
|
"""
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
# Accounts is the default provider now, but pin it explicitly
|
|
# so this fixture doesn't depend on the global default.
|
|
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "accounts")
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_COOKIE_SECRET", secrets.token_hex(32))
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_BASE_URL", "http://localhost:8000")
|
|
if init_admin_password is not None:
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD", init_admin_password)
|
|
else:
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD", raising=False)
|
|
# Pin the admin username to "admin" so the existing test
|
|
# assertions don't depend on whatever getpass.getuser() returns
|
|
# in CI. The OS-username resolution path is exercised by
|
|
# dedicated tests below.
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_INIT_ADMIN_USERNAME", "admin")
|
|
monkeypatch.setenv("OMNIGENT_ADMIN_CREDENTIALS_PATH", str(tmp_path / "admin-creds"))
|
|
# Don't auto-open the browser during tests.
|
|
monkeypatch.setenv("OMNIGENT_ACCOUNTS_AUTO_OPEN", "0")
|
|
|
|
db_url = f"sqlite:///{tmp_path}/test.db"
|
|
from omnigent.db.utils import get_or_create_engine
|
|
from omnigent.runtime import init as init_runtime
|
|
from omnigent.runtime import telemetry
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.runtime.caps import RuntimeCaps
|
|
from omnigent.server.app import create_app
|
|
from omnigent.stores.agent_store.sqlalchemy_store import (
|
|
SqlAlchemyAgentStore,
|
|
)
|
|
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
|
from omnigent.stores.comment_store.sqlalchemy_store import (
|
|
SqlAlchemyCommentStore,
|
|
)
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
|
from omnigent.stores.host_store import HostStore
|
|
|
|
get_or_create_engine(db_url)
|
|
telemetry.init()
|
|
permission_store = SqlAlchemyPermissionStore(db_url)
|
|
agent_store = SqlAlchemyAgentStore(db_url)
|
|
conversation_store = SqlAlchemyConversationStore(db_url)
|
|
file_store = SqlAlchemyFileStore(db_url)
|
|
comment_store = SqlAlchemyCommentStore(db_url)
|
|
host_store = HostStore(db_url)
|
|
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
|
agent_cache = AgentCache(artifact_store=artifact_store, cache_dir=tmp_path / "cache")
|
|
init_runtime(
|
|
agent_cache=agent_cache,
|
|
caps=RuntimeCaps(),
|
|
agent_store=agent_store,
|
|
file_store=file_store,
|
|
conversation_store=conversation_store,
|
|
artifact_store=artifact_store,
|
|
comment_store=comment_store,
|
|
)
|
|
|
|
auth_provider = create_auth_provider()
|
|
# Explicit AccountStore — create_app no longer constructs one
|
|
# internally so the internal hosted product can opt out by
|
|
# passing None.
|
|
account_store = SqlAlchemyAccountStore(db_url)
|
|
app = create_app(
|
|
agent_store=agent_store,
|
|
file_store=file_store,
|
|
conversation_store=conversation_store,
|
|
artifact_store=artifact_store,
|
|
agent_cache=agent_cache,
|
|
comment_store=comment_store,
|
|
permission_store=permission_store,
|
|
host_store=host_store,
|
|
auth_provider=auth_provider,
|
|
account_store=account_store,
|
|
)
|
|
with TestClient(app) as client:
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
def accounts_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
|
"""Accounts-mode app with the admin pre-seeded (``admin`` / ``admin-pw-12345``).
|
|
|
|
The common case for route tests — an admin already exists, so
|
|
``/v1/info`` reports ``needs_setup=false`` and ``_login`` works.
|
|
"""
|
|
yield from _build_accounts_app(tmp_path, monkeypatch, init_admin_password="admin-pw-12345")
|
|
|
|
|
|
@pytest.fixture
|
|
def accounts_app_needs_setup(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> Iterator[TestClient]:
|
|
"""Accounts-mode app with NO admin yet — first-run setup pending.
|
|
|
|
No ``INIT_ADMIN_PASSWORD``, so bootstrap creates nothing and
|
|
``/v1/info`` reports ``needs_setup=true``. Exercises the
|
|
``/auth/setup`` first-admin claim.
|
|
"""
|
|
yield from _build_accounts_app(tmp_path, monkeypatch, init_admin_password=None)
|
|
|
|
|
|
@pytest.fixture
|
|
def header_mode_app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
|
"""An app wired in header mode (accounts OFF) for negative-case tests.
|
|
|
|
Mirrors ``accounts_app`` but with no accounts env vars and
|
|
``account_store=None`` so /v1/info reports accounts_enabled=false
|
|
and the accounts router never mounts. Used to verify the
|
|
"internal hosted product is byte-equivalent" invariant.
|
|
"""
|
|
# Accounts is now the default provider — explicitly pin
|
|
# "header" so this negative-case fixture actually exercises
|
|
# header mode regardless of the global default.
|
|
monkeypatch.setenv("OMNIGENT_AUTH_PROVIDER", "header")
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_COOKIE_SECRET", raising=False)
|
|
monkeypatch.delenv("OMNIGENT_ACCOUNTS_BASE_URL", raising=False)
|
|
|
|
db_url = f"sqlite:///{tmp_path}/header.db"
|
|
from omnigent.db.utils import get_or_create_engine
|
|
from omnigent.runtime import init as init_runtime
|
|
from omnigent.runtime import telemetry
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.runtime.caps import RuntimeCaps
|
|
from omnigent.server.app import create_app
|
|
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
|
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
|
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
|
from omnigent.stores.host_store import HostStore
|
|
|
|
get_or_create_engine(db_url)
|
|
telemetry.init()
|
|
permission_store = SqlAlchemyPermissionStore(db_url)
|
|
agent_store = SqlAlchemyAgentStore(db_url)
|
|
conversation_store = SqlAlchemyConversationStore(db_url)
|
|
file_store = SqlAlchemyFileStore(db_url)
|
|
comment_store = SqlAlchemyCommentStore(db_url)
|
|
host_store = HostStore(db_url)
|
|
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
|
agent_cache = AgentCache(artifact_store=artifact_store, cache_dir=tmp_path / "cache")
|
|
init_runtime(
|
|
agent_cache=agent_cache,
|
|
caps=RuntimeCaps(),
|
|
agent_store=agent_store,
|
|
file_store=file_store,
|
|
conversation_store=conversation_store,
|
|
artifact_store=artifact_store,
|
|
comment_store=comment_store,
|
|
)
|
|
auth_provider = create_auth_provider()
|
|
app = create_app(
|
|
agent_store=agent_store,
|
|
file_store=file_store,
|
|
conversation_store=conversation_store,
|
|
artifact_store=artifact_store,
|
|
agent_cache=agent_cache,
|
|
comment_store=comment_store,
|
|
permission_store=permission_store,
|
|
host_store=host_store,
|
|
auth_provider=auth_provider,
|
|
account_store=None,
|
|
)
|
|
with TestClient(app) as client:
|
|
yield client
|
|
|
|
|
|
def _login(client: TestClient, username: str, password: str) -> TestClient:
|
|
"""Log in via /auth/login and confirm the session cookie was set."""
|
|
resp = client.post("/auth/login", json={"username": username, "password": password})
|
|
assert resp.status_code == 200, resp.text
|
|
# The TestClient holds cookies across calls — return it so the
|
|
# caller can use it as the authed client.
|
|
return client
|
|
|
|
|
|
def test_info_endpoint_advertises_accounts_enabled(accounts_app: TestClient) -> None:
|
|
"""``/v1/info`` reports accounts_enabled=true when the provider is active.
|
|
|
|
The SPA reads this at boot (unauthed — must not 401) and uses
|
|
the flag to decide whether to register /login, /register,
|
|
/members routes and render the AccountMenu. If this regresses,
|
|
the internal hosted product's SPA would start rendering
|
|
accounts UI for users who can't use it.
|
|
"""
|
|
resp = accounts_app.get("/v1/info")
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["accounts_enabled"] is True
|
|
assert body["login_url"] == "/login"
|
|
|
|
|
|
def test_info_endpoint_reports_disabled_in_header_mode(
|
|
header_mode_app: TestClient,
|
|
) -> None:
|
|
"""``/v1/info`` reports accounts_enabled=false in header mode.
|
|
|
|
The frontend gates EVERY accounts surface (route table, account
|
|
menu, /auth/me probe) on this value. A regression where it
|
|
returned True in header mode would render broken login forms
|
|
on the internal hosted product. Negative-case complement to
|
|
``test_info_endpoint_advertises_accounts_enabled``.
|
|
"""
|
|
resp = header_mode_app.get("/v1/info")
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["accounts_enabled"] is False
|
|
assert body["login_url"] is None
|
|
|
|
|
|
def test_login_wrong_password_returns_401(accounts_app: TestClient) -> None:
|
|
"""Wrong password → 401 with a generic error message.
|
|
|
|
The message MUST NOT distinguish "no such user" from "wrong
|
|
password" — leaking that would enable username enumeration.
|
|
"""
|
|
resp = accounts_app.post("/auth/login", json={"username": "admin", "password": "wrong"})
|
|
assert resp.status_code == 401
|
|
assert "invalid" in resp.json()["error"].lower()
|
|
|
|
|
|
def test_login_unknown_user_returns_401(accounts_app: TestClient) -> None:
|
|
"""Unknown user → same 401 + same generic message as wrong-password."""
|
|
resp = accounts_app.post(
|
|
"/auth/login", json={"username": "ghost", "password": "anything12345"}
|
|
)
|
|
assert resp.status_code == 401
|
|
assert "invalid" in resp.json()["error"].lower()
|
|
|
|
|
|
def test_login_correct_password_sets_cookie(accounts_app: TestClient) -> None:
|
|
"""Correct credentials → 200 + session cookie + user payload."""
|
|
resp = accounts_app.post(
|
|
"/auth/login", json={"username": "admin", "password": "admin-pw-12345"}
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["user"]["id"] == "admin"
|
|
assert body["user"]["is_admin"] is True
|
|
assert "token" in body
|
|
# And the cookie name MUST be the HTTP variant for a localhost base_url.
|
|
assert "ap_session" in resp.headers.get("set-cookie", "")
|
|
|
|
|
|
def test_me_unauthed_returns_401(accounts_app: TestClient) -> None:
|
|
"""No cookie → /auth/me returns 401."""
|
|
resp = accounts_app.get("/auth/me")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_me_authed_returns_user(accounts_app: TestClient) -> None:
|
|
"""Cookie-authed call returns the user's identity + admin flag."""
|
|
client = _login(accounts_app, "admin", "admin-pw-12345")
|
|
resp = client.get("/auth/me")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["id"] == "admin"
|
|
assert body["is_admin"] is True
|
|
|
|
|
|
def test_logout_clears_cookie(accounts_app: TestClient) -> None:
|
|
"""/auth/logout returns 204 and emits a Set-Cookie that clears the session."""
|
|
client = _login(accounts_app, "admin", "admin-pw-12345")
|
|
resp = client.post("/auth/logout")
|
|
assert resp.status_code == 204
|
|
|
|
|
|
# ── Invite + register (integration) ───────────────────────────────
|
|
|
|
|
|
def test_non_admin_cannot_mint_invite(accounts_app: TestClient) -> None:
|
|
"""/auth/invite refuses non-admin callers with 403.
|
|
|
|
Privilege separation: ordinary members can't create new
|
|
accounts. Without this, any user could invite teammates,
|
|
making the admin role useless.
|
|
"""
|
|
# First mint an invite as admin so we have a way to create a member.
|
|
admin_client = _login(accounts_app, "admin", "admin-pw-12345")
|
|
invite_resp = admin_client.post("/auth/invite", json={})
|
|
token = invite_resp.json()["token"]
|
|
|
|
# Register alice via the invite (fresh client → fresh cookie).
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
alice = _TC(accounts_app.app)
|
|
register_resp = alice.post(
|
|
"/auth/register",
|
|
json={"invite": token, "username": "alice", "password": "alice-pw-67890"},
|
|
)
|
|
assert register_resp.status_code == 200
|
|
|
|
# Alice trying to mint an invite → 403.
|
|
bad_resp = alice.post("/auth/invite", json={})
|
|
assert bad_resp.status_code == 403
|
|
|
|
|
|
def test_invite_is_single_use(accounts_app: TestClient) -> None:
|
|
"""The same invite cannot be redeemed twice.
|
|
|
|
Atomic single-use is enforced at the store layer via
|
|
UPDATE ... WHERE redeemed_at IS NULL. This integration
|
|
test exercises the route + store together.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
token = admin.post("/auth/invite", json={}).json()["token"]
|
|
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
# First redemption succeeds.
|
|
first = _TC(accounts_app.app).post(
|
|
"/auth/register",
|
|
json={"invite": token, "username": "alice", "password": "alice-pw-67890"},
|
|
)
|
|
assert first.status_code == 200
|
|
|
|
# Second redemption (different username, same token) fails with 400.
|
|
second = _TC(accounts_app.app).post(
|
|
"/auth/register",
|
|
json={"invite": token, "username": "bob", "password": "bob-pw-12345"},
|
|
)
|
|
assert second.status_code == 400
|
|
assert (
|
|
"invalid" in second.json()["error"].lower() or "expired" in second.json()["error"].lower()
|
|
)
|
|
|
|
|
|
def test_register_rejects_reserved_username(accounts_app: TestClient) -> None:
|
|
"""Reserved usernames ("local", "__public__") cannot be claimed.
|
|
|
|
The auth provider also rejects them at cookie validation time
|
|
so even bypassing this guard wouldn't authenticate, but
|
|
catching it at registration time gives a clean error and
|
|
prevents the row from being created.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
token = admin.post("/auth/invite", json={}).json()["token"]
|
|
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
resp = _TC(accounts_app.app).post(
|
|
"/auth/register",
|
|
json={"invite": token, "username": "local", "password": "aaaaaaaa"},
|
|
)
|
|
assert resp.status_code == 400
|
|
assert "reserved" in resp.json()["error"].lower()
|
|
|
|
|
|
def test_alice_cannot_see_bobs_admin_endpoints(accounts_app: TestClient) -> None:
|
|
"""Cross-user isolation: a regular member can't reach admin routes.
|
|
|
|
The Alice/Bob multi-user check: ensures that having a valid
|
|
session for one user never grants access to another user's
|
|
admin-only surface. Server-side enforcement; the frontend's
|
|
role gating is just UX, this is what actually protects the
|
|
data.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
# Create alice (non-admin).
|
|
alice_token = admin.post("/auth/invite", json={"is_admin": False}).json()["token"]
|
|
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
alice = _TC(accounts_app.app)
|
|
alice.post(
|
|
"/auth/register",
|
|
json={"invite": alice_token, "username": "alice", "password": "alice-pw-67890"},
|
|
)
|
|
|
|
# Each admin route must 403 for alice.
|
|
for path, method in (
|
|
("/auth/users", "GET"),
|
|
("/auth/invite", "POST"),
|
|
("/auth/users/admin/reset", "POST"),
|
|
):
|
|
resp = alice.request(method, path, json={})
|
|
assert resp.status_code == 403, f"{method} {path} should 403 for non-admin"
|
|
|
|
|
|
# ── Magic-link (integration) ──────────────────────────────────────
|
|
|
|
|
|
def test_magic_link_authenticates_in_fresh_client(
|
|
accounts_app: TestClient,
|
|
) -> None:
|
|
"""Magic-link redeem in a fresh browser signs the same user in.
|
|
|
|
Closes the CLI → web handoff: a CLI session mints a magic
|
|
URL, the URL pops the user into a browser already signed in.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
magic_resp = admin.post("/auth/magic")
|
|
assert magic_resp.status_code == 200
|
|
redeem_url = magic_resp.json()["redeem_url"]
|
|
# Strip the base_url to get just the path + query for TestClient.
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(redeem_url)
|
|
path_q = f"{parsed.path}?{parsed.query}"
|
|
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
fresh = _TC(accounts_app.app)
|
|
resp = fresh.get(path_q, follow_redirects=False)
|
|
assert resp.status_code == 302
|
|
# The fresh client now has a cookie and /auth/me returns admin.
|
|
me = fresh.get("/auth/me")
|
|
assert me.status_code == 200
|
|
assert me.json()["id"] == "admin"
|
|
|
|
|
|
def test_magic_link_is_single_use(accounts_app: TestClient) -> None:
|
|
"""A second redeem of the same token redirects to ``/login?magic=expired``."""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
redeem_url = admin.post("/auth/magic").json()["redeem_url"]
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(redeem_url)
|
|
path_q = f"{parsed.path}?{parsed.query}"
|
|
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
_TC(accounts_app.app).get(path_q, follow_redirects=False) # consume
|
|
second = _TC(accounts_app.app).get(path_q, follow_redirects=False)
|
|
|
|
assert second.status_code == 302
|
|
assert "magic=expired" in second.headers.get("location", "")
|
|
|
|
|
|
def test_unauthed_cannot_mint_magic_link(accounts_app: TestClient) -> None:
|
|
"""Magic-link minting requires an authenticated session.
|
|
|
|
Without this check, anyone could mint a token bound to no
|
|
user (or worse, harvest tokens for later analysis).
|
|
"""
|
|
resp = accounts_app.post("/auth/magic")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
# ── Members admin (integration) ───────────────────────────────────
|
|
|
|
|
|
def test_admin_can_list_users(accounts_app: TestClient) -> None:
|
|
"""GET /auth/users returns every account for admin callers."""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
resp = admin.get("/auth/users")
|
|
assert resp.status_code == 200
|
|
user_ids = {u["id"] for u in resp.json()["users"]}
|
|
assert "admin" in user_ids
|
|
|
|
|
|
def test_admin_list_excludes_legacy_local_and_public_sentinels(
|
|
accounts_app: TestClient,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The Members page hides ``"local"`` and ``"__public__"``.
|
|
|
|
Both rows exist in the ``users`` table — ``"local"`` is
|
|
backfilled by the original session-permissions migration so
|
|
pre-accounts deploys had a default owner row for existing
|
|
conversations, and ``"__public__"`` is the anonymous-grant
|
|
sentinel. Neither is actionable in accounts mode (reserved
|
|
names can't authenticate, can't be reset, can't be promoted),
|
|
so listing them on the Members page is dead weight.
|
|
|
|
We seed both rows directly (bypassing the accounts API, which
|
|
rejects reserved names) into the same sqlite file the
|
|
``accounts_app`` fixture wired up, then confirm the admin
|
|
list filter drops them.
|
|
"""
|
|
from omnigent.db.db_models import SqlUser
|
|
from omnigent.db.utils import get_or_create_engine, make_managed_session_maker
|
|
|
|
db_url = f"sqlite:///{tmp_path}/test.db"
|
|
engine = get_or_create_engine(db_url)
|
|
session_maker = make_managed_session_maker(engine)
|
|
with session_maker() as session:
|
|
for sentinel in ("local", "__public__"):
|
|
if session.get(SqlUser, (0, sentinel)) is None:
|
|
session.add(SqlUser(id=sentinel, is_admin=False))
|
|
session.commit()
|
|
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
resp = admin.get("/auth/users")
|
|
assert resp.status_code == 200
|
|
user_ids = {u["id"] for u in resp.json()["users"]}
|
|
assert "admin" in user_ids
|
|
assert "local" not in user_ids, (
|
|
"list_users() must hide the legacy 'local' row from the Members page"
|
|
)
|
|
assert "__public__" not in user_ids, (
|
|
"list_users() must hide the '__public__' anonymous-grant sentinel"
|
|
)
|
|
|
|
|
|
def test_admin_cannot_delete_self(accounts_app: TestClient) -> None:
|
|
"""Deleting the calling admin is refused with 400.
|
|
|
|
Prevents self-lockout: deleting yourself while signed in
|
|
would leave your session valid but the row gone, and any
|
|
future cookie validation would fail. Worse — if you're the
|
|
only admin, the deploy has no recovery path.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
resp = admin.delete("/auth/users/admin")
|
|
# Both "cannot delete self" AND "cannot delete bootstrap admin"
|
|
# routes through 400 here; either reason is fine.
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_admin_can_delete_former_admin_when_others_exist(
|
|
accounts_app: TestClient,
|
|
) -> None:
|
|
"""The previously-locked bootstrap admin IS deletable when another admin exists.
|
|
|
|
Earlier iterations hard-coded "can't delete the user named
|
|
'admin'", which made sense when the bootstrap username was
|
|
always the literal "admin". Now the bootstrap defaults to
|
|
the OS user (``dhruv.gupta`` etc.) so the check generalized
|
|
to "would this leave zero admins". As long as another admin
|
|
exists, the original bootstrap row IS deletable — admins
|
|
might want to rename or rotate it.
|
|
|
|
The "last admin" invariant is exercised by the negative
|
|
test below.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
invite_token = admin.post("/auth/invite", json={"is_admin": True}).json()["token"]
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
second = _TC(accounts_app.app)
|
|
second.post(
|
|
"/auth/register",
|
|
json={"invite": invite_token, "username": "second", "password": "second-pw-1234"},
|
|
)
|
|
|
|
resp = second.delete("/auth/users/admin")
|
|
assert resp.status_code == 204, resp.text
|
|
|
|
|
|
def test_admin_cannot_delete_last_admin(accounts_app: TestClient) -> None:
|
|
"""If only one admin exists, deleting them returns 400.
|
|
|
|
Closes the same recovery-path invariant the old "cannot
|
|
delete the bootstrap admin" check protected, but generalizes
|
|
so it works regardless of the bootstrap username. Setup:
|
|
create a second user as a regular member (NOT admin), then
|
|
have the second user attempt to delete the only admin.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
member_invite = admin.post("/auth/invite", json={"is_admin": False}).json()["token"]
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
member = _TC(accounts_app.app)
|
|
member.post(
|
|
"/auth/register",
|
|
json={"invite": member_invite, "username": "alice", "password": "alice-pw-1234"},
|
|
)
|
|
|
|
# alice (non-admin) → 403; doesn't even reach the last-admin check.
|
|
resp = member.delete("/auth/users/admin")
|
|
assert resp.status_code == 403
|
|
|
|
# Now promote alice manually by using the admin to reset, then
|
|
# try deleting admin from alice's session — but alice would
|
|
# need to be admin first. Simpler scenario: use admin to
|
|
# delete admin (self-delete is the actual block here). For
|
|
# last-admin, a future test where alice IS promoted would
|
|
# cover; today we don't have a promote endpoint.
|
|
#
|
|
# The closest assertion we can make from existing routes:
|
|
# admin trying to delete themselves is rejected, which proves
|
|
# there's no path to "zero admins" via the DELETE route.
|
|
resp = admin.delete("/auth/users/admin")
|
|
assert resp.status_code == 400
|
|
assert "self" in resp.json()["error"].lower() or "last admin" in resp.json()["error"].lower()
|
|
|
|
|
|
def test_admin_reset_returns_new_plaintext_once(
|
|
accounts_app: TestClient,
|
|
) -> None:
|
|
"""Admin-issued reset returns the new plaintext password exactly once.
|
|
|
|
This is the "DM the password" flow — the admin sends the
|
|
plaintext out-of-band. The route returning it is the only
|
|
place it surfaces; the stored hash overwrites the old one
|
|
so the prior password stops working.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
invite_token = admin.post("/auth/invite", json={}).json()["token"]
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
alice = _TC(accounts_app.app)
|
|
alice.post(
|
|
"/auth/register",
|
|
json={"invite": invite_token, "username": "alice", "password": "old-pw-12345"},
|
|
)
|
|
|
|
resp = admin.post("/auth/users/alice/reset")
|
|
assert resp.status_code == 200
|
|
new_pw = resp.json()["new_password"]
|
|
assert len(new_pw) > 10
|
|
|
|
# The new password works, the old one doesn't.
|
|
fresh = _TC(accounts_app.app)
|
|
bad = fresh.post("/auth/login", json={"username": "alice", "password": "old-pw-12345"})
|
|
assert bad.status_code == 401
|
|
good = fresh.post("/auth/login", json={"username": "alice", "password": new_pw})
|
|
assert good.status_code == 200
|
|
|
|
|
|
def test_admin_can_delete_normal_member(accounts_app: TestClient) -> None:
|
|
"""Admin DELETE /auth/users/{id} succeeds and removes the user.
|
|
|
|
The refusal paths (self-delete, bootstrap-admin) are tested
|
|
above; this is the positive path — confirms the route + the
|
|
store's ``delete_user`` actually drop the row and the user
|
|
no longer appears in the listing.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
invite = admin.post("/auth/invite", json={}).json()["token"]
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
alice = _TC(accounts_app.app)
|
|
alice.post(
|
|
"/auth/register",
|
|
json={"invite": invite, "username": "alice", "password": "alice-pw-1234"},
|
|
)
|
|
|
|
# Pre-condition: alice in the list.
|
|
pre = admin.get("/auth/users").json()
|
|
assert "alice" in {u["id"] for u in pre["users"]}
|
|
|
|
resp = admin.delete("/auth/users/alice")
|
|
assert resp.status_code == 204, resp.text
|
|
|
|
post = admin.get("/auth/users").json()
|
|
assert "alice" not in {u["id"] for u in post["users"]}
|
|
|
|
|
|
def test_change_own_password_round_trip(accounts_app: TestClient) -> None:
|
|
"""POST /auth/users/me/password rotates the password.
|
|
|
|
Correct old password → 204, new password works on the next
|
|
login, old password stops working. Covers the happy path
|
|
that the self-serve UX depends on.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
resp = admin.post(
|
|
"/auth/users/me/password",
|
|
json={"old_password": "admin-pw-12345", "new_password": "new-strong-pw-1"},
|
|
)
|
|
assert resp.status_code == 204, resp.text
|
|
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
# Old password no longer works.
|
|
bad = _TC(accounts_app.app).post(
|
|
"/auth/login", json={"username": "admin", "password": "admin-pw-12345"}
|
|
)
|
|
assert bad.status_code == 401
|
|
|
|
# New password does.
|
|
good = _TC(accounts_app.app).post(
|
|
"/auth/login", json={"username": "admin", "password": "new-strong-pw-1"}
|
|
)
|
|
assert good.status_code == 200
|
|
|
|
|
|
def test_change_own_password_rejects_wrong_old_password(
|
|
accounts_app: TestClient,
|
|
) -> None:
|
|
"""Wrong old_password → 401, password is NOT rotated.
|
|
|
|
Required because the route is reachable by anyone with a
|
|
valid session — without verifying old_password an attacker
|
|
who steals a session cookie could lock out the legitimate
|
|
user by setting a new password.
|
|
"""
|
|
admin = _login(accounts_app, "admin", "admin-pw-12345")
|
|
resp = admin.post(
|
|
"/auth/users/me/password",
|
|
json={"old_password": "wrong", "new_password": "new-strong-pw-1"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
# Original password still works.
|
|
from fastapi.testclient import TestClient as _TC
|
|
|
|
good = _TC(accounts_app.app).post(
|
|
"/auth/login", json={"username": "admin", "password": "admin-pw-12345"}
|
|
)
|
|
assert good.status_code == 200
|
|
|
|
|
|
def test_purge_expired_tokens_drops_only_expired(
|
|
fresh_store: SqlAlchemyAccountStore,
|
|
) -> None:
|
|
"""purge_expired_tokens deletes expired rows + returns the count.
|
|
|
|
Boundary case: a token whose expires_at exactly equals "now"
|
|
is considered expired (the WHERE clause is ``<= now``).
|
|
"""
|
|
fresh_store.create_token(
|
|
"live",
|
|
kind="invite",
|
|
user_id=None,
|
|
created_by="admin",
|
|
created_at=1000,
|
|
expires_at=2000,
|
|
)
|
|
fresh_store.create_token(
|
|
"expired-1",
|
|
kind="invite",
|
|
user_id=None,
|
|
created_by="admin",
|
|
created_at=10,
|
|
expires_at=20,
|
|
)
|
|
fresh_store.create_token(
|
|
"expired-2",
|
|
kind="magic",
|
|
user_id="admin",
|
|
created_by=None,
|
|
created_at=100,
|
|
expires_at=200,
|
|
)
|
|
|
|
n = fresh_store.purge_expired_tokens(now_epoch_seconds=500)
|
|
assert n == 2
|
|
|
|
# The non-expired token still redeems.
|
|
redeemed = fresh_store.redeem_token("live", kind="invite", now_epoch_seconds=1500)
|
|
assert redeemed is not None and redeemed.id == "live"
|
|
|
|
|
|
# ── CLI: omnigent login accounts flow ───────────────────────────
|
|
|
|
|
|
def test_cli_accounts_login_happy_path_stores_token(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""`omnigent login` in accounts mode prompts → POSTs → stores token.
|
|
|
|
Mocks the network surface (the /v1/me probe + the /auth/login
|
|
POST) and the token storage (cli_auth.store_token writes to
|
|
the user's ~/.omnigent/), so the test verifies the CLI
|
|
plumbing without spinning up a server.
|
|
|
|
Closes the AI-review gap flagged in the first review pass —
|
|
the accounts-login CLI path was the only auth code in the
|
|
PR without test coverage.
|
|
"""
|
|
import httpx as _httpx
|
|
from click.testing import CliRunner
|
|
|
|
from omnigent import cli_auth
|
|
from omnigent.cli import cli
|
|
|
|
# Redirect $HOME so cli_auth.store_token writes into tmp.
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
|
|
# First call: /v1/me probe → 401 + login_url=/login so the CLI
|
|
# picks the accounts branch.
|
|
# Second call: /auth/login → 200 with the token payload.
|
|
calls = {"n": 0}
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status_code: int, body: object) -> None:
|
|
self.status_code = status_code
|
|
self._body = body
|
|
self.is_success = 200 <= status_code < 300
|
|
self.text = str(body)
|
|
# The login probe inspects response headers (Databricks-fronted
|
|
# server detection); a plain accounts server sends none relevant.
|
|
self.headers: dict[str, str] = {}
|
|
|
|
def json(self) -> object:
|
|
return self._body
|
|
|
|
def fake_get(url: str, **_kw: object) -> _FakeResponse:
|
|
calls["n"] += 1
|
|
assert url.endswith("/v1/me")
|
|
return _FakeResponse(401, {"user_id": None, "login_url": "/login"})
|
|
|
|
def fake_post(url: str, **kw: object) -> _FakeResponse:
|
|
calls["n"] += 1
|
|
assert url.endswith("/auth/login")
|
|
body = kw["json"]
|
|
assert body == {"username": "alice", "password": "alice-pw-1234"}
|
|
return _FakeResponse(
|
|
200,
|
|
{
|
|
"token": "fake.jwt.token",
|
|
"user": {"id": "alice", "is_admin": False},
|
|
"expires_in": 8 * 3600,
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(_httpx, "get", fake_get)
|
|
monkeypatch.setattr(_httpx, "post", fake_post)
|
|
|
|
# CliRunner feeds the prompts via stdin: username (empty → default
|
|
# "admin", but we override with "alice"), then password.
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["login", "http://localhost:8000"],
|
|
input="alice\nalice-pw-1234\n",
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Logged in as alice" in result.output
|
|
# The store_token side effect lands in ~/.omnigent/auth_tokens.json.
|
|
assert cli_auth.load_token("http://localhost:8000") == "fake.jwt.token"
|
|
|
|
|
|
def test_cli_accounts_login_wrong_password_surfaces_clean_error(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A 401 from /auth/login → ClickException with the generic message.
|
|
|
|
The server returns "invalid username or password" without
|
|
distinguishing between unknown-user and wrong-password — the
|
|
CLI surfaces that as a ``ClickException`` (non-zero exit,
|
|
formatted as ``Error: ...``) rather than a raw traceback.
|
|
"""
|
|
import httpx as _httpx
|
|
from click.testing import CliRunner
|
|
|
|
from omnigent.cli import cli
|
|
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status_code: int, body: object) -> None:
|
|
self.status_code = status_code
|
|
self._body = body
|
|
self.is_success = 200 <= status_code < 300
|
|
self.text = str(body)
|
|
# The login probe inspects response headers (Databricks-fronted
|
|
# server detection); a plain accounts server sends none relevant.
|
|
self.headers: dict[str, str] = {}
|
|
|
|
def json(self) -> object:
|
|
return self._body
|
|
|
|
def fake_get(url: str, **_kw: object) -> _FakeResponse:
|
|
return _FakeResponse(401, {"user_id": None, "login_url": "/login"})
|
|
|
|
def fake_post(url: str, **_kw: object) -> _FakeResponse:
|
|
return _FakeResponse(401, {"error": "invalid username or password"})
|
|
|
|
monkeypatch.setattr(_httpx, "get", fake_get)
|
|
monkeypatch.setattr(_httpx, "post", fake_post)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["login", "http://localhost:8000"],
|
|
input="admin\nwrong-password\n",
|
|
)
|
|
|
|
assert result.exit_code != 0
|
|
assert "Invalid username or password" in result.output
|
|
# Generic message — no enumeration leak about whether the
|
|
# username exists.
|
|
assert "username" not in result.output.lower() or "invalid" in result.output.lower()
|
|
|
|
|
|
def test_cli_accounts_login_network_failure_surfaces_clean_error(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A network error reaching /auth/login → ClickException, not traceback.
|
|
|
|
Covers the case an invited user might hit when their network
|
|
flakes or the server is briefly down between probe and login.
|
|
"""
|
|
import httpx as _httpx
|
|
from click.testing import CliRunner
|
|
|
|
from omnigent.cli import cli
|
|
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, status_code: int, body: object) -> None:
|
|
self.status_code = status_code
|
|
self._body = body
|
|
self.is_success = 200 <= status_code < 300
|
|
self.text = str(body)
|
|
# The login probe inspects response headers (Databricks-fronted
|
|
# server detection); a plain accounts server sends none relevant.
|
|
self.headers: dict[str, str] = {}
|
|
|
|
def json(self) -> object:
|
|
return self._body
|
|
|
|
def fake_get(url: str, **_kw: object) -> _FakeResponse:
|
|
return _FakeResponse(401, {"user_id": None, "login_url": "/login"})
|
|
|
|
def fake_post(url: str, **_kw: object) -> None:
|
|
raise _httpx.HTTPError("connection refused")
|
|
|
|
monkeypatch.setattr(_httpx, "get", fake_get)
|
|
monkeypatch.setattr(_httpx, "post", fake_post)
|
|
|
|
result = CliRunner().invoke(
|
|
cli,
|
|
["login", "http://localhost:8000"],
|
|
input="alice\nalice-pw-1234\n",
|
|
)
|
|
|
|
assert result.exit_code != 0
|
|
assert "Could not reach" in result.output
|
|
|
|
|
|
# ── First-run web setup: POST /auth/setup (first-admin claim) ─────
|
|
|
|
|
|
def test_setup_creates_first_admin_and_signs_in(
|
|
accounts_app_needs_setup: TestClient,
|
|
) -> None:
|
|
"""On a fresh instance, /auth/setup claims the first admin + signs in.
|
|
|
|
The remote-deploy CUJ (Render/Railway/Docker): open the URL, the
|
|
first visitor picks a username + password, and lands signed in as
|
|
an admin — no container access, no log-digging.
|
|
"""
|
|
client = accounts_app_needs_setup
|
|
|
|
# Before setup, /v1/info advertises that setup is pending.
|
|
info_before = client.get("/v1/info").json()
|
|
assert info_before["needs_setup"] is True
|
|
|
|
resp = client.post("/auth/setup", json={"username": "alice", "password": "alice-pw-12345"})
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["user"]["id"] == "alice"
|
|
assert body["user"]["is_admin"] is True
|
|
# The session cookie was set, so the same client is now authed.
|
|
me = client.get("/auth/me")
|
|
assert me.status_code == 200, me.text
|
|
assert me.json()["id"] == "alice"
|
|
|
|
# Setup is no longer pending once the first admin exists.
|
|
info_after = client.get("/v1/info").json()
|
|
assert info_after["needs_setup"] is False
|
|
|
|
|
|
def test_setup_writes_loopback_cli_token(
|
|
accounts_app_needs_setup: TestClient,
|
|
) -> None:
|
|
"""First-run web admin-claim mints the loopback CLI token.
|
|
|
|
The local CUJ: ``omnigent run`` (re)spawns the local server in
|
|
accounts mode with no admin, so the operator claims it via the
|
|
browser form. ``/auth/setup`` must also mint the loopback CLI token
|
|
(the fixture's base URL is ``http://localhost:8000`` — loopback) so
|
|
the in-flight ``omnigent run`` is signed in immediately instead of
|
|
401-ing until the next server boot.
|
|
"""
|
|
from omnigent import cli_auth
|
|
|
|
client = accounts_app_needs_setup
|
|
base_url = "http://localhost:8000"
|
|
# No CLI token before the admin is claimed.
|
|
assert cli_auth.load_token(base_url) is None
|
|
|
|
resp = client.post("/auth/setup", json={"username": "alice", "password": "alice-pw-12345"})
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
# The loopback handoff fired: the spawning CLI now has a usable token.
|
|
assert cli_auth.load_token(base_url) is not None
|
|
|
|
|
|
def test_setup_409_once_an_admin_exists(accounts_app: TestClient) -> None:
|
|
"""/auth/setup hard-locks the instant any account exists.
|
|
|
|
This is the gate that stops the unauthenticated route from being
|
|
used to escalate or add a second admin after first-run. The
|
|
``accounts_app`` fixture pre-seeds an admin, so setup must 409.
|
|
"""
|
|
resp = accounts_app.post(
|
|
"/auth/setup", json={"username": "mallory", "password": "mallory-pw-12345"}
|
|
)
|
|
assert resp.status_code == 409, resp.text
|
|
# And no account was created by the rejected call.
|
|
assert accounts_app.get("/v1/info").json()["needs_setup"] is False
|
|
|
|
|
|
def test_setup_is_single_use(accounts_app_needs_setup: TestClient) -> None:
|
|
"""A second /auth/setup after the first claim is rejected with 409."""
|
|
client = accounts_app_needs_setup
|
|
|
|
first = client.post("/auth/setup", json={"username": "alice", "password": "alice-pw-12345"})
|
|
assert first.status_code == 200, first.text
|
|
|
|
second = client.post("/auth/setup", json={"username": "bob", "password": "bob-pw-123456"})
|
|
assert second.status_code == 409, second.text
|
|
# The rejected second claim created no account (alice is signed in
|
|
# from the first setup, so she can list users).
|
|
user_ids = {u["id"] for u in client.get("/auth/users").json()["users"]}
|
|
assert "alice" in user_ids
|
|
assert "bob" not in user_ids
|