Files
forge/apps/api/tests/test_auth.py
T
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

137 lines
5.9 KiB
Python

"""Auth, RBAC, and team-management tests (in-process ASGI)."""
from __future__ import annotations
import uuid
import httpx
from forge.config import settings
from forge.main import create_app
def _client() -> httpx.AsyncClient:
app = create_app()
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test")
def _email() -> str:
return f"u{uuid.uuid4().hex[:10]}@example.com"
async def test_register_login_me_flow():
async with _client() as c:
email = _email()
r = await c.post("/v1/auth/register", json={"email": email, "password": "supersecret1"})
assert r.status_code == 201, r.text
body = r.json()
assert body["user"]["role"] == "owner"
token = body["access_token"]
# The same email may own another workspace. If both accounts use the same
# credentials, login requires the workspace id to disambiguate them.
second = await c.post(
"/v1/auth/register",
json={"email": email, "password": "supersecret1", "workspace_name": "Second"},
)
assert second.status_code == 201
assert second.json()["user"]["tenant_id"] != body["user"]["tenant_id"]
# wrong password
assert (await c.post("/v1/auth/login", json={"email": email, "password": "nope"})).status_code == 401
# correct password
assert (
await c.post("/v1/auth/login", json={"email": email, "password": "supersecret1"})
).status_code == 401
r = await c.post(
"/v1/auth/login",
json={
"email": email,
"password": "supersecret1",
"workspace_id": body["user"]["tenant_id"],
},
)
assert r.status_code == 200
# me with token
r = await c.get("/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200 and r.json()["email"] == email
async def test_auth_required_blocks_anonymous(monkeypatch):
monkeypatch.setattr(settings, "auth_required", True)
async with _client() as c:
assert (await c.get("/v1/auth/me")).status_code == 401
async def test_rbac_viewer_cannot_manage_team():
async with _client() as c:
owner_email, viewer_email = _email(), _email()
owner = (await c.post("/v1/auth/register", json={"email": owner_email, "password": "ownerpass1"})).json()
oh = {"Authorization": f"Bearer {owner['access_token']}"}
# owner invites a viewer with a password so they can log in
r = await c.post("/v1/team/members", json={"email": viewer_email, "role": "viewer", "password": "viewerpass1"}, headers=oh)
assert r.status_code == 201, r.text
viewer = (await c.post("/v1/auth/login", json={"email": viewer_email, "password": "viewerpass1"})).json()
vh = {"Authorization": f"Bearer {viewer['access_token']}"}
# viewer is forbidden from listing/managing the team
assert (await c.get("/v1/team/members", headers=vh)).status_code == 403
# owner can
r = await c.get("/v1/team/members", headers=oh)
assert r.status_code == 200 and len(r.json()) == 2
async def test_invite_without_password_emails_link_and_can_be_accepted():
async with _client() as c:
owner_email, invitee_email = _email(), _email()
owner = (await c.post("/v1/auth/register", json={"email": owner_email, "password": "ownerpass1"})).json()
oh = {"Authorization": f"Bearer {owner['access_token']}"}
# invite with no password => a pending 'invited' user. SMTP is unconfigured in tests,
# so the API hands back a redeemable link instead of emailing it.
r = await c.post("/v1/team/members", json={"email": invitee_email, "role": "editor"}, headers=oh)
assert r.status_code == 201, r.text
body = r.json()
assert body["status"] == "invited" and body["email_sent"] is False
assert "invite=" in body.get("invite_url", "")
token = body["invite_url"].split("invite=", 1)[1]
# can't log in yet - no password set
assert (await c.post("/v1/auth/login", json={"email": invitee_email, "password": "whatever1"})).status_code == 401
# invite-info reflects the pending invite
info = await c.get(f"/v1/auth/invite-info?token={token}")
assert info.status_code == 200 and info.json()["email"] == invitee_email
# redeeming sets the invitee's own password and logs them in
acc = await c.post("/v1/auth/accept-invite", json={"token": token, "password": "myownpass1"})
assert acc.status_code == 200, acc.text
assert acc.json()["user"]["email"] == invitee_email
assert (await c.post("/v1/auth/login", json={"email": invitee_email, "password": "myownpass1"})).status_code == 200
# a used invite can't be redeemed again
assert (await c.post("/v1/auth/accept-invite", json={"token": token, "password": "another123"})).status_code == 400
async def test_refresh_issues_new_access_token():
async with _client() as c:
email = _email()
body = (await c.post("/v1/auth/register", json={"email": email, "password": "supersecret1"})).json()
r = await c.post("/v1/auth/refresh", json={"refresh_token": body["refresh_token"]})
assert r.status_code == 200 and "access_token" in r.json()
# an access token is not accepted as a refresh token
assert (await c.post("/v1/auth/refresh", json={"refresh_token": body["access_token"]})).status_code == 401
async def test_cannot_demote_only_owner():
async with _client() as c:
email = _email()
body = (await c.post("/v1/auth/register", json={"email": email, "password": "ownerpass1"})).json()
oh = {"Authorization": f"Bearer {body['access_token']}"}
uid = body["user"]["id"]
r = await c.patch(f"/v1/team/members/{uid}", json={"role": "viewer"}, headers=oh)
assert r.status_code == 400 and "owner" in r.json()["detail"].lower()