Admin-Plattform für die Tenants von Onyscribe: Editor mit Live-Vorschau (echter Core in Sandbox), Versionen, KI-Agent (Cortecs), OIDC-Abgleich über die Login-Developer-API, Deploy-Engine (Mittwald, gesperrt ohne DEPLOY_LIVE, Freigabe pro Tenant), Core-Vertrag (Schema/Manifest), Core-Releases mit Kompatibilitätsprüfung, Canary-Rollout und Rollback, Zustandsabgleich. Runbook: docs/RUNBOOK_PARALLELTEST.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
60 lines
2.8 KiB
Python
60 lines
2.8 KiB
Python
"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md) für Tests und lokale Entwicklung."""
|
|
|
|
|
|
class FakeLogin:
|
|
"""In-Memory-Nachbau von login.onyva.dev /api/v1 (Verhalten laut LoginSeite/docs/developer-api.md)."""
|
|
|
|
def __init__(self, users=("anna@x.de", "bob@x.de")):
|
|
self.apps, self.members, self.users, self.calls, self.n, self.rate_limit_once = {}, {}, set(users), [], 0, False
|
|
|
|
def __call__(self, method, url, headers, body):
|
|
path = url.split("/api/v1")[1]
|
|
self.calls.append((method, path, body))
|
|
if headers.get("Authorization") != "Bearer onyva_testkey":
|
|
return 401, {"detail": "invalid"}, {}
|
|
if self.rate_limit_once:
|
|
self.rate_limit_once = False
|
|
return 429, {"detail": "slow down"}, {"Retry-After": "2"}
|
|
parts = path.strip("/").split("/")
|
|
if parts == ["apps"]:
|
|
if method == "GET":
|
|
return 200, {"apps": list(self.apps.values())}, {}
|
|
self.n += 1
|
|
cid = f"app_{self.n:04d}"
|
|
app = {"client_id": cid, "name": body["name"], "slug": body["name"].lower(), "redirect_uris": body["redirect_uris"],
|
|
"auth_mode": body.get("auth_mode", "required"), "totp_mode": body.get("totp_mode", "disabled"),
|
|
"allow_registration": body.get("allow_registration", False), "backchannel_logout_uri": body.get("backchannel_logout_uri")}
|
|
self.apps[cid], self.members[cid] = app, {}
|
|
return 201, {**app, "client_secret": f"secret-{self.n}"}, {}
|
|
cid = parts[1]
|
|
if cid not in self.apps:
|
|
return 404, {"detail": "not found"}, {}
|
|
if len(parts) == 2:
|
|
if method == "GET":
|
|
return 200, self.apps[cid], {}
|
|
if method == "PATCH":
|
|
self.apps[cid].update(body)
|
|
return 200, self.apps[cid], {}
|
|
if method == "DELETE":
|
|
del self.apps[cid]
|
|
return 204, None, {}
|
|
if parts[2] == "rotate-secret":
|
|
self.n += 1
|
|
return 200, {"client_id": cid, "client_secret": f"rotated-{self.n}"}, {}
|
|
if parts[2] == "memberships":
|
|
if method == "GET":
|
|
return 200, {"memberships": [{"email": e, "display_name": e, "role": r} for e, r in self.members[cid].items()]}, {}
|
|
if method == "PUT":
|
|
if body["user_email"] not in self.users:
|
|
return 404, {"detail": "user"}, {}
|
|
self.members[cid][body["user_email"]] = body.get("role", "viewer")
|
|
return 200, body, {}
|
|
if method == "DELETE":
|
|
self.members[cid].pop(parts[3], None)
|
|
return 204, None, {}
|
|
return 404, {}, {}
|
|
|
|
@property
|
|
def writes(self):
|
|
return [c for c in self.calls if c[0] != "GET"]
|