From 7a5385c46a9ef033d647d7da896b7ee0a569ea98 Mon Sep 17 00:00:00 2001 From: Doudou Date: Sat, 26 Sep 2026 02:17:22 +0000 Subject: [PATCH] Test suite: pytest unit and integration tests, node tests, CI workflow --- .comfyignore | 1 + .github/workflows/tests.yml | 23 ++++ README.md | 1 + __init__.py | 21 ++-- docs/development.md | 37 ++++++ pyproject.toml | 3 + tests/js/graphdoc.test.mjs | 40 +++++++ tests/js/sync.test.mjs | 79 +++++++++++++ tests/python/conftest.py | 21 ++++ tests/python/helpers.py | 199 +++++++++++++++++++++++++++++++++ tests/python/test_graphdoc.py | 150 +++++++++++++++++++++++++ tests/python/test_server.py | 199 +++++++++++++++++++++++++++++++++ tests/python/test_store.py | 157 ++++++++++++++++++++++++++ tests/python/test_workspace.py | 133 ++++++++++++++++++++++ 14 files changed, 1055 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 docs/development.md create mode 100644 tests/js/graphdoc.test.mjs create mode 100644 tests/js/sync.test.mjs create mode 100644 tests/python/conftest.py create mode 100644 tests/python/helpers.py create mode 100644 tests/python/test_graphdoc.py create mode 100644 tests/python/test_server.py create mode 100644 tests/python/test_store.py create mode 100644 tests/python/test_workspace.py diff --git a/.comfyignore b/.comfyignore index 46168c5..26d605e 100644 --- a/.comfyignore +++ b/.comfyignore @@ -2,3 +2,4 @@ docs/ .github/ .gitattributes +tests/ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..11d26a2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Tests +on: + push: + pull_request: + +jobs: + test: + name: Python and JavaScript tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - uses: actions/setup-node@v5 + with: + node-version: "22" + - name: Install test dependencies + run: pip install pytest aiohttp + - name: Python tests + run: python -m pytest -q + - name: JavaScript tests + run: node --test "tests/js/*.test.mjs" diff --git a/README.md b/README.md index e8a83f1..e2a0664 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Behind a reverse proxy, turn on WebSocket support, or nothing will be live. See | [Settings](docs/settings.md) | Panel settings, ComfyUI settings, every key in `config.json` | | [Reverse proxy](docs/reverse-proxy.md) | Nginx Proxy Manager, plain nginx, HTTPS | | [Security](docs/security.md) | How passwords, sessions and keys are protected | +| [Development](docs/development.md) | Running the tests, code layout | | [Changelog](CHANGELOG.md) | What changed, and when | ## Why this exists diff --git a/__init__.py b/__init__.py index 582fce9..cc26dd3 100644 --- a/__init__.py +++ b/__init__.py @@ -1,18 +1,16 @@ import logging import os -import folder_paths -from server import PromptServer - -from .rigshare_core.store import Store -from .rigshare_core.hub import Hub -from .rigshare_core.workspace import Workspace -from .rigshare_core import routes - NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} WEB_DIRECTORY = "./web" +try: + import folder_paths + from server import PromptServer +except ImportError: # imported outside ComfyUI, e.g. by the test runner + folder_paths = PromptServer = None + def data_folder(): return os.environ.get("RIGSHARE_DATA_DIR") or os.path.join(folder_paths.base_path, "rigshare") @@ -27,8 +25,13 @@ def workflows_dir(): return os.path.join(user_root or os.path.join(folder_paths.get_user_directory(), "default"), "workflows") -if hasattr(PromptServer, "instance"): +if PromptServer is not None and hasattr(PromptServer, "instance"): try: + from .rigshare_core.store import Store + from .rigshare_core.hub import Hub + from .rigshare_core.workspace import Workspace + from .rigshare_core import routes + folder = data_folder() os.makedirs(folder, exist_ok=True) store = Store(folder) diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..a82989c --- /dev/null +++ b/docs/development.md @@ -0,0 +1,37 @@ +# Development + +## Tests + +RigShare has two test suites. Neither needs ComfyUI. + +```bash +pip install pytest aiohttp +python -m pytest -q +node --test "tests/js/*.test.mjs" +``` + +| Suite | What it covers | +|---|---| +| `tests/python/test_store.py` | Accounts, first-run setup, password hashing, JWT sessions (tampering, `alg: none`, expiry, refresh, revocation), API keys, snapshots | +| `tests/python/test_graphdoc.py` | Workflow patches, link repair, id renumbering for simultaneous edits, and parity with the JavaScript implementation | +| `tests/python/test_workspace.py` | Folder rules: path normalisation (double encoding, `../`), private and shared folders, access lists, moves, deletes, browsing | +| `tests/python/test_server.py` | End to end against a real aiohttp server: login gate, cookies, API keys, permissions, Manager gating, template filtering, live rooms, access lists, folders through ComfyUI's userdata API | +| `tests/js/graphdoc.test.mjs` | The client-side patch logic | +| `tests/js/sync.test.mjs` | Client diffs, and the regression test for opening a file overwriting the previous tab's shared workflow | + +The server tests use a small stand-in for ComfyUI's `/userdata` API (`tests/python/helpers.py`), so they run anywhere Python does. GitHub Actions runs both suites on every push and pull request. + +`tests/` is excluded from the Comfy Registry package by `.comfyignore`. + +## Layout + +| Path | | +|---|---| +| `__init__.py` | ComfyUI entry point: wires the store, hub, workspace and routes | +| `rigshare_core/store.py` | Accounts, sessions, API keys, config, rooms and snapshots on disk | +| `rigshare_core/jwt.py` | HS256 tokens | +| `rigshare_core/hub.py` | Websocket hub: presence, chat, cursors, live rooms | +| `rigshare_core/graphdoc.py` | Workflow patches (mirrored by `web/rigshare/graphdoc.js`) | +| `rigshare_core/workspace.py` | Private and shared folders | +| `rigshare_core/routes.py` | HTTP API and the middleware that enforces login and permissions | +| `web/rigshare/` | The frontend extension | diff --git a/pyproject.toml b/pyproject.toml index ca5895c..0913a60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,3 +16,6 @@ Documentation = "https://github.com/xiaodoudou/ComfyUI-RigShare#documentation" PublisherId = "xiaodoudou" DisplayName = "ComfyUI RigShare" Icon = "https://raw.githubusercontent.com/xiaodoudou/ComfyUI-RigShare/master/web/rigshare/assets/icon.png" + +[tool.pytest.ini_options] +testpaths = ["tests/python"] diff --git a/tests/js/graphdoc.test.mjs b/tests/js/graphdoc.test.mjs new file mode 100644 index 0000000..6ba3196 --- /dev/null +++ b/tests/js/graphdoc.test.mjs @@ -0,0 +1,40 @@ +// Workflow patch logic on the client (mirror of rigshare_core/graphdoc.py). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { applyPatch, reconcile, linkId } from "../../web/rigshare/graphdoc.js"; + +const node = (id) => ({ id, type: "T", inputs: [{ name: "a", link: null }, { name: "b", link: null }], outputs: [{ name: "o", links: [] }] }); +const doc = (links = []) => reconcile({ last_node_id: 3, last_link_id: links.length, nodes: [1, 2, 3].map(node), links, groups: [], extra: {} }); + +test("upserts and removes nodes without mutating the input", () => { + const base = doc(); + const moved = { ...structuredClone(base.nodes[0]), pos: [5, 5] }; + const out = applyPatch(base, { nodes: [moved, node(4)], removed: [2] }); + assert.deepEqual(out.nodes.map((n) => n.id), [1, 3, 4]); + assert.equal(base.nodes.length, 3); +}); + +test("links merge by id instead of replacing each other", () => { + const out = applyPatch(doc([[1, 1, 0, 2, 0, "X"]]), { links: { upsert: [[2, 3, 0, 2, 1, "X"]] } }); + assert.deepEqual(out.links.map(linkId).sort(), [1, 2]); + assert.deepEqual(out.nodes[1].inputs.map((s) => s.link), [1, 2]); +}); + +test("last ids never go backwards", () => { + const base = { ...doc(), last_node_id: 10 }; + assert.equal(applyPatch(base, { set: { last_node_id: 4 } }).last_node_id, 10); +}); + +test("reconcile drops dangling links and keeps the newest link per input", () => { + const d = doc(); + d.links = [[1, 1, 0, 2, 0, "X"], [2, 3, 0, 2, 0, "X"], [3, 9, 0, 2, 1, "X"]]; + reconcile(d); + assert.deepEqual(d.links.map(linkId), [2]); + assert.equal(d.nodes[1].inputs[0].link, 2); + assert.deepEqual(d.nodes[2].outputs[0].links, [2]); +}); + +test("full replaces the document", () => { + const out = applyPatch(doc(), { full: { nodes: [], links: [] } }); + assert.deepEqual(out, { nodes: [], links: [] }); +}); diff --git a/tests/js/sync.test.mjs b/tests/js/sync.test.mjs new file mode 100644 index 0000000..f88502e --- /dev/null +++ b/tests/js/sync.test.mjs @@ -0,0 +1,79 @@ +// Client sync: diffs round-trip, and loading a graph never leaks into the old tab's room. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +globalThis.window ??= new EventTarget(); +// RoomSync polls with setInterval; let those timers not keep the test process alive. +const nativeSetInterval = globalThis.setInterval; +globalThis.setInterval = (...args) => { const t = nativeSetInterval(...args); t.unref?.(); return t; }; +globalThis.performance ??= { now: () => Date.now() }; +const { RoomSync, snapshotOf, diffSnapshots } = await import("../../web/rigshare/sync.js"); +const { applyPatch, reconcile } = await import("../../web/rigshare/graphdoc.js"); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const node = (id) => ({ id, type: "T", pos: [0, 0], inputs: [{ name: "a", link: null }, { name: "b", link: null }], outputs: [{ name: "o", links: [] }] }); + +test("diff then apply reproduces the edited workflow", () => { + let seed = 3; + const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; + for (let t = 0; t < 300; t++) { + const base = reconcile({ last_node_id: 5, last_link_id: 2, nodes: [1, 2, 3, 4, 5].map(node), + links: [[1, 1, 0, 2, 0, "X"], [2, 3, 0, 4, 1, "X"]], groups: [{ id: 1, title: "g" }], extra: { a: 1 }, version: 0.4 }); + const cur = structuredClone(base); + if (rnd() < 0.5) cur.nodes[0].pos = [rnd(), rnd()]; + if (rnd() < 0.4) { cur.last_node_id++; cur.nodes.push(node(cur.last_node_id)); } + if (rnd() < 0.4) { cur.last_link_id++; cur.links.push([cur.last_link_id, cur.nodes.at(-1).id, 0, 5, 0, "X"]); } + if (rnd() < 0.3) cur.links = cur.links.filter((l) => l[0] !== 1); + if (rnd() < 0.3) cur.groups[0].title = "renamed"; + if (rnd() < 0.2) cur.extra.b = 2; + if (rnd() < 0.15) cur.nodes = cur.nodes.filter((n) => n.id !== 3); + reconcile(cur); + const patch = diffSnapshots(snapshotOf(base), snapshotOf(cur)); + const out = patch ? applyPatch(base, patch) : base; + assert.equal(diffSnapshots(snapshotOf(out), snapshotOf(cur)), null); + } +}); + +test("opening a file never overwrites the shared workflow of the previous tab", async () => { + const graphA = { nodes: [node(1), node(2)], links: [], extra: {}, version: 0.4 }; + const fromDisk = { nodes: [{ id: 1, type: "ReActorFaceSwap" }, { id: 2, type: "LoadImage" }, { id: 3, type: "SaveImage" }], links: [], extra: {}, version: 0.4 }; + let current = structuredClone(graphA); + const tabA = { path: "workflows/shared.json", filename: "shared.json", isTemporary: false }; + const store = { activeWorkflow: tabA, openWorkflows: [tabA] }; + const sent = []; + const client = Object.assign(new EventTarget(), { + online: true, perms: { edit: true }, rooms: [], users: [], + on(type, fn) { this.addEventListener(type, (e) => fn(e.detail)); }, + emit(type, detail) { this.dispatchEvent(new CustomEvent(type, { detail })); }, + send(m) { sent.push(m); return true; }, + }); + const app = { + extensionManager: { workflow: store }, canvas: null, + rootGraph: { serialize: () => structuredClone(current), extra: {}, getNodeById: () => null }, + // Like ComfyUI: swap the graph, yield, then switch to a new tab. + async loadGraphData(data, clean, restore, target) { + current = structuredClone(data); + await sleep(30); + if (typeof target === "string" || target == null) { + const tab = { path: `workflows/${target}`, filename: target, isTemporary: true }; + store.openWorkflows.push(tab); + store.activeWorkflow = tab; + } + }, + }; + const api = new EventTarget(); + const sync = new RoomSync(app, api, client); + sync.overrides[tabA.path] = true; + sync.tick(); + client.emit("room", { room: `file:${tabA.path}`, version: 1, doc: graphA, role: "edit", manage: true }); + await sleep(400); + sent.length = 0; + + const loading = app.loadGraphData(fromDisk, true, true, "face swap.json"); + for (let i = 0; i < 5; i++) { api.dispatchEvent(new Event("graphChanged")); sync.checkLocal(); sync.tick(); await sleep(5); } + await loading; + for (let i = 0; i < 5; i++) { api.dispatchEvent(new Event("graphChanged")); sync.tick(); await sleep(20); } + + const leaked = sent.filter((m) => m.type === "graph" && m.room === `file:${tabA.path}`); + assert.equal(leaked.length, 0, "the previous tab's room must not receive the new graph"); +}); diff --git a/tests/python/conftest.py b/tests/python/conftest.py new file mode 100644 index 0000000..565d1c4 --- /dev/null +++ b/tests/python/conftest.py @@ -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)) diff --git a/tests/python/helpers.py b/tests/python/helpers.py new file mode 100644 index 0000000..c046ab0 --- /dev/null +++ b/tests/python/helpers.py @@ -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() diff --git a/tests/python/test_graphdoc.py b/tests/python/test_graphdoc.py new file mode 100644 index 0000000..495edf2 --- /dev/null +++ b/tests/python/test_graphdoc.py @@ -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}" diff --git a/tests/python/test_server.py b/tests/python/test_server.py new file mode 100644 index 0000000..5f62cab --- /dev/null +++ b/tests/python/test_server.py @@ -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()) diff --git a/tests/python/test_store.py b/tests/python/test_store.py new file mode 100644 index 0000000..ed265cf --- /dev/null +++ b/tests/python/test_store.py @@ -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) diff --git a/tests/python/test_workspace.py b/tests/python/test_workspace.py new file mode 100644 index 0000000..58db88f --- /dev/null +++ b/tests/python/test_workspace.py @@ -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"