from __future__ import annotations from pathlib import Path import yaml from fastapi.testclient import TestClient def _write_config(tmp_path: Path) -> Path: cfg = { "ray": { "address": "http://127.0.0.1:8265", "shared_root": "/private", "entrypoint_resources": {"worker_node": 1}, "runtime_env": {"env_vars": {}}, }, "data": { # Avoid touching real /private in tests. Keep ray.shared_root as /private # so existing path validation tests remain unchanged. "user_root": str(tmp_path / "users"), }, "service": { "api": {"host": "127.0.0.1", "port": 0}, "auth": {"token_env": "MVP_INTERNAL_TOKEN"}, "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, "scheduler": {"tick_s": 1, "retry_interval_s": 1, "max_running_tasks": 1}, }, } p = tmp_path / "cfg.yaml" p.write_text(yaml.safe_dump(cfg), encoding="utf-8") return p def test_db_user_token_lifecycle(tmp_path: Path): from argus.service.db import Db db = Db(str(tmp_path / "mvp.sqlite3")) db.init() db.create_user(user_id="alice", display_name="Alice") tok = db.issue_token(user_id="alice") info = db.resolve_token(tok) assert info and info["user_id"] == "alice" db.disable_user(user_id="alice") info2 = db.resolve_token(tok) assert info2 and info2["state"] == "DISABLED" def test_admin_create_user_issue_token_and_disabled_rejected(tmp_path: Path, monkeypatch): from argus.service import app as app_mod cfg_path = _write_config(tmp_path) monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") class _Scheduler: def __init__(self, **kwargs): self.tool = object() def run_forever(self, stop_flag): return None monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) app = app_mod.create_app(str(cfg_path)) admin_headers = {"authorization": "Bearer adm1"} with TestClient(app) as c: # list users requires admin assert c.get("/api/v2/users", headers={"authorization": "Bearer nope"}).status_code in (401, 403) r1 = c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice", "display_name": "Alice"}) assert r1.status_code == 200 assert r1.json()["user_id"] == "alice" r2 = c.post("/api/v2/users/alice/tokens", headers=admin_headers) assert r2.status_code == 200 token = r2.json()["token"] assert token r2b = c.get("/api/v2/users", headers=admin_headers) assert r2b.status_code == 200 users = r2b.json()["users"] assert any(u.get("user_id") == "alice" for u in users) # non-admin token can access regular endpoints r3 = c.get("/api/v2/queue", headers={"authorization": f"Bearer {token}"}) assert r3.status_code == 200 r4 = c.post("/api/v2/users/alice:disable", headers=admin_headers) assert r4.status_code == 200 r5 = c.get("/api/v2/queue", headers={"authorization": f"Bearer {token}"}) assert r5.status_code == 403 def test_tasks_are_isolated_by_user(tmp_path: Path, monkeypatch): from argus.service import app as app_mod cfg_path = _write_config(tmp_path) monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") # Deterministic task ids counter = {"n": 0} def _new_id(workload: str, **kwargs) -> str: counter["n"] += 1 return f"{workload}_t{counter['n']}" monkeypatch.setattr(app_mod, "new_task_id", _new_id) class _Scheduler: def __init__(self, **kwargs): self.tool = object() def run_forever(self, stop_flag): return None monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) app = app_mod.create_app(str(cfg_path)) admin_headers = {"authorization": "Bearer adm1"} with TestClient(app) as c: # Create users and tokens assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "bob"}).status_code == 200 alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"] bob_tok = c.post("/api/v2/users/bob/tokens", headers=admin_headers).json()["token"] alice_headers = {"authorization": f"Bearer {alice_tok}"} bob_headers = {"authorization": f"Bearer {bob_tok}"} # Each user submits one task. r1 = c.post( "/api/v2/tasks", headers=alice_headers, data="workload: ppo\ncode_path: /private/common/code/verl\nmodel_id: m\ntrain_file: /private/common/datasets/t\nval_file: /private/common/datasets/v\n", ) assert r1.status_code == 200 alice_tid = r1.json()["task_id"] r2 = c.post( "/api/v2/tasks", headers=bob_headers, data="workload: ppo\ncode_path: /private/common/code/verl\nmodel_id: m\ntrain_file: /private/common/datasets/t\nval_file: /private/common/datasets/v\n", ) assert r2.status_code == 200 bob_tid = r2.json()["task_id"] # Queue is scoped to user. qa = c.get("/api/v2/queue", headers=alice_headers).json() qb = c.get("/api/v2/queue", headers=bob_headers).json() assert {t["task_id"] for t in qa["pending"]} == {alice_tid} assert {t["task_id"] for t in qb["pending"]} == {bob_tid} # Cross-user access returns 404 (no existence leak). assert c.get(f"/api/v2/tasks/{bob_tid}", headers=alice_headers).status_code == 404 assert c.post(f"/api/v2/tasks/{bob_tid}:cancel", headers=alice_headers).status_code == 404 assert c.get(f"/api/v2/tasks/{bob_tid}/attempts", headers=alice_headers).status_code == 404 # Admin can see global queue. qadm = c.get("/api/v2/queue", headers=admin_headers).json() assert {t["task_id"] for t in qadm["pending"]} == {alice_tid, bob_tid} def test_submit_rejects_non_common_inputs(tmp_path: Path, monkeypatch): from argus.service import app as app_mod cfg_path = _write_config(tmp_path) monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") class _Scheduler: def __init__(self, **kwargs): self.tool = object() def run_forever(self, stop_flag): return None monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) app = app_mod.create_app(str(cfg_path)) admin_headers = {"authorization": "Bearer adm1"} with TestClient(app) as c: assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"] alice_headers = {"authorization": f"Bearer {alice_tok}"} r = c.post( "/api/v2/tasks", headers=alice_headers, data="workload: ppo\ncode_path: /c\nmodel_id: m\ntrain_file: /private/common/datasets/t\nval_file: /private/common/datasets/v\n", ) assert r.status_code == 400 assert "code_path must start with /private/common/" in r.text def test_submit_accepts_user_dataset_paths_and_local_model_paths(tmp_path: Path, monkeypatch): from argus.service import app as app_mod cfg_path = _write_config(tmp_path) monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") class _Scheduler: def __init__(self, **kwargs): self.tool = object() def run_forever(self, stop_flag): return None monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) app = app_mod.create_app(str(cfg_path)) admin_headers = {"authorization": "Bearer adm1"} with TestClient(app) as c: assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"] alice_headers = {"authorization": f"Bearer {alice_tok}"} # User dataset paths are allowed. r1 = c.post( "/api/v2/tasks", headers=alice_headers, data=( "workload: ppo\n" "code_path: /private/common/code/verl\n" "model_id: Qwen/Qwen2.5-0.5B-Instruct\n" "train_file: /private/users/alice/datasets/t\n" "val_file: /private/users/alice/datasets/v\n" ), ) assert r1.status_code == 200 # Local model paths under user models/ are allowed (no TaskSpec schema change). r2 = c.post( "/api/v2/tasks", headers=alice_headers, data=( "workload: ppo\n" "code_path: /private/common/code/verl\n" "model_id: /private/users/alice/models/m1\n" "train_file: /private/common/datasets/t\n" "val_file: /private/common/datasets/v\n" ), ) assert r2.status_code == 200 def test_submit_rejects_cross_user_paths_and_bad_local_model_dirs(tmp_path: Path, monkeypatch): from argus.service import app as app_mod cfg_path = _write_config(tmp_path) monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") class _Scheduler: def __init__(self, **kwargs): self.tool = object() def run_forever(self, stop_flag): return None monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) app = app_mod.create_app(str(cfg_path)) admin_headers = {"authorization": "Bearer adm1"} with TestClient(app) as c: assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "bob"}).status_code == 200 alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"] bob_tok = c.post("/api/v2/users/bob/tokens", headers=admin_headers).json()["token"] bob_headers = {"authorization": f"Bearer {bob_tok}"} # Cross-user dataset path should be rejected. r1 = c.post( "/api/v2/tasks", headers=bob_headers, data=( "workload: ppo\n" "code_path: /private/common/code/verl\n" "model_id: Qwen/Qwen2.5-0.5B-Instruct\n" "train_file: /private/users/alice/datasets/t\n" "val_file: /private/users/alice/datasets/v\n" ), ) assert r1.status_code == 400 assert "/private/users/bob/datasets/" in r1.text # Local model path must be under models/. r2 = c.post( "/api/v2/tasks", headers=bob_headers, data=( "workload: ppo\n" "code_path: /private/common/code/verl\n" "model_id: /private/users/bob/jobs/j1/checkpoints\n" "train_file: /private/common/datasets/t\n" "val_file: /private/common/datasets/v\n" ), ) assert r2.status_code == 400 assert "model_id local path must start with" in r2.text def test_me_returns_paths_and_retention(tmp_path: Path, monkeypatch): from argus.service import app as app_mod cfg_path = _write_config(tmp_path) monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") class _Scheduler: def __init__(self, **kwargs): self.tool = object() def run_forever(self, stop_flag): return None monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) app = app_mod.create_app(str(cfg_path)) admin_headers = {"authorization": "Bearer adm1"} with TestClient(app) as c: assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 alice_tok = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"] r = c.get("/api/v2/me", headers={"authorization": f"Bearer {alice_tok}"}) assert r.status_code == 200 obj = r.json() assert obj["user_id"] == "alice" assert obj["paths"]["home"].endswith("/users/alice") assert obj["paths"]["jobs"].endswith("/users/alice/jobs") assert obj["paths"]["trash_jobs"].endswith("/users/alice/trash/jobs") assert obj["retention"]["jobs_trash_after_days"] == 3 assert obj["retention"]["jobs_purge_after_days"] == 7 def test_create_user_creates_user_dirs(tmp_path: Path, monkeypatch): from argus.service import app as app_mod cfg_path = _write_config(tmp_path) monkeypatch.setenv("MVP_INTERNAL_TOKEN", "adm1") class _Scheduler: def __init__(self, **kwargs): self.tool = object() def run_forever(self, stop_flag): return None monkeypatch.setattr(app_mod, "Scheduler", _Scheduler) app = app_mod.create_app(str(cfg_path)) admin_headers = {"authorization": "Bearer adm1"} with TestClient(app) as c: assert c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"}).status_code == 200 base = tmp_path / "users" / "alice" assert (base / "datasets").is_dir() assert (base / "models").is_dir() assert (base / "code").is_dir() assert (base / "jobs").is_dir() assert (base / "trash" / "jobs").is_dir()