416 lines
14 KiB
Python
416 lines
14 KiB
Python
"""Unit tests for CLI OIDC token storage (omnigent/cli_auth.py).
|
|
|
|
Tests the store/load/clear lifecycle for session tokens persisted
|
|
by ``omnigent login``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture()
|
|
def token_dir(tmp_path, monkeypatch):
|
|
"""Redirect the token file to a temp directory.
|
|
|
|
Patches ``state_dir`` to return ``tmp_path`` so tests don't
|
|
touch ``~/.omnigent``.
|
|
|
|
:param tmp_path: Pytest temp directory.
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:returns: The temp directory path.
|
|
"""
|
|
monkeypatch.setattr(
|
|
"omnigent.cli_auth._token_file_path",
|
|
lambda: tmp_path / "auth_tokens.json",
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
def test_store_and_load_token(token_dir) -> None:
|
|
"""A stored token can be loaded back by server URL.
|
|
|
|
This is the happy path: ``omnigent login`` stores a token,
|
|
``omnigent run --server`` loads it.
|
|
"""
|
|
from omnigent.cli_auth import load_token, store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="jwt-abc",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
|
|
result = load_token("http://localhost:8000")
|
|
# Token must be the exact value stored.
|
|
assert result == "jwt-abc", f"Expected 'jwt-abc', got {result!r}."
|
|
|
|
|
|
def test_load_returns_none_when_no_file(token_dir) -> None:
|
|
"""load_token returns None when no token file exists.
|
|
|
|
The first time a user runs ``omnigent run --server`` without
|
|
having run ``omnigent login``, there should be no crash.
|
|
"""
|
|
from omnigent.cli_auth import load_token
|
|
|
|
assert load_token("http://localhost:8000") is None
|
|
|
|
|
|
def test_load_returns_none_for_unknown_server(token_dir) -> None:
|
|
"""load_token returns None for a server with no stored token.
|
|
|
|
A token stored for one server must not leak to another.
|
|
"""
|
|
from omnigent.cli_auth import load_token, store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="jwt-abc",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
|
|
assert load_token("http://other-server:9000") is None
|
|
|
|
|
|
def test_load_returns_none_for_expired_token(token_dir) -> None:
|
|
"""load_token returns None when the stored token has expired.
|
|
|
|
Expired tokens must not be used — the user needs to re-run
|
|
``omnigent login``.
|
|
"""
|
|
from omnigent.cli_auth import load_token, store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="jwt-expired",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() - 1, # Already expired.
|
|
)
|
|
|
|
assert load_token("http://localhost:8000") is None
|
|
|
|
|
|
def test_clear_token(token_dir) -> None:
|
|
"""clear_token removes a stored token for a server.
|
|
|
|
After clearing, load_token must return None.
|
|
"""
|
|
from omnigent.cli_auth import clear_token, load_token, store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="jwt-abc",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
clear_token("http://localhost:8000")
|
|
|
|
assert load_token("http://localhost:8000") is None
|
|
|
|
|
|
def test_trailing_slash_normalization(token_dir) -> None:
|
|
"""Server URLs are normalized (trailing slash stripped).
|
|
|
|
``http://localhost:8000/`` and ``http://localhost:8000`` must
|
|
resolve to the same stored token.
|
|
"""
|
|
from omnigent.cli_auth import load_token, store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000/",
|
|
token="jwt-slash",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
|
|
# Load without trailing slash.
|
|
assert load_token("http://localhost:8000") == "jwt-slash"
|
|
|
|
|
|
def test_file_permissions(token_dir) -> None:
|
|
"""Token file is created with 0o600 (user-only read/write).
|
|
|
|
Tokens are sensitive — they must not be world-readable.
|
|
"""
|
|
|
|
from omnigent.cli_auth import store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="jwt-abc",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
|
|
path = token_dir / "auth_tokens.json"
|
|
mode = path.stat().st_mode & 0o777
|
|
# 0o600 = user read + write only.
|
|
assert mode == 0o600, (
|
|
f"Token file should have 0o600 permissions, got {oct(mode)}. "
|
|
f"This means the token could be readable by other users."
|
|
)
|
|
|
|
|
|
def test_store_overwrites_existing(token_dir) -> None:
|
|
"""Storing a token for the same server overwrites the old one.
|
|
|
|
Re-running ``omnigent login`` should update the token, not
|
|
append.
|
|
"""
|
|
from omnigent.cli_auth import load_token, store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="old-token",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="new-token",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
|
|
assert load_token("http://localhost:8000") == "new-token"
|
|
|
|
|
|
def test_multiple_servers(token_dir) -> None:
|
|
"""Tokens for different servers are stored independently.
|
|
|
|
A user may have accounts on multiple servers.
|
|
"""
|
|
from omnigent.cli_auth import load_token, store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="token-a",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
store_token(
|
|
server_url="https://prod.example.com",
|
|
token="token-b",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
|
|
assert load_token("http://localhost:8000") == "token-a"
|
|
assert load_token("https://prod.example.com") == "token-b"
|
|
|
|
|
|
# ── Databricks Apps pointer records ────────────────────────────────
|
|
|
|
|
|
def test_store_and_load_databricks_record(token_dir) -> None:
|
|
"""A stored Databricks pointer record resolves back to its workspace.
|
|
|
|
``omnigent login <apps-url>`` stores the record; the server-auth
|
|
chain looks up the workspace host to mint fresh tokens.
|
|
"""
|
|
from omnigent.cli_auth import load_databricks_workspace_host, store_databricks_auth
|
|
|
|
store_databricks_auth(
|
|
server_url="https://myapp-123.aws.databricksapps.com",
|
|
workspace_host="https://example.databricks.com",
|
|
user_id="alice@example.com",
|
|
)
|
|
|
|
host = load_databricks_workspace_host("https://myapp-123.aws.databricksapps.com")
|
|
assert host == "https://example.databricks.com", (
|
|
f"Expected the stored workspace host back, got {host!r}. A miss means "
|
|
"the auth chain would silently fall through to ambient credentials."
|
|
)
|
|
|
|
|
|
def test_databricks_request_headers_org_only(token_dir) -> None:
|
|
"""A recorded ?o= selector surfaces as the workspace-routing header.
|
|
|
|
When the bare host is the account, the request routes by this header
|
|
(equivalently to ``?o=``). A record with no org id (single-workspace
|
|
host) yields no header, so those callers are unaffected.
|
|
"""
|
|
from omnigent.cli_auth import databricks_request_headers, store_databricks_auth
|
|
|
|
store_databricks_auth(
|
|
server_url="https://acme.databricks.com/api/2.0/omnigent",
|
|
workspace_host="https://acme.databricks.com",
|
|
org_id="2850744067564480",
|
|
)
|
|
assert databricks_request_headers("https://acme.databricks.com/api/2.0/omnigent") == {
|
|
"X-Databricks-Org-Id": "2850744067564480"
|
|
}
|
|
|
|
store_databricks_auth(
|
|
server_url="https://single.databricks.com/api/2.0/omnigent",
|
|
workspace_host="https://single.databricks.com",
|
|
)
|
|
assert databricks_request_headers("https://single.databricks.com/api/2.0/omnigent") == {}
|
|
|
|
|
|
def test_databricks_request_headers_pairs_bearer_and_org(token_dir) -> None:
|
|
"""The paired minter always emits the bearer and the ?o= header together.
|
|
|
|
The static-dict seams (WS handshakes, hook-config replay) call this so a
|
|
workspace request can never carry ``Authorization`` without the routing
|
|
header. A missing token or selector is omitted, so single-workspace and
|
|
local-unauthenticated callers are unaffected.
|
|
"""
|
|
from omnigent.cli_auth import databricks_request_headers, store_databricks_auth
|
|
|
|
store_databricks_auth(
|
|
server_url="https://acme.databricks.com/api/2.0/omnigent",
|
|
workspace_host="https://acme.databricks.com",
|
|
org_id="2850744067564480",
|
|
)
|
|
recorded = "https://acme.databricks.com/api/2.0/omnigent"
|
|
# Bearer + org travel together.
|
|
assert databricks_request_headers(recorded, bearer_token="tok") == {
|
|
"Authorization": "Bearer tok",
|
|
"X-Databricks-Org-Id": "2850744067564480",
|
|
}
|
|
# Recorded selector but no token (local/unauth): org still rides, no bearer.
|
|
assert databricks_request_headers(recorded) == {"X-Databricks-Org-Id": "2850744067564480"}
|
|
# No record (unknown server): bearer only, no routing header.
|
|
assert databricks_request_headers("https://other.example.com", bearer_token="tok") == {
|
|
"Authorization": "Bearer tok"
|
|
}
|
|
|
|
|
|
def test_databricks_request_headers_folds_extra_headers(token_dir, monkeypatch) -> None:
|
|
"""OMNIGENT_DATABRICKS_EXTRA_HEADERS rides every request built via the helper.
|
|
|
|
Databricks deployments set it to opaque request-routing selector headers so
|
|
a request pins to a specific server instance. Because it is folded into this
|
|
one helper, any caller that routes through it gets the selectors for free; a
|
|
caller that hand-rolls a bare bearer misses them. Malformed / unset input is
|
|
a no-op (prod-safe).
|
|
"""
|
|
from omnigent.cli_auth import databricks_request_headers, store_databricks_auth
|
|
|
|
store_databricks_auth(
|
|
server_url="https://acme.databricks.com/api/2.0/omnigent",
|
|
workspace_host="https://acme.databricks.com",
|
|
org_id="2850744067564480",
|
|
)
|
|
recorded = "https://acme.databricks.com/api/2.0/omnigent"
|
|
monkeypatch.setenv(
|
|
"OMNIGENT_DATABRICKS_EXTRA_HEADERS",
|
|
'{"x-databricks-route-hint": "instance-abc"}',
|
|
)
|
|
# Extra header travels alongside the bearer + ?o= routing header.
|
|
assert databricks_request_headers(recorded, bearer_token="tok") == {
|
|
"Authorization": "Bearer tok",
|
|
"X-Databricks-Org-Id": "2850744067564480",
|
|
"x-databricks-route-hint": "instance-abc",
|
|
}
|
|
# It rides even for an unknown server with no token (routing-only request).
|
|
assert databricks_request_headers("https://other.example.com") == {
|
|
"x-databricks-route-hint": "instance-abc",
|
|
}
|
|
# Malformed JSON is ignored (no crash) so a bad value can't break requests.
|
|
monkeypatch.setenv("OMNIGENT_DATABRICKS_EXTRA_HEADERS", "not-json")
|
|
assert databricks_request_headers("https://other.example.com", bearer_token="tok") == {
|
|
"Authorization": "Bearer tok",
|
|
}
|
|
# Unset (prod default) is a no-op.
|
|
monkeypatch.delenv("OMNIGENT_DATABRICKS_EXTRA_HEADERS", raising=False)
|
|
assert databricks_request_headers("https://other.example.com", bearer_token="tok") == {
|
|
"Authorization": "Bearer tok",
|
|
}
|
|
|
|
|
|
def test_load_token_returns_none_for_databricks_record(token_dir) -> None:
|
|
"""A Databricks pointer record carries NO bearer — load_token must miss.
|
|
|
|
Databricks OAuth tokens expire after ~1h, so the record deliberately
|
|
stores only the workspace host. If load_token returned anything here,
|
|
the JWT path would send a garbage Authorization header.
|
|
"""
|
|
from omnigent.cli_auth import load_token, store_databricks_auth
|
|
|
|
store_databricks_auth(
|
|
server_url="https://myapp-123.aws.databricksapps.com",
|
|
workspace_host="https://example.databricks.com",
|
|
)
|
|
|
|
assert load_token("https://myapp-123.aws.databricksapps.com") is None
|
|
|
|
|
|
def test_load_databricks_host_returns_none_for_jwt_record(token_dir) -> None:
|
|
"""A session-JWT record is not a Databricks pointer record.
|
|
|
|
The Databricks resolution path must not fire for servers the user
|
|
logged into via accounts/OIDC — those send the stored JWT instead.
|
|
"""
|
|
import time
|
|
|
|
from omnigent.cli_auth import load_databricks_workspace_host, store_token
|
|
|
|
store_token(
|
|
server_url="http://localhost:8000",
|
|
token="jwt-abc",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
|
|
assert load_databricks_workspace_host("http://localhost:8000") is None
|
|
|
|
|
|
def test_databricks_record_normalizes_workspace_trailing_slash(token_dir) -> None:
|
|
"""The stored workspace host is normalized (trailing slash stripped).
|
|
|
|
``Config(host=...)`` treats ``https://ws`` and ``https://ws/`` as
|
|
distinct cache keys in some SDK paths — store one canonical form.
|
|
"""
|
|
from omnigent.cli_auth import load_databricks_workspace_host, store_databricks_auth
|
|
|
|
store_databricks_auth(
|
|
server_url="https://myapp-123.aws.databricksapps.com/",
|
|
workspace_host="https://example.databricks.com/",
|
|
)
|
|
|
|
# Lookup without the trailing slash hits the same record, and the
|
|
# stored host comes back canonical.
|
|
host = load_databricks_workspace_host("https://myapp-123.aws.databricksapps.com")
|
|
assert host == "https://example.databricks.com"
|
|
|
|
|
|
def test_databricks_record_overwrites_jwt_record(token_dir) -> None:
|
|
"""Re-logging into a server replaces its record wholesale.
|
|
|
|
A server that switched deployment shape (accounts → Databricks Apps)
|
|
must not keep serving the stale JWT.
|
|
"""
|
|
import time
|
|
|
|
from omnigent.cli_auth import (
|
|
load_databricks_workspace_host,
|
|
load_token,
|
|
store_databricks_auth,
|
|
store_token,
|
|
)
|
|
|
|
store_token(
|
|
server_url="https://server.example.com",
|
|
token="old-jwt",
|
|
user_id="alice@example.com",
|
|
expires_at=time.time() + 3600,
|
|
)
|
|
store_databricks_auth(
|
|
server_url="https://server.example.com",
|
|
workspace_host="https://example.databricks.com",
|
|
)
|
|
|
|
# The JWT is gone; the pointer record answers instead.
|
|
assert load_token("https://server.example.com") is None
|
|
assert (
|
|
load_databricks_workspace_host("https://server.example.com")
|
|
== "https://example.databricks.com"
|
|
)
|