- client_api.py: ClientKeyStore (SHA-256 hashed at rest, scoped, revocable), checkout with per-client rate limit, /api/me status, /api/me/issues submit, signature-authenticated /api/webhooks/stripe reusing the issue-#3 seam, and operator key-issuance routes; compound assembly guard replaces the billing operator before_request with identical operator-route semantics so scoped client endpoints are reachable and valid client keys get 403 on operator routes (cross-tenant + operator denials). - auth.py: PUBLIC_PATHS + /api/webhooks/stripe; FORBIDDEN_BODY/ forbidden_response; hash_client_key (no new compare_digest call site). - schema.sql: client_api_keys table (hash + prefix only). - app.py: mounts the client surface (stripe_builder/gitea injectable). - tests/test_client_api.py (18 tests) + assembled-app coverage in test_app.py. Full suite: 293 passed, 1 skipped.
552 lines
22 KiB
Python
552 lines
22 KiB
Python
"""Hermetic tests for the client-facing surface (issue #41).
|
|
|
|
Per-client API keys (hashed at rest, scoped, revocable), self-serve
|
|
checkout (rate-limited), the Stripe webhook credit path (idempotent via
|
|
the issue-#3 seam), ``GET /api/me`` client status, self-serve work
|
|
submission, and the cross-tenant / operator-route denial guarantees.
|
|
|
|
Zero network: Flask test client + temp SQLite DB + MockStripeClient +
|
|
FakeGitea. Run with:
|
|
python -m pytest tests/test_client_api.py -q
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from gitea_saas.auth import (API_KEY_HEADER, FORBIDDEN_BODY, # noqa: E402
|
|
UNAUTHORIZED_BODY, hash_client_key)
|
|
from gitea_saas.billing_core import BillingCore # noqa: E402
|
|
from gitea_saas.client_api import (ClientKeyStore, ClientStatus, # noqa: E402
|
|
ClientSurface, register_client_routes)
|
|
from gitea_saas.config import ServerConfig # noqa: E402
|
|
from gitea_saas.stripe_client import MockStripeClient # noqa: E402
|
|
|
|
API_KEY = "operator-test-key-41"
|
|
AUTH = {API_KEY_HEADER: API_KEY}
|
|
|
|
# Explicit mock signature: tests only, never a production default.
|
|
MOCK_SIG = "test-webhook-sig-41"
|
|
|
|
|
|
class FakeGitea:
|
|
"""In-memory create_org_repo/create_issue seam (factory contract)."""
|
|
|
|
def __init__(self):
|
|
self.repos = set()
|
|
self.issues = []
|
|
|
|
def create_org_repo(self, name):
|
|
self.repos.add(name)
|
|
|
|
def create_issue(self, repo, title, body, labels=None):
|
|
number = len(self.issues) + 1
|
|
self.issues.append({"repo": repo, "title": title, "body": body,
|
|
"labels": labels, "number": number})
|
|
return number
|
|
|
|
|
|
def _mock_stripe_builder():
|
|
return MockStripeClient(webhook_signature=MOCK_SIG)
|
|
|
|
|
|
def _cfg(tmp_path: Path, api_key: Optional[str] = API_KEY) -> ServerConfig:
|
|
return ServerConfig(api_key=api_key, port=8080, db_path=tmp_path / "saas.db")
|
|
|
|
|
|
def _webhook_payload(event_id="evt_41_1", client_id=1, amount=2500):
|
|
return {"id": event_id, "type": "payment.succeeded",
|
|
"data": {"object": {"metadata": {"client_id": str(client_id)},
|
|
"amount_total": amount}}}
|
|
|
|
|
|
@pytest.fixture()
|
|
def client_factory(tmp_path):
|
|
"""Build ONE assembled app per test (cached); returns a zero-arg builder."""
|
|
state = {}
|
|
|
|
def build():
|
|
if "app" in state:
|
|
return state["app"], state["gitea"]
|
|
from gitea_saas.app import create_app
|
|
gitea = FakeGitea()
|
|
application = create_app(_cfg(tmp_path, API_KEY),
|
|
stripe_builder=_mock_stripe_builder,
|
|
gitea=gitea)
|
|
application.config.update(TESTING=True)
|
|
state["app"] = application
|
|
state["gitea"] = gitea
|
|
return application, gitea
|
|
|
|
build()
|
|
return build
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(client_factory):
|
|
application, _ = client_factory()
|
|
return application.test_client()
|
|
|
|
|
|
@pytest.fixture()
|
|
def gitea(client_factory):
|
|
return client_factory()[1]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# helpers: provision clients + issue keys through the operator API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _create_client(client, name: str) -> int:
|
|
resp = client.post("/api/clients", json={"name": name}, headers=AUTH)
|
|
assert resp.status_code == 201
|
|
return resp.get_json()["id"]
|
|
|
|
|
|
def _issue_key(client, client_id: int) -> dict:
|
|
resp = client.post(f"/api/clients/{client_id}/keys", headers=AUTH)
|
|
assert resp.status_code == 201
|
|
return resp.get_json()
|
|
|
|
|
|
def _client_header(raw_key: str) -> dict:
|
|
return {API_KEY_HEADER: raw_key}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ClientKeyStore unit tests (acceptance criterion 2: hashed at rest)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_keys_stored_hashed_plaintext_never_in_db(tmp_path):
|
|
core = BillingCore(tmp_path / "saas.db")
|
|
ok, rec = core.create_client("HashCo")
|
|
store = ClientKeyStore(tmp_path / "saas.db")
|
|
ok, issued = store.issue(rec["id"])
|
|
assert ok
|
|
raw = issued["key"]
|
|
|
|
# The plaintext key is not anywhere in the DB — not as a value, not as
|
|
# a column. The stored material is hash + display prefix only.
|
|
rows = store._conn.execute(
|
|
"SELECT key_hash, key_prefix FROM client_api_keys").fetchall()
|
|
assert len(rows) == 1
|
|
stored_hash, stored_prefix = rows[0]["key_hash"], rows[0]["key_prefix"]
|
|
assert hash_client_key(raw) == stored_hash
|
|
assert stored_hash != raw
|
|
assert stored_prefix != raw # prefix is a truncated display hint
|
|
assert raw not in stored_hash
|
|
# Dump the whole DB as text: plaintext must not appear anywhere.
|
|
blob = " ".join(str(v) for row in store._conn.execute(
|
|
"SELECT * FROM client_api_keys") for v in row)
|
|
assert raw not in blob
|
|
|
|
|
|
def test_resolve_client_id_scoped_and_revoked(tmp_path):
|
|
core = BillingCore(tmp_path / "saas.db")
|
|
_, a = core.create_client("Alice")
|
|
_, b = core.create_client("Bob")
|
|
store = ClientKeyStore(tmp_path / "saas.db")
|
|
_, ka = store.issue(a["id"])
|
|
_, kb = store.issue(b["id"])
|
|
|
|
assert store.resolve_client_id(ka["key"]) == a["id"]
|
|
assert store.resolve_client_id(kb["key"]) == b["id"]
|
|
assert store.resolve_client_id("saas_wrong") is None
|
|
assert store.resolve_client_id("") is None
|
|
assert store.resolve_client_id(None) is None
|
|
|
|
# Revoke A's key only; B's keeps resolving.
|
|
ok, _ = store.revoke(a["id"], ka["key_id"])
|
|
assert ok
|
|
assert store.resolve_client_id(ka["key"]) is None
|
|
assert store.resolve_client_id(kb["key"]) == b["id"]
|
|
|
|
|
|
def test_revoke_is_scoped_no_cross_tenant_revoke(tmp_path):
|
|
core = BillingCore(tmp_path / "saas.db")
|
|
_, a = core.create_client("Alice")
|
|
_, b = core.create_client("Bob")
|
|
store = ClientKeyStore(tmp_path / "saas.db")
|
|
_, ka = store.issue(a["id"])
|
|
|
|
ok, details = store.revoke(b["id"], ka["key_id"])
|
|
assert not ok and (details.get("error") or {}).get("code") == "KEY_NOT_FOUND"
|
|
assert store.resolve_client_id(ka["key"]) == a["id"] # still active
|
|
|
|
ok, details = store.revoke(a["id"], 999999)
|
|
assert not ok and (details.get("error") or {}).get("code") == "KEY_NOT_FOUND"
|
|
|
|
|
|
def test_list_and_revoke_all_never_expose_secrets(tmp_path):
|
|
core = BillingCore(tmp_path / "saas.db")
|
|
_, a = core.create_client("Alice")
|
|
store = ClientKeyStore(tmp_path / "saas.db")
|
|
_, k1 = store.issue(a["id"])
|
|
_, k2 = store.issue(a["id"])
|
|
ok, listing = store.list_keys(a["id"])
|
|
assert ok and listing["count"] == 2
|
|
for item in listing["keys"]:
|
|
assert "key" not in item and "key_hash" not in item
|
|
ok, res = store.revoke_all(a["id"])
|
|
assert ok and res["revoked_count"] == 2
|
|
assert store.resolve_client_id(k1["key"]) is None
|
|
assert store.resolve_client_id(k2["key"]) is None
|
|
|
|
|
|
def test_issue_unknown_client(tmp_path):
|
|
BillingCore(tmp_path / "saas.db")
|
|
store = ClientKeyStore(tmp_path / "saas.db")
|
|
ok, details = store.issue(999)
|
|
assert not ok and (details.get("error") or {}).get("code") == "UNKNOWN_CLIENT"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Operator key management HTTP routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_operator_issues_and_revokes_keys(client):
|
|
cid = _create_client(client, "KeyCo")
|
|
issued = _issue_key(client, cid)
|
|
assert issued["client_id"] == cid
|
|
assert issued["key"].startswith("saas_")
|
|
assert issued["prefix"] == issued["key"][:10]
|
|
assert len(issued["key"]) > 20
|
|
|
|
# Listing shows metadata only.
|
|
listing = client.get(f"/api/clients/{cid}/keys", headers=AUTH)
|
|
assert listing.status_code == 200
|
|
assert listing.get_json()["count"] == 1
|
|
assert "key" not in listing.get_json()["keys"][0]
|
|
|
|
# The key works on the client surface.
|
|
me = client.get("/api/me", headers=_client_header(issued["key"]))
|
|
assert me.status_code == 200
|
|
assert me.get_json()["client_id"] == cid
|
|
|
|
# Revoke via the operator route: key stops working.
|
|
rev = client.delete(f"/api/clients/{cid}/keys/{issued['key_id']}",
|
|
headers=AUTH)
|
|
assert rev.status_code == 200
|
|
assert client.get("/api/me",
|
|
headers=_client_header(issued["key"])).status_code == 401
|
|
|
|
|
|
def test_key_routes_require_operator_key(client):
|
|
cid = _create_client(client, "LockedCo")
|
|
assert client.post(f"/api/clients/{cid}/keys").status_code == 401
|
|
assert client.get(f"/api/clients/{cid}/keys").status_code == 401
|
|
# A CLIENT key (other tenant) is 403 on key issuance routes.
|
|
key = _issue_key(client, cid)
|
|
other = _create_client(client, "OtherCo")
|
|
resp = client.post(f"/api/clients/{other}/keys",
|
|
headers=_client_header(key["key"]))
|
|
assert resp.status_code == 403
|
|
assert resp.get_json() == FORBIDDEN_BODY
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tenancy: a client key never reaches another client's resources, and never
|
|
# reaches an operator route (acceptance criterion 1 + security note)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_cross_tenant_and_operator_denials(client):
|
|
a_id = _create_client(client, "TenantA")
|
|
b_id = _create_client(client, "TenantB")
|
|
key_a = _issue_key(client, a_id)["key"]
|
|
|
|
# Client A's key on client B's resources: 403 everywhere.
|
|
for path in (
|
|
f"/api/factory/{b_id}/issues",
|
|
f"/api/factory/{b_id}/lock",
|
|
f"/api/factory/{b_id}/unlock",
|
|
f"/api/clients/{b_id}",
|
|
f"/api/clients/{b_id}/ledger",
|
|
f"/api/clients/{b_id}/credits",
|
|
):
|
|
resp = client.post(path, json={}, headers=_client_header(key_a))
|
|
assert resp.status_code == 403, path
|
|
assert resp.get_json() == FORBIDDEN_BODY
|
|
|
|
# Operator routes: /api/clients, /api/factory/*, /dashboard.
|
|
assert client.get("/api/clients", headers=_client_header(key_a)) \
|
|
.status_code == 403
|
|
assert client.post("/api/factory/provision",
|
|
json={"client_id": b_id},
|
|
headers=_client_header(key_a)).status_code == 403
|
|
assert client.get("/dashboard", headers=_client_header(key_a)) \
|
|
.status_code == 403
|
|
assert client.get("/api/dashboard/summary", headers=_client_header(key_a)) \
|
|
.status_code == 403
|
|
|
|
# And the operator key never works on the client surface.
|
|
assert client.get("/api/me", headers=AUTH).status_code == 401
|
|
assert client.get("/api/me").get_json() == UNAUTHORIZED_BODY
|
|
assert client.post("/api/checkout", json={"amount_minor": 100},
|
|
headers=AUTH).status_code == 401
|
|
|
|
|
|
def test_each_client_sees_only_its_own_me(client):
|
|
a_id = _create_client(client, "SelfScopeA")
|
|
b_id = _create_client(client, "SelfScopeB")
|
|
key_a = _issue_key(client, a_id)["key"]
|
|
key_b = _issue_key(client, b_id)["key"]
|
|
|
|
me_a = client.get("/api/me", headers=_client_header(key_a)).get_json()
|
|
me_b = client.get("/api/me", headers=_client_header(key_b)).get_json()
|
|
assert me_a["client_id"] == a_id
|
|
assert me_b["client_id"] == b_id
|
|
assert me_a["name"] == "SelfScopeA"
|
|
assert me_b["name"] == "SelfScopeB"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Checkout (rate-limited) + webhook credit (idempotent) — acceptance 3
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_checkout_creates_session_scoped_to_caller(client, tmp_path):
|
|
cid = _create_client(client, "CheckoutCo")
|
|
key = _issue_key(client, cid)["key"]
|
|
resp = client.post("/api/checkout",
|
|
json={"amount_minor": 2500,
|
|
"success_url": "https://example.test/ok"},
|
|
headers=_client_header(key))
|
|
assert resp.status_code == 201
|
|
body = resp.get_json()
|
|
assert set(body) == {"checkout_url", "session_id"}
|
|
assert body["checkout_url"].startswith("https://checkout.stripe.com/mock/")
|
|
assert body["session_id"].startswith("cs_mock_")
|
|
|
|
|
|
def test_checkout_webhook_credit_exactly_once(client, tmp_path):
|
|
"""Acceptance 3: checkout -> webhook -> balance credit, replay no-op."""
|
|
cid = _create_client(client, "FundCo")
|
|
key = _issue_key(client, cid)["key"]
|
|
|
|
checkout = client.post("/api/checkout", json={"amount_minor": 2500},
|
|
headers=_client_header(key))
|
|
assert checkout.status_code == 201
|
|
|
|
payload = _webhook_payload(client_id=cid, amount=2500)
|
|
headers = {"Stripe-Signature": MOCK_SIG}
|
|
first = client.post("/api/webhooks/stripe",
|
|
data=json.dumps(payload), # raw bytes path
|
|
content_type="application/json", headers=headers)
|
|
assert first.status_code == 200
|
|
assert first.get_json()["applied"] is True
|
|
|
|
balance = client.get("/api/me", headers=_client_header(key)).get_json()
|
|
assert balance["balance_minor"] == 2500
|
|
|
|
# Replayed webhook (same event id): exactly once — no double credit.
|
|
replay = client.post("/api/webhooks/stripe",
|
|
data=json.dumps(payload),
|
|
content_type="application/json", headers=headers)
|
|
assert replay.status_code == 200
|
|
balance_after = client.get("/api/me",
|
|
headers=_client_header(key)).get_json()
|
|
assert balance_after["balance_minor"] == 2500
|
|
|
|
|
|
def test_webhook_rejects_bad_signature_and_bad_payload(client):
|
|
cid = _create_client(client, "WebhookCo")
|
|
payload = json.dumps(_webhook_payload(client_id=cid)).encode()
|
|
resp = client.post("/api/webhooks/stripe", data=payload,
|
|
content_type="application/json",
|
|
headers={"Stripe-Signature": "wrong-sig"})
|
|
assert resp.status_code == 400
|
|
assert resp.get_json()["error"]["code"] == "invalid_signature"
|
|
|
|
# No signature header at all: same generic 400.
|
|
resp = client.post("/api/webhooks/stripe", data=payload,
|
|
content_type="application/json")
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_checkout_validation(client):
|
|
cid = _create_client(client, "BadCo")
|
|
key = _issue_key(client, cid)["key"]
|
|
h = _client_header(key)
|
|
|
|
assert client.post("/api/checkout", json={"amount_minor": 0},
|
|
headers=h).status_code == 400
|
|
assert client.post("/api/checkout", json={"amount_minor": "50"},
|
|
headers=h).status_code == 400
|
|
assert client.post("/api/checkout", json={"amount_minor": 100,
|
|
"currency": "too-long"},
|
|
headers=h).status_code == 400
|
|
assert client.post("/api/checkout",
|
|
json={"amount_minor": 100,
|
|
"success_url": "javascript:alert(1)"},
|
|
headers=h).status_code == 400
|
|
assert client.post("/api/checkout", json="nope",
|
|
headers=h).status_code == 400
|
|
|
|
|
|
def test_checkout_rate_limited_per_client(tmp_path):
|
|
"""Rate-limit /api/checkout per client (security note)."""
|
|
db = tmp_path / "saas.db"
|
|
core = BillingCore(db)
|
|
ok, rec = core.create_client("RatedCo")
|
|
store = ClientKeyStore(db)
|
|
ok, issued = store.issue(rec["id"])
|
|
|
|
import flask
|
|
from gitea_saas.client_api import ClientStatus
|
|
app = flask.Flask(__name__)
|
|
app.config["API_KEY"] = API_KEY
|
|
surface = ClientSurface(core, store, ClientStatus(core, db),
|
|
_mock_stripe_builder,
|
|
checkout_rate=2, checkout_window_s=60)
|
|
register_client_routes(app, surface,
|
|
gitea=FakeGitea(),
|
|
config=type("C", (), {"db_path": db})())
|
|
app.config.update(TESTING=True)
|
|
c = app.test_client()
|
|
h = _client_header(issued["key"])
|
|
|
|
assert c.post("/api/checkout", json={"amount_minor": 100},
|
|
headers=h).status_code == 201
|
|
assert c.post("/api/checkout", json={"amount_minor": 100},
|
|
headers=h).status_code == 201
|
|
third = c.post("/api/checkout", json={"amount_minor": 100}, headers=h)
|
|
assert third.status_code == 429
|
|
assert third.get_json()["error"]["code"] == "rate_limited"
|
|
assert third.headers.get("Retry-After") is not None
|
|
|
|
# Other client's limiter is its own (no cross-tenant throttling).
|
|
ok, rec2 = core.create_client("OtherRated")
|
|
ok, issued2 = store.issue(rec2["id"])
|
|
assert c.post("/api/checkout", json={"amount_minor": 100},
|
|
headers=_client_header(issued2["key"])).status_code == 201
|
|
|
|
|
|
def test_stripe_unconfigured_fails_closed(tmp_path, monkeypatch):
|
|
"""No Stripe mode/creds -> checkout & webhook 503, never a 200."""
|
|
monkeypatch.delenv("SAAS_STRIPE_MODE", raising=False)
|
|
monkeypatch.delenv("STRIPE_SECRET_KEY", raising=False)
|
|
monkeypatch.delenv("STRIPE_WEBHOOK_SECRET", raising=False)
|
|
|
|
from gitea_saas.app import create_app
|
|
application = create_app(_cfg(tmp_path, API_KEY), gitea=FakeGitea())
|
|
application.config.update(TESTING=True)
|
|
c = application.test_client()
|
|
cid = c.post("/api/clients", json={"name": "NoStripe"},
|
|
headers=AUTH).get_json()["id"]
|
|
key = c.post(f"/api/clients/{cid}/keys", headers=AUTH).get_json()["key"]
|
|
|
|
checkout = c.post("/api/checkout", json={"amount_minor": 100},
|
|
headers=_client_header(key))
|
|
assert checkout.status_code == 503
|
|
assert checkout.get_json()["error"]["code"] == "stripe_unconfigured"
|
|
|
|
webhook = c.post("/api/webhooks/stripe",
|
|
data=json.dumps(_webhook_payload()).encode(),
|
|
content_type="application/json",
|
|
headers={"Stripe-Signature": "anything"})
|
|
assert webhook.status_code == 503
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# /api/me status (balance, queued, in-flight, delivered)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_me_reports_balance_and_counts(client, gitea, tmp_path):
|
|
cid = _create_client(client, "StatusCo")
|
|
key = _issue_key(client, cid)["key"]
|
|
|
|
# Unfunded submit -> queued (no Gitea issue created).
|
|
resp = client.post("/api/me/issues",
|
|
json={"repo": "statusco-repo",
|
|
"title": "First task",
|
|
"acceptance_body": "do it"},
|
|
headers=_client_header(key))
|
|
assert resp.status_code == 202
|
|
assert resp.get_json()["delivered"] is False
|
|
me = client.get("/api/me", headers=_client_header(key)).get_json()
|
|
assert me["queued_items"] == 1
|
|
assert me["in_flight_items"] == 0
|
|
assert me["delivered_count"] == 0
|
|
assert me["balance_minor"] == 0
|
|
|
|
# Fund + lock: submits now queue; unlock flushes them to Gitea, which
|
|
# is what the factory records as "shipped" (in-flight, awaiting merge).
|
|
core = BillingCore(tmp_path / "saas.db")
|
|
ok, _ = core.credit(cid, 10000, "fund-1")
|
|
assert ok
|
|
assert client.post(f"/api/factory/{cid}/lock", json={},
|
|
headers=AUTH).status_code == 200
|
|
resp = client.post("/api/me/issues",
|
|
json={"repo": "statusco-repo",
|
|
"title": "Second task",
|
|
"acceptance_body": "do it too"},
|
|
headers=_client_header(key))
|
|
assert resp.status_code == 202
|
|
assert resp.get_json()["reason"] == "locked"
|
|
me = client.get("/api/me", headers=_client_header(key)).get_json()
|
|
assert me["queued_items"] == 2 # unfunded + locked
|
|
assert me["in_flight_items"] == 0
|
|
|
|
flush = client.post(f"/api/factory/{cid}/unlock", json={}, headers=AUTH)
|
|
assert flush.status_code == 200
|
|
assert flush.get_json()["flushed"] == 2
|
|
assert len(gitea.issues) == 2
|
|
me = client.get("/api/me", headers=_client_header(key)).get_json()
|
|
assert me["queued_items"] == 0
|
|
assert me["in_flight_items"] == 2 # shipped to Gitea, not delivered
|
|
assert me["balance_minor"] == 10000
|
|
|
|
# Delivery debit (what delivery.py writes on merge) -> delivered count.
|
|
ok, _ = core.debit(cid, 500, "deliver:statusco-repo:1:sha",
|
|
ref_type="pr", ref_id="statusco-repo#1")
|
|
assert ok
|
|
me = client.get("/api/me", headers=_client_header(key)).get_json()
|
|
assert me["delivered_count"] == 1
|
|
assert me["balance_minor"] == 9500
|
|
|
|
|
|
def test_me_issues_validation_and_scoping(client, tmp_path):
|
|
cid = _create_client(client, "SubmitCo")
|
|
other = _create_client(client, "OtherSubmit")
|
|
key = _issue_key(client, cid)["key"]
|
|
other_key = _issue_key(client, other)["key"]
|
|
h = _client_header(key)
|
|
|
|
assert client.post("/api/me/issues", json={"repo": "../evil",
|
|
"title": "x"},
|
|
headers=h).status_code == 400
|
|
assert client.post("/api/me/issues", json={"repo": "good-repo",
|
|
"title": ""},
|
|
headers=h).status_code == 400
|
|
|
|
# The submit is ALWAYS scoped to the caller — a queued row can only
|
|
# ever belong to the key's own client_id (factory enforces existence).
|
|
resp = client.post("/api/me/issues",
|
|
json={"repo": "submitco-repo", "title": "Task"},
|
|
headers=_client_header(other_key))
|
|
assert resp.status_code == 202
|
|
me_other = client.get("/api/me", headers=_client_header(other_key)).get_json()
|
|
assert me_other["queued_items"] == 1
|
|
me_self = client.get("/api/me", headers=h).get_json()
|
|
assert me_self["queued_items"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Assembled-app reachability + fail-closed 503 (no SAAS_API_KEY)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_assembled_app_has_client_routes(client_factory, tmp_path, monkeypatch):
|
|
monkeypatch.delenv("SAAS_API_KEY", raising=False)
|
|
from gitea_saas.app import create_app
|
|
application = create_app(_cfg(tmp_path, api_key=None), gitea=FakeGitea())
|
|
application.config.update(TESTING=True)
|
|
c = application.test_client()
|
|
# No SAAS_API_KEY: every authed route (client surface included) is 503.
|
|
assert c.get("/api/me").status_code == 503
|
|
assert c.post("/api/checkout", json={"amount_minor": 100}).status_code == 503
|
|
assert c.post("/api/me/issues", json={}).status_code == 503
|