Test suite: pytest unit and integration tests, node tests, CI workflow
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def run():
|
||||
"""Run a coroutine to completion (no pytest-asyncio dependency)."""
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
return _run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
from helpers import Store
|
||||
return Store(str(tmp_path))
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Test helpers: a RigShare server without ComfyUI.
|
||||
|
||||
``rig_server()`` starts a real aiohttp app with RigShare's routes, middleware
|
||||
and hub, plus a small stand-in for ComfyUI's /userdata API backed by a temp
|
||||
folder, so HTTP and websocket behaviour can be tested end to end.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from aiohttp import web, ClientSession, CookieJar
|
||||
from aiohttp.test_utils import TestServer
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from rigshare_core.store import Store # noqa: E402
|
||||
from rigshare_core.hub import Hub # noqa: E402
|
||||
from rigshare_core.workspace import Workspace # noqa: E402
|
||||
from rigshare_core import routes # noqa: E402
|
||||
|
||||
PASSWORD = "longpassword"
|
||||
|
||||
|
||||
def enc(path):
|
||||
"""Encode a userdata path the way ComfyUI's frontend does."""
|
||||
return quote(path, safe="")
|
||||
|
||||
|
||||
class FakePromptServer:
|
||||
def __init__(self):
|
||||
self.app = web.Application()
|
||||
self.sent = []
|
||||
|
||||
def send_sync(self, event, data, sid=None):
|
||||
self.sent.append((event, sid))
|
||||
|
||||
|
||||
def comfy_routes(user_root):
|
||||
"""Minimal ComfyUI /userdata routes for the single default user."""
|
||||
|
||||
def resolve(name):
|
||||
if "%" in name:
|
||||
name = unquote(name)
|
||||
path = os.path.abspath(os.path.join(user_root, name))
|
||||
return path if os.path.commonpath((user_root, path)) == user_root else None
|
||||
|
||||
async def listing(request):
|
||||
base = resolve(request.query["dir"])
|
||||
out = []
|
||||
for full in glob.glob(os.path.join(glob.escape(base), "**", "*"), recursive=True):
|
||||
if os.path.isfile(full):
|
||||
out.append({"path": os.path.relpath(full, base).replace(os.sep, "/"), "size": 1, "modified": 1})
|
||||
return web.json_response(out)
|
||||
|
||||
async def get(request):
|
||||
path = resolve(request.match_info["file"])
|
||||
return web.FileResponse(path) if path and os.path.exists(path) else web.Response(status=404)
|
||||
|
||||
async def post(request):
|
||||
path = resolve(request.match_info["file"])
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "wb") as f:
|
||||
f.write(await request.read())
|
||||
return web.json_response(os.path.relpath(path, user_root))
|
||||
|
||||
async def delete(request):
|
||||
os.remove(resolve(request.match_info["file"]))
|
||||
return web.Response(status=204)
|
||||
|
||||
async def move(request):
|
||||
src, dst = resolve(request.match_info["file"]), resolve(request.match_info["dest"])
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
os.rename(src, dst)
|
||||
return web.json_response(os.path.relpath(dst, user_root))
|
||||
|
||||
async def ok(request):
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
table = web.RouteTableDef()
|
||||
for prefix in ("", "/api"):
|
||||
table.get(prefix + "/userdata")(listing)
|
||||
table.get(prefix + "/userdata/{file}")(get)
|
||||
table.post(prefix + "/userdata/{file}")(post)
|
||||
table.delete(prefix + "/userdata/{file}")(delete)
|
||||
table.post(prefix + "/userdata/{file}/move/{dest}")(move)
|
||||
table.route("*", prefix + "/prompt")(ok)
|
||||
table.route("*", prefix + "/manager/{tail:.*}")(ok)
|
||||
table.route("*", prefix + "/customnode/{tail:.*}")(ok)
|
||||
return table
|
||||
|
||||
|
||||
class Rig:
|
||||
"""A running test server plus convenience helpers."""
|
||||
|
||||
def __init__(self, server, store, hub, prompt_server, user_root):
|
||||
self.server, self.store, self.hub, self.ps, self.user_root = server, store, hub, prompt_server, user_root
|
||||
self.base = f"http://{server.host}:{server.port}"
|
||||
self.http = None
|
||||
self.headers = {}
|
||||
|
||||
def url(self, path):
|
||||
return self.base + path
|
||||
|
||||
async def setup_admin(self, username="boss"):
|
||||
r = await self.http.post(self.url("/rigshare/api/setup"), json={"username": username, "password": PASSWORD})
|
||||
assert r.status == 200, await r.text()
|
||||
self.headers[username] = {"Authorization": "Bearer " + (await r.json())["token"]}
|
||||
return self.headers[username]
|
||||
|
||||
async def add_user(self, username, **perms):
|
||||
admin = next(iter(self.headers.values()))
|
||||
r = await self.http.post(self.url("/rigshare/api/users"), headers=admin,
|
||||
json={"username": username, "password": PASSWORD, "perms": perms})
|
||||
assert r.status == 200, await r.text()
|
||||
r = await self.http.post(self.url("/rigshare/api/login"), json={"username": username, "password": PASSWORD})
|
||||
self.headers[username] = {"Authorization": "Bearer " + (await r.json())["token"]}
|
||||
return self.headers[username]
|
||||
|
||||
async def ws(self, username):
|
||||
session = ClientSession()
|
||||
sock = await session.ws_connect(self.url("/rigshare/ws"), headers=self.headers[username])
|
||||
await sock.send_json({"type": "hello"})
|
||||
await recv_until(sock, "welcome")
|
||||
return Socket(session, sock)
|
||||
|
||||
|
||||
class Socket:
|
||||
def __init__(self, session, sock):
|
||||
self.session, self.sock = session, sock
|
||||
|
||||
async def send(self, message):
|
||||
await self.sock.send_json(message)
|
||||
|
||||
async def until(self, kind, timeout=2.0):
|
||||
return await recv_until(self.sock, kind, timeout)
|
||||
|
||||
async def drain(self, timeout=0.3):
|
||||
return await drain(self.sock, timeout)
|
||||
|
||||
async def close(self):
|
||||
await self.sock.close()
|
||||
await self.session.close()
|
||||
|
||||
|
||||
async def recv_until(sock, kind, timeout=2.0):
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
end = loop.time() + timeout
|
||||
while True:
|
||||
msg = await asyncio.wait_for(sock.receive(), max(0.01, end - loop.time()))
|
||||
data = json.loads(msg.data)
|
||||
if data.get("type") == kind:
|
||||
return data
|
||||
|
||||
|
||||
async def drain(sock, timeout=0.3):
|
||||
import asyncio
|
||||
out = []
|
||||
try:
|
||||
while True:
|
||||
msg = await asyncio.wait_for(sock.receive(), timeout)
|
||||
out.append(json.loads(msg.data))
|
||||
except asyncio.TimeoutError:
|
||||
return out
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def rig_server(config=None, workspace=True, setup=None):
|
||||
data = tempfile.mkdtemp()
|
||||
user_root = tempfile.mkdtemp()
|
||||
os.makedirs(os.path.join(user_root, "workflows"))
|
||||
store = Store(data)
|
||||
# Tests connect from 127.0.0.1: nothing may be trusted, or checks are skipped.
|
||||
store.update_config({"api_protection": {"enabled": True, "trusted_ips": []}, **(config or {})})
|
||||
ps = FakePromptServer()
|
||||
hub = Hub(store, ps)
|
||||
if workspace:
|
||||
hub.workspace = Workspace(store, os.path.join(user_root, "workflows"))
|
||||
routes.setup(ps, store, hub)
|
||||
ps.app.add_routes(comfy_routes(user_root))
|
||||
if setup:
|
||||
setup(ps.app)
|
||||
server = TestServer(ps.app, host="127.0.0.1")
|
||||
await server.start_server()
|
||||
rig = Rig(server, store, hub, ps, user_root)
|
||||
# unsafe=True: keep cookies for the 127.0.0.1 test host (browsers do).
|
||||
async with ClientSession(cookie_jar=CookieJar(unsafe=True)) as http:
|
||||
rig.http = http
|
||||
try:
|
||||
yield rig
|
||||
finally:
|
||||
await server.close()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Workflow patches: apply, link repair, conflict renumbering, JS parity."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import ROOT
|
||||
from rigshare_core.graphdoc import apply_patch, reconcile, remap_conflicts, link_id, link_ends
|
||||
|
||||
|
||||
def node(nid, n_in=2, n_out=1):
|
||||
return {"id": nid, "type": "T", "pos": [0, 0],
|
||||
"inputs": [{"name": f"i{i}", "link": None} for i in range(n_in)],
|
||||
"outputs": [{"name": f"o{j}", "links": []} for j in range(n_out)]}
|
||||
|
||||
|
||||
def doc_with(n=3, links=()):
|
||||
doc = {"last_node_id": n, "last_link_id": len(links), "nodes": [node(i + 1) for i in range(n)],
|
||||
"links": [list(l) for l in links], "groups": [], "extra": {}, "version": 0.4}
|
||||
return reconcile(doc)
|
||||
|
||||
|
||||
def assert_consistent(doc):
|
||||
ids = {n["id"] for n in doc["nodes"]}
|
||||
links = {link_id(l): l for l in doc["links"]}
|
||||
assert len(links) == len(doc["links"]), "duplicate link ids"
|
||||
assert len(ids) == len(doc["nodes"]), "duplicate node ids"
|
||||
for l in doc["links"]:
|
||||
origin, _, target, _ = link_ends(l)
|
||||
assert origin in ids and target in ids, "dangling link"
|
||||
for n in doc["nodes"]:
|
||||
for i, slot in enumerate(n["inputs"]):
|
||||
if slot["link"] is not None:
|
||||
_, _, target, target_slot = link_ends(links[slot["link"]])
|
||||
assert (target, target_slot) == (n["id"], i)
|
||||
|
||||
|
||||
def test_upsert_and_remove_nodes():
|
||||
doc = doc_with(3)
|
||||
moved = copy.deepcopy(doc["nodes"][0]); moved["pos"] = [5, 5]
|
||||
out = apply_patch(doc, {"nodes": [moved, node(4)], "removed": [2]})
|
||||
assert [n["id"] for n in out["nodes"]] == [1, 3, 4]
|
||||
assert out["nodes"][0]["pos"] == [5, 5]
|
||||
assert doc["nodes"][0]["pos"] == [0, 0], "input document must not be mutated"
|
||||
|
||||
|
||||
def test_links_merge_by_id():
|
||||
doc = doc_with(3, [(1, 1, 0, 2, 0, "X")])
|
||||
out = apply_patch(doc, {"links": {"upsert": [[2, 3, 0, 2, 1, "X"]]}})
|
||||
assert sorted(link_id(l) for l in out["links"]) == [1, 2], "a new link must not erase another"
|
||||
|
||||
|
||||
def test_max_keys_never_go_backwards():
|
||||
doc = doc_with(3)
|
||||
doc["last_node_id"] = 10
|
||||
assert apply_patch(doc, {"set": {"last_node_id": 4}})["last_node_id"] == 10
|
||||
|
||||
|
||||
def test_reconcile_drops_dangling_and_duplicate_inputs():
|
||||
doc = doc_with(3)
|
||||
doc["links"] = [[1, 1, 0, 2, 0, "X"], [2, 3, 0, 2, 0, "X"], [3, 9, 0, 2, 1, "X"]]
|
||||
reconcile(doc)
|
||||
assert [link_id(l) for l in doc["links"]] == [2], "newest link on a slot wins, dangling dropped"
|
||||
assert doc["nodes"][1]["inputs"][0]["link"] == 2
|
||||
assert doc["nodes"][2]["outputs"][0]["links"] == [2]
|
||||
assert_consistent(doc)
|
||||
|
||||
|
||||
def test_remap_simultaneous_adds():
|
||||
base = doc_with(2)
|
||||
def add(target_slot):
|
||||
return {"nodes": [node(3)], "added": [3],
|
||||
"links": {"upsert": [[1, 3, 0, 2, target_slot, "X"]], "added": [1]},
|
||||
"set": {"last_node_id": 3, "last_link_id": 1}}
|
||||
server = base
|
||||
pa, changed_a = remap_conflicts(server, add(0)); server = apply_patch(server, pa)
|
||||
pb, changed_b = remap_conflicts(server, add(1)); server = apply_patch(server, pb)
|
||||
assert not changed_a and changed_b
|
||||
assert sorted(n["id"] for n in server["nodes"]) == [1, 2, 3, 4]
|
||||
assert sorted(link_id(l) for l in server["links"]) == [1, 2]
|
||||
assert_consistent(server)
|
||||
|
||||
|
||||
def test_remap_leaves_updates_of_existing_nodes_alone():
|
||||
doc = doc_with(3)
|
||||
update = copy.deepcopy(doc["nodes"][0]); update["pos"] = [1, 1]
|
||||
patch, changed = remap_conflicts(doc, {"nodes": [update]})
|
||||
assert not changed and patch["nodes"][0]["id"] == 1
|
||||
|
||||
|
||||
def test_concurrent_edits_converge():
|
||||
rnd = random.Random(1)
|
||||
for _ in range(100):
|
||||
server = doc_with(4, [(1, 1, 0, 2, 0, "X")])
|
||||
observer = copy.deepcopy(server)
|
||||
patches = []
|
||||
for _ in range(2): # two people, same base, same instant
|
||||
nid, lid = server["last_node_id"] + 1, server["last_link_id"] + 1
|
||||
target = rnd.choice([n["id"] for n in server["nodes"]])
|
||||
patches.append({"nodes": [node(nid)], "added": [nid],
|
||||
"links": {"upsert": [[lid, nid, 0, target, rnd.randint(0, 1), "X"]], "added": [lid]},
|
||||
"set": {"last_node_id": nid, "last_link_id": lid}})
|
||||
for p in patches:
|
||||
p2, _ = remap_conflicts(server, p)
|
||||
server = apply_patch(server, p2)
|
||||
observer = apply_patch(observer, p2)
|
||||
assert_consistent(server)
|
||||
assert len(server["nodes"]) == 6
|
||||
assert json.dumps(server, sort_keys=True) == json.dumps(observer, sort_keys=True)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node is not installed")
|
||||
def test_javascript_mirror_matches(tmp_path):
|
||||
"""graphdoc.js must produce exactly the same documents as graphdoc.py."""
|
||||
rnd = random.Random(7)
|
||||
cases = []
|
||||
for _ in range(60):
|
||||
doc = doc_with(5, [(1, 1, 0, 2, 0, "X"), (2, 3, 0, 4, 1, "X")])
|
||||
patches, cur = [], doc
|
||||
for _ in range(5):
|
||||
ids = [n["id"] for n in cur["nodes"]]
|
||||
p = {}
|
||||
if rnd.random() < .5:
|
||||
n = copy.deepcopy(rnd.choice(cur["nodes"])); n["pos"] = [rnd.randint(0, 99), 0]; p["nodes"] = [n]
|
||||
if rnd.random() < .4 and len(ids) > 1:
|
||||
a, b = rnd.sample(ids, 2); lid = cur["last_link_id"] + 1
|
||||
p["links"] = {"upsert": [[lid, a, 0, b, rnd.randint(0, 1), "X"]], "added": [lid]}
|
||||
p["set"] = {"last_link_id": lid}
|
||||
if rnd.random() < .2 and len(ids) > 3:
|
||||
p["removed"] = [rnd.choice(ids)]
|
||||
patches.append(p)
|
||||
cur = apply_patch(cur, p)
|
||||
cases.append({"doc": doc, "patches": patches, "expected": cur})
|
||||
data = tmp_path / "cases.json"
|
||||
data.write_text(json.dumps(cases))
|
||||
module = (Path(ROOT) / "web" / "rigshare" / "graphdoc.js").as_uri()
|
||||
script = tmp_path / "check.mjs"
|
||||
script.write_text(
|
||||
f'import fs from "fs"; import {{ applyPatch }} from "{module}";\n'
|
||||
'const cases = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); let bad = 0;\n'
|
||||
'for (const c of cases) { let cur = c.doc; for (const p of c.patches) cur = applyPatch(cur, p);\n'
|
||||
' if (JSON.stringify(cur) !== JSON.stringify(c.expected)) bad++; }\n'
|
||||
'console.log(bad); process.exit(bad ? 1 : 0);\n')
|
||||
result = subprocess.run(["node", str(script), str(data)], capture_output=True, text=True)
|
||||
assert result.returncode == 0, f"{result.stdout} mismatching cases\n{result.stderr}"
|
||||
@@ -0,0 +1,199 @@
|
||||
"""End-to-end: HTTP API, login gate, websocket hub, folders over ComfyUI's API."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from helpers import PASSWORD, enc, rig_server
|
||||
|
||||
BASE_DOC = {"last_node_id": 2, "last_link_id": 0, "nodes": [
|
||||
{"id": 1, "type": "A", "inputs": [], "outputs": [{"name": "o", "links": []}]},
|
||||
{"id": 2, "type": "B", "inputs": [{"name": "i", "link": None}, {"name": "j", "link": None}], "outputs": []}],
|
||||
"links": [], "groups": [], "extra": {}, "version": 0.4}
|
||||
|
||||
|
||||
def test_login_gate_and_setup(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
h = rig.http
|
||||
r = await h.get(rig.url("/"), headers={"Accept": "text/html"}, allow_redirects=False)
|
||||
assert r.status == 302 and "/rigshare/login" in r.headers["Location"]
|
||||
assert (await h.get(rig.url("/api/prompt"))).status == 401
|
||||
status = await (await h.get(rig.url("/rigshare/api/status"))).json()
|
||||
assert status["needs_setup"]
|
||||
page = await (await h.get(rig.url("/rigshare/login"))).text()
|
||||
assert "const SETUP = true" in page
|
||||
await rig.setup_admin()
|
||||
r = await h.post(rig.url("/rigshare/api/setup"), json={"username": "evil", "password": PASSWORD})
|
||||
assert r.status == 409, "setup works only once"
|
||||
r = await h.post(rig.url("/rigshare/api/login"), json={"username": "boss", "password": "nope-nope"})
|
||||
assert r.status == 401
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_cookie_session_and_logout(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
r = await rig.http.post(rig.url("/rigshare/api/login"), json={"username": "boss", "password": PASSWORD})
|
||||
token = (await r.json())["token"]
|
||||
assert "rigshare_session" in r.cookies
|
||||
assert (await rig.http.get(rig.url("/api/prompt"))).status == 200, "cookie authenticates"
|
||||
await rig.http.post(rig.url("/rigshare/api/logout"), headers={"Authorization": f"Bearer {token}"})
|
||||
assert (await rig.http.get(rig.url("/api/prompt"), headers={"Authorization": f"Bearer {token}"})).status == 401
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_api_keys_over_http(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
r = await rig.http.post(rig.url("/rigshare/api/me/keys"), json={"name": "script"}, headers=admin)
|
||||
body = await r.json()
|
||||
key = {"Authorization": f"Bearer {body['key']}"}
|
||||
assert (await rig.http.get(rig.url("/api/prompt"), headers=key)).status == 200
|
||||
await rig.http.delete(rig.url(f"/rigshare/api/me/keys/{body['id']}"), headers=admin)
|
||||
assert (await rig.http.get(rig.url("/api/prompt"), headers=key)).status == 401
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_permissions(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
viewer = await rig.add_user("viewer")
|
||||
queuer = await rig.add_user("queuer", queue=True)
|
||||
h = rig.http
|
||||
assert (await h.post(rig.url("/api/prompt"), headers=viewer)).status == 403
|
||||
assert (await h.post(rig.url("/api/prompt"), headers=queuer)).status == 200
|
||||
assert (await h.post(rig.url("/api/manager/queue/install"), headers=queuer)).status == 403
|
||||
assert (await h.get(rig.url("/customnode/getmappings"), headers=viewer)).status == 200, "read-only lookup"
|
||||
assert (await h.post(rig.url("/api/manager/queue/install"), headers=admin)).status == 200
|
||||
await h.patch(rig.url("/rigshare/api/users/queuer"), json={"perms": {"manager": True}}, headers=admin)
|
||||
assert (await h.post(rig.url("/api/manager/queue/install"), headers=queuer)).status == 200
|
||||
assert (await h.get(rig.url("/rigshare/api/users"), headers=viewer)).status == 403
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_template_filter_and_live_options(run, tmp_path):
|
||||
templates = tmp_path / "templates"
|
||||
templates.mkdir()
|
||||
(templates / "index.json").write_text(json.dumps([
|
||||
{"title": "Image", "templates": [{"name": "sdxl", "tags": ["Image"], "openSource": True},
|
||||
{"name": "api_flux", "tags": ["API"], "openSource": False}]},
|
||||
{"title": "Ads", "templates": [{"name": "x", "tags": ["API"]}]}]))
|
||||
|
||||
async def scenario():
|
||||
async with rig_server(setup=lambda app: app.router.add_static("/templates", str(templates))) as rig:
|
||||
admin = await rig.setup_admin()
|
||||
alice = await rig.add_user("alice", edit=True)
|
||||
sock = await rig.ws("alice")
|
||||
full = await (await rig.http.get(rig.url("/templates/index.json"), headers=alice)).json()
|
||||
assert sum(len(c["templates"]) for c in full) == 3
|
||||
await rig.http.patch(rig.url("/rigshare/api/config"), json={"hide_api_templates": True, "hide_comfy_account": True}, headers=admin)
|
||||
assert (await sock.until("server"))["server"]["hide_comfy_account"] is True
|
||||
slim = await (await rig.http.get(rig.url("/templates/index.json"), headers=alice)).json()
|
||||
assert [t["name"] for c in slim for t in c["templates"]] == ["sdxl"] and len(slim) == 1
|
||||
await sock.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_rooms_conflicts_and_access_lists(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
admin = await rig.setup_admin()
|
||||
for name in ("alice", "bob", "carol"):
|
||||
await rig.add_user(name, edit=True)
|
||||
alice, bob, carol = [await rig.ws(n) for n in ("alice", "bob", "carol")]
|
||||
room = "file:workflows/w.json"
|
||||
await alice.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC})
|
||||
joined = await alice.until("room")
|
||||
assert joined["created"] and joined["manage"]
|
||||
await bob.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC})
|
||||
await bob.until("room")
|
||||
await alice.drain(); await bob.drain()
|
||||
|
||||
def add(target_slot):
|
||||
return {"nodes": [{"id": 3, "type": "N", "inputs": [], "outputs": [{"name": "o", "links": [1]}]}], "added": [3],
|
||||
"links": {"upsert": [[1, 3, 0, 2, target_slot, "X"]], "added": [1]},
|
||||
"set": {"last_node_id": 3, "last_link_id": 1}}
|
||||
await alice.send({"type": "graph", "room": room, "patch": add(0)})
|
||||
await bob.send({"type": "graph", "room": room, "patch": add(1)})
|
||||
await asyncio.sleep(0.4)
|
||||
doc = rig.hub.rooms[room].doc
|
||||
assert sorted(n["id"] for n in doc["nodes"]) == [1, 2, 3, 4], "both nodes kept"
|
||||
assert [s["link"] for s in doc["nodes"][1]["inputs"]] == [1, 2]
|
||||
assert any(m["type"] == "doc" and m.get("reason") is None for m in await bob.drain()), "quiet resync"
|
||||
|
||||
r = await rig.http.put(rig.url("/rigshare/api/room/acl"), json={"key": room, "restricted": True, "members": {"carol": "view"}}, headers=rig.headers["bob"])
|
||||
assert r.status == 403, "only owner or admin"
|
||||
r = await rig.http.put(rig.url("/rigshare/api/room/acl"), json={"key": room, "restricted": True, "members": {"carol": "view"}}, headers=rig.headers["alice"])
|
||||
assert r.status == 200
|
||||
msgs = await bob.drain()
|
||||
assert any(m["type"] == "room_denied" for m in msgs)
|
||||
presence = [m for m in msgs if m["type"] == "presence"][-1]
|
||||
assert all(r["key"] != room for r in presence["rooms"])
|
||||
assert (await rig.http.get(rig.url(f"/api/userdata/{enc('workflows/w.json')}"), headers=rig.headers["bob"])).status == 403
|
||||
|
||||
await carol.send({"type": "join", "room": room, "name": "w", "doc": BASE_DOC})
|
||||
assert (await carol.until("room"))["role"] == "view"
|
||||
await carol.drain()
|
||||
await carol.send({"type": "graph", "room": room, "patch": {"nodes": [dict(BASE_DOC["nodes"][0], pos=[9, 9])]}})
|
||||
assert "view" in ((await carol.until("doc")).get("reason") or "")
|
||||
r = await rig.http.put(rig.url("/rigshare/api/room/acl"), json={"key": room, "restricted": True, "members": {"carol": "edit"}}, headers=admin)
|
||||
assert (await carol.until("room_info"))["role"] == "edit"
|
||||
for s in (alice, bob, carol):
|
||||
await s.close()
|
||||
run(scenario())
|
||||
|
||||
|
||||
def test_folders_over_comfy_api(run):
|
||||
async def scenario():
|
||||
async with rig_server() as rig:
|
||||
await rig.setup_admin()
|
||||
for name, perms in (("alice", {"edit": True}), ("bob", {"edit": True}), ("carol", {"edit": True}), ("dave", {})):
|
||||
await rig.add_user(name, **perms)
|
||||
H, h = rig.headers, rig.http
|
||||
|
||||
async def save(user, path):
|
||||
return (await h.post(rig.url(f"/api/userdata/{enc(path)}"), data=b"{}", headers=H[user])).status
|
||||
|
||||
async def listing(user):
|
||||
r = await h.get(rig.url("/api/userdata?dir=workflows&recurse=true&full_info=true"), headers=H[user])
|
||||
return {i["path"] for i in await r.json()}
|
||||
|
||||
assert await save("alice", "workflows/users/alice/secret.json") == 200
|
||||
assert await save("dave", "workflows/users/dave/mine.json") == 200
|
||||
assert await save("dave", "workflows/common.json") == 403
|
||||
assert await save("bob", "workflows/users/alice/evil.json") == 403
|
||||
for raw in (enc("workflows/users/alice/secret.json"),
|
||||
quote(enc("workflows/users/alice/secret.json"), safe=""),
|
||||
enc("workflows/shared/../users/alice/secret.json"),
|
||||
"workflows/users/alice/secret.json"):
|
||||
assert (await h.get(rig.url(f"/api/userdata/{raw}"), headers=H["bob"])).status in (403, 404), raw
|
||||
assert "users/alice/secret.json" not in await listing("bob")
|
||||
assert "users/alice/secret.json" in await listing("alice")
|
||||
|
||||
r = await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Team"}, headers=H["bob"])
|
||||
assert r.status == 200
|
||||
assert await save("alice", "workflows/shared/Team/plan.json") == 200
|
||||
await h.patch(rig.url("/rigshare/api/workspace/folders"), json={"path": "shared/Team", "restricted": True, "members": {"carol": "view"}}, headers=H["bob"])
|
||||
assert not any(p.startswith("shared/Team/") for p in await listing("alice"))
|
||||
assert await save("carol", "workflows/shared/Team/plan.json") == 403
|
||||
assert (await h.get(rig.url(f"/api/userdata/{enc('workflows/shared/Team/plan.json')}"), headers=H["carol"])).status == 200
|
||||
|
||||
r = await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/Team/plan.json')}/move/{enc('workflows/users/bob/plan.json')}"), headers=H["bob"])
|
||||
assert r.status == 200
|
||||
assert (await h.delete(rig.url(f"/api/userdata/{enc('workflows/users/alice/secret.json')}"), headers=H["bob"])).status == 403
|
||||
assert (await h.delete(rig.url(f"/api/userdata/{enc('workflows/users/alice/secret.json')}"), headers=H["alice"])).status == 204
|
||||
|
||||
sock = await rig.ws("bob")
|
||||
await sock.send({"type": "join", "room": "file:workflows/users/alice/other.json", "name": "o", "doc": {"nodes": []}})
|
||||
assert (await sock.until("room_denied"))["room"].endswith("alice/other.json")
|
||||
await sock.close()
|
||||
|
||||
await save("alice", "workflows/users/alice/keep.json")
|
||||
await h.patch(rig.url("/rigshare/api/users/alice"), json={"username": "alicia"}, headers=H["boss"])
|
||||
assert os.path.exists(os.path.join(rig.user_root, "workflows", "users", "alicia", "keep.json"))
|
||||
run(scenario())
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Accounts, passwords, JWT sessions, API keys, snapshots."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import PASSWORD
|
||||
from rigshare_core import jwt
|
||||
import rigshare_core.store as store_module
|
||||
|
||||
|
||||
def make_admin(store, name="admin"):
|
||||
return store.setup_admin(name, PASSWORD)
|
||||
|
||||
|
||||
# ----- setup and accounts ------------------------------------------------
|
||||
|
||||
def test_no_default_accounts(store):
|
||||
assert store.needs_setup
|
||||
assert store.users == {}
|
||||
|
||||
|
||||
def test_setup_only_once(store):
|
||||
user = make_admin(store)
|
||||
assert user["perms"]["admin"] and not store.needs_setup
|
||||
with pytest.raises(PermissionError):
|
||||
store.setup_admin("intruder", PASSWORD)
|
||||
|
||||
|
||||
def test_env_provisioning(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("RIGSHARE_ADMIN_USER", "ops")
|
||||
monkeypatch.setenv("RIGSHARE_ADMIN_PASSWORD", PASSWORD)
|
||||
s = store_module.Store(str(tmp_path))
|
||||
assert s.users["ops"]["perms"]["admin"]
|
||||
|
||||
|
||||
def test_passwords_are_hashed(store, tmp_path):
|
||||
make_admin(store)
|
||||
raw = (tmp_path / "users.json").read_text()
|
||||
assert PASSWORD not in raw
|
||||
assert store.users["admin"]["password"].startswith("pbkdf2_sha256$")
|
||||
|
||||
|
||||
def test_authenticate(store):
|
||||
make_admin(store)
|
||||
assert store.authenticate("admin", PASSWORD)
|
||||
assert store.authenticate("admin", "wrong-password") is None
|
||||
assert store.authenticate("nobody", PASSWORD) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("username,password", [("x", PASSWORD), ("bad name", PASSWORD), ("ok_name", "short")])
|
||||
def test_create_user_validation(store, username, password):
|
||||
make_admin(store)
|
||||
with pytest.raises(ValueError):
|
||||
store.create_user(username, password)
|
||||
|
||||
|
||||
def test_last_admin_is_protected(store):
|
||||
make_admin(store)
|
||||
with pytest.raises(ValueError):
|
||||
store.delete_user("admin")
|
||||
with pytest.raises(ValueError):
|
||||
store.update_user("admin", perms={"admin": False})
|
||||
|
||||
|
||||
def test_password_change_revokes_sessions(store):
|
||||
make_admin(store)
|
||||
token = store.create_session("admin")
|
||||
store.update_user("admin", password="another-password")
|
||||
assert store.user_for_token(token) is None
|
||||
|
||||
|
||||
# ----- JWT sessions --------------------------------------------------------
|
||||
|
||||
def claims_of(token):
|
||||
return json.loads(jwt._b64d(token.split(".")[1]))
|
||||
|
||||
|
||||
def test_session_is_jwt(store):
|
||||
make_admin(store)
|
||||
token = store.create_session("admin")
|
||||
assert token.count(".") == 2
|
||||
assert store.user_for_token(token)["username"] == "admin"
|
||||
|
||||
|
||||
def test_tampered_token_rejected(store):
|
||||
make_admin(store)
|
||||
header, body, sig = store.create_session("admin").split(".")
|
||||
forged = jwt._b64e(json.dumps({**claims_of(f"{header}.{body}.{sig}"), "sub": "someone"}).encode())
|
||||
assert store.user_for_token(f"{header}.{forged}.{sig}") is None
|
||||
assert store.user_for_token(f"{header}.{body}.{sig[:-2]}AA") is None
|
||||
|
||||
|
||||
def test_alg_none_rejected(store):
|
||||
make_admin(store)
|
||||
_, body, _ = store.create_session("admin").split(".")
|
||||
none_header = jwt._b64e(b'{"alg":"none","typ":"JWT"}')
|
||||
assert store.user_for_token(f"{none_header}.{body}.") is None
|
||||
|
||||
|
||||
def test_expired_token_rejected(store):
|
||||
make_admin(store)
|
||||
sid = claims_of(store.create_session("admin"))["sid"]
|
||||
old = jwt.encode({"iss": "rigshare", "sub": "admin", "sid": sid, "iat": 0, "nbf": 0, "exp": 1}, store.secret)
|
||||
assert store.user_for_token(old) is None
|
||||
|
||||
|
||||
def test_logout_revokes_immediately(store):
|
||||
make_admin(store)
|
||||
token = store.create_session("admin")
|
||||
store.revoke_session(token)
|
||||
assert store.user_for_token(token) is None
|
||||
|
||||
|
||||
def test_refresh_only_when_old(store, monkeypatch):
|
||||
make_admin(store)
|
||||
token = store.create_session("admin")
|
||||
assert store.refresh_token(token) is None
|
||||
monkeypatch.setattr(store_module, "TOKEN_REFRESH", -1)
|
||||
fresh = store.refresh_token(token)
|
||||
assert fresh and store.user_for_token(fresh)["username"] == "admin"
|
||||
assert claims_of(fresh)["sid"] == claims_of(token)["sid"]
|
||||
|
||||
|
||||
def test_rename_keeps_sessions_and_keys(store):
|
||||
make_admin(store)
|
||||
token = store.create_session("admin")
|
||||
key, _ = store.create_api_key("admin", "k")
|
||||
store.rename_user("admin", "root")
|
||||
assert store.user_for_token(token)["username"] == "root"
|
||||
assert store.user_for_token(key)["username"] == "root"
|
||||
|
||||
|
||||
# ----- API keys ---------------------------------------------------------------
|
||||
|
||||
def test_api_keys(store, tmp_path):
|
||||
make_admin(store)
|
||||
key, meta = store.create_api_key("admin", "script")
|
||||
assert key.startswith("rs_") and key not in (tmp_path / "users.json").read_text()
|
||||
assert store.user_for_token(key)["username"] == "admin"
|
||||
assert "hash" not in store.list_api_keys("admin")[0]
|
||||
store.revoke_api_key("admin", meta["id"])
|
||||
assert store.user_for_token(key) is None
|
||||
|
||||
|
||||
# ----- snapshots ----------------------------------------------------------------
|
||||
|
||||
def test_snapshot_rotation_keeps_labelled(store):
|
||||
store.update_config({"snapshot_keep": 2})
|
||||
for i in range(4):
|
||||
store.add_snapshot({"nodes": []}, "me", "room", "Room")
|
||||
time.sleep(0.01)
|
||||
store.add_snapshot({"nodes": []}, "me", "room", "Room", label="keep me")
|
||||
snaps = store.list_snapshots("room")
|
||||
assert len([s for s in snaps if not s["label"]]) == 2
|
||||
assert any(s["label"] == "keep me" for s in snaps)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Folder rules: path normalisation, roles, deletes, moves, browsing."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from helpers import Store
|
||||
from rigshare_core.workspace import Workspace, norm
|
||||
|
||||
ADMIN = {"edit": True, "queue": True, "admin": True}
|
||||
EDITOR = {"edit": True, "queue": True, "admin": False}
|
||||
VIEWER = {"edit": False, "queue": False, "admin": False}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ws(tmp_path):
|
||||
root = tmp_path / "workflows"
|
||||
root.mkdir()
|
||||
return Workspace(Store(str(tmp_path / "data")), str(root))
|
||||
|
||||
|
||||
def touch(ws, rel):
|
||||
path = os.path.join(ws.root, *rel.split("/"))
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
open(path, "w").write("{}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw,expected", [
|
||||
("workflows/a.json", "workflows/a.json"),
|
||||
("workflows%2Fusers%2Fbob%2Fa.json", "workflows/users/bob/a.json"),
|
||||
("workflows%252Fusers%252Fbob%252Fa.json", "workflows/users/bob/a.json"),
|
||||
("workflows/shared/../users/bob/a.json", "workflows/users/bob/a.json"),
|
||||
("../../etc/passwd", "etc/passwd"),
|
||||
("workflows\\users\\bob", "workflows/users/bob"),
|
||||
("", ""),
|
||||
])
|
||||
def test_norm(raw, expected):
|
||||
assert norm(raw) == expected
|
||||
|
||||
|
||||
def test_areas():
|
||||
assert Workspace.area("users/bob/x.json") == ("private", "bob")
|
||||
assert Workspace.area("shared/Team/x.json") == ("shared", "shared/Team")
|
||||
assert Workspace.area("x.json") == ("common", None)
|
||||
assert Workspace.workflows_rel("workflows/a/b.json") == "a/b.json"
|
||||
assert Workspace.workflows_rel("subgraphs/x.json") is None
|
||||
|
||||
|
||||
def test_private_space(ws):
|
||||
assert ws.role("users/bob/x.json", "user", "bob", VIEWER) == "edit", "own space even for viewer accounts"
|
||||
assert ws.role("users/bob/x.json", "user", "alice", EDITOR) is None
|
||||
assert ws.role("users/bob/x.json", "user", "root", ADMIN) == "edit"
|
||||
assert ws.can_delete("users/bob/x.json", "user", "bob", VIEWER)
|
||||
assert not ws.can_delete("users/bob/x.json", "user", "alice", EDITOR)
|
||||
|
||||
|
||||
def test_common_space(ws):
|
||||
assert ws.role("x.json", "user", "alice", EDITOR) == "edit"
|
||||
assert ws.role("x.json", "user", "dave", VIEWER) == "view"
|
||||
assert not ws.can_delete("x.json", "user", "alice", EDITOR), "Common deletes are admin-only"
|
||||
assert ws.can_delete("x.json", "user", "root", ADMIN)
|
||||
|
||||
|
||||
def test_shared_folder_access_list(ws):
|
||||
folder = ws.create_folder("Team", "bob")
|
||||
assert ws.role(f"{folder}/a.json", "user", "alice", EDITOR) == "edit", "open by default"
|
||||
ws.set_acl(folder, {"members": {"carol": "view", "erin": "edit"}})
|
||||
assert ws.role(f"{folder}/a.json", "user", "alice", EDITOR) is None
|
||||
assert ws.role(f"{folder}/a.json", "user", "carol", EDITOR) == "view"
|
||||
assert ws.role(f"{folder}/a.json", "user", "erin", EDITOR) == "edit"
|
||||
assert ws.role(f"{folder}/a.json", "user", "erin", VIEWER) == "view", "a role never exceeds account perms"
|
||||
assert ws.role(f"{folder}/a.json", "user", "bob", EDITOR) == "edit", "owner always"
|
||||
assert ws.can_delete(f"{folder}/a.json", "user", "bob", EDITOR)
|
||||
assert not ws.can_delete(f"{folder}/a.json", "user", "erin", EDITOR)
|
||||
|
||||
|
||||
def test_moves(ws):
|
||||
folder = ws.create_folder("Team", "bob")
|
||||
assert ws.can_move("users/bob/a.json", f"{folder}/a.json", "user", "bob", EDITOR)
|
||||
assert ws.can_move(f"{folder}/a.json", f"{folder}/b.json", "user", "alice", EDITOR), "rename in place"
|
||||
assert not ws.can_move(f"{folder}/a.json", "users/alice/a.json", "user", "alice", EDITOR), "only owner moves out"
|
||||
assert not ws.can_move("users/bob/a.json", "users/alice/a.json", "user", "bob", EDITOR)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["", "..", "a/b", "x" * 80, ".hidden"])
|
||||
def test_bad_folder_names(ws, name):
|
||||
with pytest.raises(ValueError):
|
||||
ws.create_folder(name, "bob")
|
||||
|
||||
|
||||
def test_list_dir(ws):
|
||||
folder = ws.create_folder("Team", "bob")
|
||||
touch(ws, "common.json")
|
||||
touch(ws, "users/alice/secret.json")
|
||||
touch(ws, f"{folder}/plan.json")
|
||||
shared = ws.list_dir("@shared", "user", "alice", EDITOR)
|
||||
assert {e["name"] for e in shared["entries"]} == {"Team", "common"}
|
||||
ws.set_acl(folder, {"members": {}})
|
||||
shared = ws.list_dir("@shared", "user", "alice", EDITOR)
|
||||
assert {e["name"] for e in shared["entries"]} == {"common"}
|
||||
with pytest.raises(PermissionError):
|
||||
ws.list_dir("users/alice", "user", "bob", EDITOR)
|
||||
with pytest.raises(PermissionError):
|
||||
ws.list_dir("users", "user", "bob", EDITOR)
|
||||
mine = ws.list_dir("users/alice", "user", "alice", EDITOR)
|
||||
assert [e["name"] for e in mine["entries"]] == ["secret"] and mine["can_mkdir"]
|
||||
|
||||
|
||||
def test_folder_operations(ws):
|
||||
touch(ws, "users/alice/keep.json")
|
||||
sub = ws.make_dir("users/alice", "renders", "user", "alice", EDITOR)
|
||||
assert sub == "users/alice/renders"
|
||||
with pytest.raises(PermissionError):
|
||||
ws.make_dir("users/alice", "x", "user", "bob", EDITOR)
|
||||
with pytest.raises(PermissionError):
|
||||
ws.rename_dir("users/alice", "hack", "user", "alice", EDITOR)
|
||||
assert ws.rename_dir(sub, "finals", "user", "alice", EDITOR) == "users/alice/finals"
|
||||
team = ws.make_dir("@shared", "Team", "user", "bob", EDITOR)
|
||||
assert team == "shared/Team" and ws.folders[team]["owner"] == "bob"
|
||||
assert ws.move_dir("users/alice/finals", team, "user", "alice", EDITOR) == "shared/Team/finals"
|
||||
with pytest.raises(PermissionError):
|
||||
ws.delete_dir(team, "user", "alice", EDITOR)
|
||||
ws.delete_dir("shared/Team/finals", "user", "bob", EDITOR)
|
||||
with pytest.raises(PermissionError):
|
||||
ws.delete_dir("users/alice", "user", "alice", EDITOR)
|
||||
|
||||
|
||||
def test_rename_user_moves_private_folder(ws):
|
||||
touch(ws, "users/alice/keep.json")
|
||||
ws.create_folder("Team", "alice")
|
||||
ws.rename_user("alice", "alicia")
|
||||
assert os.path.exists(os.path.join(ws.root, "users", "alicia", "keep.json"))
|
||||
assert ws.folders["shared/Team"]["owner"] == "alicia"
|
||||
Reference in New Issue
Block a user