Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/backend/services/authenticator/tests/e2e_unauthorized.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! End-to-end 401 contract for the session-cookie surface: every `.public()`
//! route that requires a session answers 401 both without a cookie and with a
//! cookie that resolves to no session.
//!
//! `#[ignore]` by default (needs the stack up; `run-e2e.sh` drives it):
//!
//! ```text
//! AUTH_BASE=http://localhost:8083 \
//! cargo test -p authenticator --test e2e_unauthorized -- --ignored --nocapture
//! ```
//!
//! Covers the 401 responses declared in the OpenAPI spec for /auth/me,
//! /auth/csrf, and the /auth/sessions family; /auth/refresh and
//! /internal/authz 401s are exercised by `e2e_refresh` and `e2e_login_loop`.

#![allow(clippy::unwrap_used, clippy::expect_used)]

mod common;

use reqwest::Method;

const COOKIE: &str = "__Host-sid";

fn env(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_owned())
}

const SESSION_ROUTES: &[(Method, &str)] = &[
(Method::GET, "/auth/me"),
(Method::GET, "/auth/csrf"),
(Method::GET, "/auth/sessions"),
(Method::DELETE, "/auth/sessions"),
(
Method::DELETE,
"/auth/sessions/00000000-0000-7000-8000-000000000000",
),
];

#[tokio::test]
#[ignore = "requires a running authenticator + fakeidp + Redis stack"]
async fn session_routes_reject_missing_cookie_with_401() {
let auth_base = env("AUTH_BASE", "http://localhost:8083");
let http = common::client();

for (method, path) in SESSION_ROUTES {
let resp = http
.request(method.clone(), format!("{auth_base}{path}"))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
401,
"{method} {path} without a cookie must be 401"
);
let body = resp.text().await.unwrap();
assert_eq!(
body, r#"{"error":"unauthenticated"}"#,
"{method} {path} must return the unauthenticated body"
);
}
}

#[tokio::test]
#[ignore = "requires a running authenticator + fakeidp + Redis stack"]
async fn session_routes_reject_unknown_session_token_with_401() {
let auth_base = env("AUTH_BASE", "http://localhost:8083");
let http = common::client();

for (method, path) in SESSION_ROUTES {
let resp = http
.request(method.clone(), format!("{auth_base}{path}"))
.header(
reqwest::header::COOKIE.as_str(),
format!("{COOKIE}=e2e-unauthorized-not-a-session"),
)
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
401,
"{method} {path} with an unknown session token must be 401"
);
let body = resp.text().await.unwrap();
assert_eq!(
body, r#"{"error":"unauthenticated"}"#,
"{method} {path} must return the unauthenticated body"
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
4 changes: 4 additions & 0 deletions src/backend/services/authenticator/tests/run-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ echo "==> run the session-management loop (step 10.2)"
AUTH_BASE="http://localhost:$AUTH_PORT" E2E_USER=dev@company.nonpresent \
cargo test -p authenticator --test e2e_sessions -- --ignored --nocapture

echo "==> run the 401 contract for the session-cookie surface"
AUTH_BASE="http://localhost:$AUTH_PORT" \
cargo test -p authenticator --test e2e_unauthorized -- --ignored --nocapture

echo "==> run the __override view-as loop (#1941)"
AUTH_BASE="http://localhost:$AUTH_PORT" AUTH_BASE_DISABLED="http://localhost:$AUTH2_PORT" \
E2E_USER=dev@company.nonpresent \
Expand Down
2 changes: 1 addition & 1 deletion src/ingestion/tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Locally, after a run:
./e2e.sh gates api # endpoint gate only, against .artifacts/ (in the runner image; no DB)
```

Per-status-code coverage is **reported, not enforced**: the report renders an endpoints × registered-status-codes table (`✓` observed · `✗` declared but not yet observed · `·` excluded · blank = not declared) and an overall coverage percentage. A code is *coverable* — and so counts toward the percentage — only if a black-box rig can produce it: `coverable(op) = declared(op) − {codes ≥ 500} − UNIVERSAL_BOILERPLATE{401,429} − BLOCKED[op]`. `BLOCKED` absorbs the committed spec's `.standard_errors` over-declaration (#1669) plus pinned rig/product limits, and a `·` code that becomes observed (or a `BLOCKED` op dropped from the spec) is surfaced as a non-blocking advisory so the list stays honest.
Per-status-code coverage is **reported, not enforced**: the report renders an endpoints × registered-status-codes table (`✓` observed · `✗` declared but not yet observed · `·` excluded · blank = not declared) and an overall coverage percentage. A code is *coverable* — and so counts toward the percentage — only if a black-box rig can produce it: `coverable(op) = declared(op) − {codes ≥ 500} − UNIVERSAL_BOILERPLATE{429} − BLOCKED[op]` (401 is coverable — the rig runs auth-ENABLED and `api/test_unauthorized.py` observes it on every operation). `BLOCKED` absorbs the committed spec's `.standard_errors` over-declaration (#1669) plus pinned rig/product limits, and a `·` code that becomes observed (or a `BLOCKED` op dropped from the spec) is surfaced as a non-blocking advisory so the list stays honest.

The [`api/`](api/) contract suite covers all 21 spec operations — one module per path group (`test_metrics.py`, `test_metric_thresholds.py`, `test_admin_thresholds.py`, `test_catalog.py`, `test_columns.py`, `test_persons.py`, `test_metric_results.py`), one test per (path, method, status-code) case, from self-cleaning fixtures (`api/conftest.py`). The declarative metrics suite covers successful `/v1/metric-results` computation for every builtin metric.

Expand Down
13 changes: 12 additions & 1 deletion src/ingestion/tests/e2e/api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
from __future__ import annotations

import uuid
from collections.abc import Iterator

import httpx
import pytest
from lib import mariadb
from lib import api_coverage, mariadb
from lib.analytics import AnalyticsProcess
from lib.config import TEST_TENANT_ID, SessionConfig

Expand All @@ -26,6 +28,15 @@ def api(analytics: AnalyticsProcess):
yield c


@pytest.fixture
def anon_api(analytics: AnalyticsProcess) -> Iterator[httpx.Client]:
"""Recording client with NO Authorization header (401 cases)."""
with httpx.Client(
base_url=analytics.base_url, timeout=30.0, event_hooks={"response": [api_coverage.record_response]}
) as c:
yield c


@pytest.fixture
def other_tenant_headers(analytics) -> dict:
"""`Authorization` for a DIFFERENT tenant — overrides the client's default
Expand Down
59 changes: 59 additions & 0 deletions src/ingestion/tests/e2e/api/test_unauthorized.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Contract: every documented operation answers 401 to an anonymous call.

The rig runs auth-ENABLED (the gears host's oidc-authn-plugin verifies the
gateway JWT), so authentication is rejected before any handler runs: no body,
no path lookup, no tenant resolution. One case per spec operation keeps the
coverage ledger honest — 401 left UNIVERSAL_BOILERPLATE when the auth-disabled
rig did (lib/api_coverage.py), so each declared 401 must now be observed.
"""

from __future__ import annotations

import pytest

pytestmark = pytest.mark.api

PLACEHOLDER_ID = "00000000-0000-7000-8000-000000000000"

OPERATIONS = [
("GET", "/v1/admin/metric-thresholds"),
("POST", "/v1/admin/metric-thresholds"),
("GET", f"/v1/admin/metric-thresholds/{PLACEHOLDER_ID}"),
("PUT", f"/v1/admin/metric-thresholds/{PLACEHOLDER_ID}"),
("DELETE", f"/v1/admin/metric-thresholds/{PLACEHOLDER_ID}"),
("POST", "/v1/catalog/get_metrics"),
("GET", "/v1/columns"),
("GET", "/v1/columns/anon_probe_table"),
("GET", "/v1/metric-definitions"),
("POST", "/v1/metric-drilldown"),
("POST", "/v1/metric-results"),
("GET", "/v1/metrics"),
("POST", "/v1/metrics"),
("POST", "/v1/metrics/queries"),
("GET", f"/v1/metrics/{PLACEHOLDER_ID}"),
("PUT", f"/v1/metrics/{PLACEHOLDER_ID}"),
("DELETE", f"/v1/metrics/{PLACEHOLDER_ID}"),
("POST", f"/v1/metrics/{PLACEHOLDER_ID}/query"),
("GET", f"/v1/metrics/{PLACEHOLDER_ID}/thresholds"),
("POST", f"/v1/metrics/{PLACEHOLDER_ID}/thresholds"),
("PUT", f"/v1/metrics/{PLACEHOLDER_ID}/thresholds/{PLACEHOLDER_ID}"),
("DELETE", f"/v1/metrics/{PLACEHOLDER_ID}/thresholds/{PLACEHOLDER_ID}"),
("GET", "/v1/persons/anon@example.com"),
("GET", "/v1/queries"),
("POST", "/v1/queries"),
("GET", f"/v1/queries/{PLACEHOLDER_ID}"),
("PUT", f"/v1/queries/{PLACEHOLDER_ID}"),
("DELETE", f"/v1/queries/{PLACEHOLDER_ID}"),
("POST", f"/v1/queries/{PLACEHOLDER_ID}/run"),
]


@pytest.mark.parametrize(("method", "path"), OPERATIONS, ids=lambda v: v if isinstance(v, str) else None)
def test_anonymous_call_is_rejected_401(anon_api, method: str, path: str) -> None:
r = anon_api.request(method, path)
assert r.status_code == 401, f"{method} {path}: status={r.status_code} body={r.text}"


def test_garbage_bearer_is_rejected_401(anon_api) -> None:
r = anon_api.get("/v1/metrics", headers={"Authorization": "Bearer not-a-jwt"})
assert r.status_code == 401, f"status={r.status_code} body={r.text}"
12 changes: 7 additions & 5 deletions src/ingestion/tests/e2e/lib/api_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@
# uniform {400,401,403,404,409,429,500} on every route regardless of what the
# handler can answer (spec-fidelity bug #1669). The gate subtracts the codes a
# route provably cannot produce, or it would require statuses the API never
# returns. UNIVERSAL_BOILERPLATE drops from every route: 401 (auth disabled at the
# gateway) and 429 (no rate limiter).
UNIVERSAL_BOILERPLATE = frozenset({401, 429})
# returns. UNIVERSAL_BOILERPLATE drops from every route: 429 (no rate limiter).
# 401 is REAL — the rig runs auth-ENABLED (the gears host's oidc-authn-plugin
# verifies the gateway JWT), so every route answers 401 to an anonymous call
# (api/test_unauthorized.py).
UNIVERSAL_BOILERPLATE = frozenset({429})

# Per-route declared codes the rig cannot observe, subtracted from `required` on
# top of UNIVERSAL_BOILERPLATE — tagged per entry: `.standard_errors` boilerplate
Expand Down Expand Up @@ -76,8 +78,8 @@
"GET /v1/persons/{email}": frozenset({400, 403, 409}),
# 404/409 boilerplate; 403 IS reachable (person outside the caller's visible set)
"POST /v1/metric-results": frozenset({404, 409}),
# saved-query CRUD + run (#1965): 403 (auth disabled, no role gate — cross-tenant
# is 404 by opacity) and 409 (no conflict path) are `.standard_errors` boilerplate.
# saved-query CRUD + run (#1965): 403 (no role gate — cross-tenant is 404 by
# opacity) and 409 (no conflict path) are `.standard_errors` boilerplate.
"GET /v1/queries": frozenset({400, 403, 404, 409}), # boilerplate: list, no input/lookup/conflict
"POST /v1/queries": frozenset({403, 404, 409}), # boilerplate (400 reachable: bad sql)
"GET /v1/queries/{id}": frozenset({403, 409}), # boilerplate
Expand Down
Loading