create-workframe 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/create-workframe.js +2 -2
- package/bin/workframe.js +130 -2
- package/docs/security.md +85 -0
- package/package.json +1 -1
- package/scripts/bundle-workframe-ui.mjs +9 -0
- package/scripts/sync-canonical-to-package.mjs +9 -0
- package/scripts/verify-public-deploy.sh +121 -3
- package/workframe-api/action_proxy.py +23 -17
- package/workframe-api/activity_feed.py +798 -0
- package/workframe-api/api_meta.py +157 -0
- package/workframe-api/auth_gate.py +566 -0
- package/workframe-api/avatar_registry.py +343 -0
- package/workframe-api/broker_audit.py +164 -0
- package/workframe-api/cell_authority.py +231 -0
- package/workframe-api/chat_bind.py +319 -0
- package/workframe-api/chat_sessions.py +509 -0
- package/workframe-api/chat_stream.py +694 -0
- package/workframe-api/cleanup_dogfood_smoke.py +252 -0
- package/workframe-api/credential_broker.py +162 -0
- package/workframe-api/credential_resolve.py +202 -0
- package/workframe-api/credential_store.py +245 -0
- package/workframe-api/crew_registry.py +250 -0
- package/workframe-api/db_schema.py +755 -0
- package/workframe-api/docker_gateway.py +166 -0
- package/workframe-api/doctor_runtime.py +109 -0
- package/workframe-api/domain/__init__.py +47 -0
- package/workframe-api/domain/entities.py +252 -0
- package/workframe-api/domain/schema/workframe-domain.schema.json +278 -0
- package/workframe-api/egress_policy.py +42 -0
- package/workframe-api/handler_modules/__init__.py +17 -0
- package/workframe-api/handler_modules/handler_admin.py +542 -0
- package/workframe-api/handler_modules/handler_auth.py +512 -0
- package/workframe-api/handler_modules/handler_chat.py +641 -0
- package/workframe-api/handler_modules/handler_install.py +178 -0
- package/workframe-api/handler_modules/handler_provider.py +325 -0
- package/workframe-api/handler_modules/handler_workspace.py +1420 -0
- package/workframe-api/health_monitor.py +80 -0
- package/workframe-api/hermes_admin.py +756 -0
- package/workframe-api/hermes_profiles.py +1328 -0
- package/workframe-api/install_api.py +123 -0
- package/workframe-api/kanban_cron.py +473 -0
- package/workframe-api/lane_bindings.py +423 -0
- package/workframe-api/llm_proxy.py +17 -43
- package/workframe-api/mention_helpers.py +180 -0
- package/workframe-api/mention_invoke.py +431 -0
- package/workframe-api/model_surface.py +1139 -0
- package/workframe-api/oauth_pending.py +85 -0
- package/workframe-api/oauth_redirect.py +381 -0
- package/workframe-api/openrouter_catalog.py +4 -4
- package/workframe-api/package.json +2 -2
- package/workframe-api/profile_api_lifecycle.py +66 -0
- package/workframe-api/profile_config_yaml.py +126 -0
- package/workframe-api/profile_gateway.py +436 -0
- package/workframe-api/provider_bindings.py +673 -0
- package/workframe-api/provider_bootstrap.py +347 -0
- package/workframe-api/provider_catalog.py +180 -0
- package/workframe-api/rooms.py +1613 -0
- package/workframe-api/route_registry.py +470 -0
- package/workframe-api/run-typecheck.mjs +26 -0
- package/workframe-api/run_authority.py +287 -0
- package/workframe-api/run_ledger.py +470 -0
- package/workframe-api/run_surface_wiring.py +344 -0
- package/workframe-api/runtime_cohort.py +752 -0
- package/workframe-api/runtime_tokens.py +127 -0
- package/workframe-api/server.py +1376 -19305
- package/workframe-api/snapshot_feed.py +49 -0
- package/workframe-api/stack_config.py +33 -1
- package/workframe-api/supervisor_client.py +154 -0
- package/workframe-api/test_api_meta_build_stamp.py +34 -0
- package/workframe-api/test_cell_authority.py +79 -0
- package/workframe-api/test_chat_bind.py +48 -0
- package/workframe-api/test_credential_lease_matrix.py +171 -0
- package/workframe-api/test_credential_resolve.py +51 -0
- package/workframe-api/test_credential_store.py +27 -0
- package/workframe-api/test_dogfood_flows.py +346 -0
- package/workframe-api/test_domain_entities.py +152 -0
- package/workframe-api/test_exception_hygiene.py +12 -0
- package/workframe-api/test_hermes_admin.py +72 -0
- package/workframe-api/test_install_wizard_resume.py +78 -0
- package/workframe-api/test_local_bootstrap.py +67 -0
- package/workframe-api/test_mention_helpers.py +35 -0
- package/workframe-api/test_mention_invoke.py +26 -0
- package/workframe-api/test_model_surface_consistency.py +117 -0
- package/workframe-api/test_oauth_pending.py +41 -0
- package/workframe-api/test_profile_model_yaml.py +99 -0
- package/workframe-api/test_provider_bindings.py +52 -0
- package/workframe-api/test_provider_catalog.py +28 -0
- package/workframe-api/test_public_routes.py +41 -0
- package/workframe-api/test_route_registry.py +245 -0
- package/workframe-api/test_run_authority.py +112 -0
- package/workframe-api/test_run_ledger.py +157 -0
- package/workframe-api/test_run_surface_wiring.py +130 -0
- package/workframe-api/test_runtime_cohort.py +85 -0
- package/workframe-api/test_secure_mode_docker_boundary.py +39 -0
- package/workframe-api/test_server_reexports.py +99 -0
- package/workframe-api/test_stack_config_install_smtp.py +7 -0
- package/workframe-api/time-bind-steps.py +75 -0
- package/workframe-api/time-perf.py +81 -0
- package/workframe-api/time-warm.py +107 -0
- package/workframe-api/turn_credentials.py +28 -13
- package/workframe-api/turn_overlay.py +715 -0
- package/workframe-api/updates.py +16 -1
- package/workframe-api/user_prefs.py +387 -0
- package/workframe-api/wf032_extract_remaining_handlers.py +417 -0
- package/workframe-api/workspace_bootstrap.py +536 -0
- package/workframe-api/workspace_files.py +344 -0
- package/workframe-api/workspace_messaging.py +206 -0
- package/workframe-api/zk_auth.py +3 -2
- package/workframe-supervisor/server.py +10 -1
- package/workframe-supervisor/test_supervisor_negative.py +75 -0
- package/workframe-ui/public/assets/{arc-DjpS9a2b.js → arc-aUUffclm.js} +1 -1
- package/workframe-ui/public/assets/architecture-7EHR7CIX-BSUP4S8J.js +1 -0
- package/workframe-ui/public/assets/{architectureDiagram-3BPJPVTR-puCKmKHV.js → architectureDiagram-3BPJPVTR-DU6oaqIb.js} +1 -1
- package/workframe-ui/public/assets/{blockDiagram-GPEHLZMM-C2P0g-eH.js → blockDiagram-GPEHLZMM-D2p8BU6-.js} +1 -1
- package/workframe-ui/public/assets/{c4Diagram-AAUBKEIU-DdvYEITH.js → c4Diagram-AAUBKEIU-ouwyPmTJ.js} +1 -1
- package/workframe-ui/public/assets/channel-DOunZZ0X.js +1 -0
- package/workframe-ui/public/assets/{chunk-2J33WTMH-Cxe8QsEF.js → chunk-2J33WTMH-DsE7U5dj.js} +1 -1
- package/workframe-ui/public/assets/{chunk-3OPIFGDE-DPNyTwR3.js → chunk-3OPIFGDE-DM0kkZ9h.js} +1 -1
- package/workframe-ui/public/assets/{chunk-4BX2VUAB-XKG9gfRN.js → chunk-4BX2VUAB-CQ8mAyXp.js} +1 -1
- package/workframe-ui/public/assets/{chunk-55IACEB6-oPMTulgb.js → chunk-55IACEB6-DN1BDtbH.js} +1 -1
- package/workframe-ui/public/assets/{chunk-5ZQYHXKU-BWwXvpa_.js → chunk-5ZQYHXKU-BzK2KqOt.js} +1 -1
- package/workframe-ui/public/assets/{chunk-727SXJPM-Cml2twO_.js → chunk-727SXJPM-C3AxtOi9.js} +1 -1
- package/workframe-ui/public/assets/{chunk-AQP2D5EJ-DzrRBhQu.js → chunk-AQP2D5EJ-4mVwEFuD.js} +1 -1
- package/workframe-ui/public/assets/{chunk-BSJP7CBP-EZ7STU6h.js → chunk-BSJP7CBP-23kN2q0A.js} +1 -1
- package/workframe-ui/public/assets/{chunk-CSCIHK7Q-BGTQzWYw.js → chunk-CSCIHK7Q-CPBKVW-L.js} +2 -2
- package/workframe-ui/public/assets/{chunk-FMBD7UC4-Cm4stfRa.js → chunk-FMBD7UC4-CKqcYq7K.js} +1 -1
- package/workframe-ui/public/assets/{chunk-KSCS5N6A-exbMijjy.js → chunk-KSCS5N6A-JRQnhxQE.js} +1 -1
- package/workframe-ui/public/assets/{chunk-L5ZTLDWV-C75kbQwN.js → chunk-L5ZTLDWV-BLZrdIwa.js} +1 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-DisG0XJT.js +2 -0
- package/workframe-ui/public/assets/{chunk-ND2GUHAM-D84zyIu2.js → chunk-ND2GUHAM-ClAOxArS.js} +1 -1
- package/workframe-ui/public/assets/{chunk-NZK2D7GU-CUNNo1bj.js → chunk-NZK2D7GU-XUE1aNkm.js} +1 -1
- package/workframe-ui/public/assets/{chunk-O5CBEL6O-DN6WEIh-.js → chunk-O5CBEL6O-DDN-Ifon.js} +1 -1
- package/workframe-ui/public/assets/chunk-QZHKN3VN-EVQ0VMd5.js +1 -0
- package/workframe-ui/public/assets/chunk-WU5MYG2G-k_-ZsLOS.js +1 -0
- package/workframe-ui/public/assets/{chunk-XPW4576I-Ct5VQv2D.js → chunk-XPW4576I-B4_iYZ6r.js} +1 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-W9WVQsby.js +1 -0
- package/workframe-ui/public/assets/{cose-bilkent-S5V4N54A-Bj4W5oMk.js → cose-bilkent-S5V4N54A-Cy1HhS7j.js} +1 -1
- package/workframe-ui/public/assets/{dagre-BM42HDAG-DAjHBkAy.js → dagre-BM42HDAG-Bjal81Ie.js} +1 -1
- package/workframe-ui/public/assets/{diagram-2AECGRRQ-Ch2gGFPG.js → diagram-2AECGRRQ-v5Gdvzp_.js} +1 -1
- package/workframe-ui/public/assets/{diagram-5GNKFQAL-BzIIvgYH.js → diagram-5GNKFQAL-DuyRdfmY.js} +1 -1
- package/workframe-ui/public/assets/{diagram-KO2AKTUF-Df0Q8nv6.js → diagram-KO2AKTUF-WwWCGbIW.js} +1 -1
- package/workframe-ui/public/assets/{diagram-LMA3HP47-D-G3ekmF.js → diagram-LMA3HP47-DTYwZALT.js} +1 -1
- package/workframe-ui/public/assets/{diagram-OG6HWLK6-TBHhT9f5.js → diagram-OG6HWLK6-B46kBIqE.js} +1 -1
- package/workframe-ui/public/assets/{dist-CPbiDkEZ.js → dist-mQWmB3z6.js} +1 -1
- package/workframe-ui/public/assets/{erDiagram-TEJ5UH35-YxkUmLLV.js → erDiagram-TEJ5UH35-__40xO-y.js} +1 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-Cm1uRTxU.js +1 -0
- package/workframe-ui/public/assets/{flowDiagram-I6XJVG4X-kfZ7-RSd.js → flowDiagram-I6XJVG4X-Cwj6dVUz.js} +1 -1
- package/workframe-ui/public/assets/{ganttDiagram-6RSMTGT7-DmTx3hzR.js → ganttDiagram-6RSMTGT7-D_IOSwAS.js} +1 -1
- package/workframe-ui/public/assets/{gitGraph-WXDBUCRP-BXa0ewp3.js → gitGraph-WXDBUCRP-C1vGecee.js} +1 -1
- package/workframe-ui/public/assets/{gitGraphDiagram-PVQCEYII-CQr2qMxU.js → gitGraphDiagram-PVQCEYII-DMTxFJxn.js} +1 -1
- package/workframe-ui/public/assets/index-BdiM4-lI.js +130 -0
- package/workframe-ui/public/assets/index-Fnad47vV.css +1 -0
- package/workframe-ui/public/assets/{info-J43DQDTF-t-Qlx8zv.js → info-J43DQDTF-CwjmyPmD.js} +1 -1
- package/workframe-ui/public/assets/{infoDiagram-5YYISTIA-CzkYofPx.js → infoDiagram-5YYISTIA-BGzh7ZL2.js} +1 -1
- package/workframe-ui/public/assets/{ishikawaDiagram-YF4QCWOH-djK3Oxgb.js → ishikawaDiagram-YF4QCWOH-BLXdnR_5.js} +1 -1
- package/workframe-ui/public/assets/{journeyDiagram-JHISSGLW-CWxYXxdF.js → journeyDiagram-JHISSGLW-BK3behMe.js} +1 -1
- package/workframe-ui/public/assets/{kanban-definition-UN3LZRKU-D7leCAD9.js → kanban-definition-UN3LZRKU-Bx172cCl.js} +1 -1
- package/workframe-ui/public/assets/{line-BSFj2OC5.js → line-BTCEHb7l.js} +1 -1
- package/workframe-ui/public/assets/{linear-pfPwnu-o.js → linear-CkbydpnK.js} +1 -1
- package/workframe-ui/public/assets/{mermaid-parser.core-Dm2L9Ib_.js → mermaid-parser.core-DvrtArpk.js} +2 -2
- package/workframe-ui/public/assets/{mermaid.core-Bevx9bif.js → mermaid.core-yj_1x_qj.js} +3 -3
- package/workframe-ui/public/assets/{mindmap-definition-RKZ34NQL-UkjDssmP.js → mindmap-definition-RKZ34NQL-BXbMltQ0.js} +1 -1
- package/workframe-ui/public/assets/{packet-YPE3B663-JO2ezH3_.js → packet-YPE3B663-9pVCBQ7S.js} +1 -1
- package/workframe-ui/public/assets/{pie-LRSECV5Y-DdMFsfR8.js → pie-LRSECV5Y-CdqoWesP.js} +1 -1
- package/workframe-ui/public/assets/{pieDiagram-4H26LBE5-CEPHFBU1.js → pieDiagram-4H26LBE5-kGs-VfXd.js} +1 -1
- package/workframe-ui/public/assets/{quadrantDiagram-W4KKPZXB-_RF9_86k.js → quadrantDiagram-W4KKPZXB-D9D8gQw5.js} +1 -1
- package/workframe-ui/public/assets/{radar-GUYGQ44K-Cb-YfdaA.js → radar-GUYGQ44K-d4zMZpQS.js} +1 -1
- package/workframe-ui/public/assets/{requirementDiagram-4Y6WPE33-DBVOxmBn.js → requirementDiagram-4Y6WPE33-BI7EQ9zc.js} +1 -1
- package/workframe-ui/public/assets/{sankeyDiagram-5OEKKPKP-BTpxsGUA.js → sankeyDiagram-5OEKKPKP-Bcz19jQB.js} +1 -1
- package/workframe-ui/public/assets/{sequenceDiagram-3UESZ5HK-DHP_Bq3l.js → sequenceDiagram-3UESZ5HK-iQiCEfYp.js} +1 -1
- package/workframe-ui/public/assets/{src-CY_By-r7.js → src-D6mvlP96.js} +1 -1
- package/workframe-ui/public/assets/{stateDiagram-AJRCARHV-BVtp_LPR.js → stateDiagram-AJRCARHV-CxaTXs02.js} +1 -1
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CXikj9xi.js +1 -0
- package/workframe-ui/public/assets/{timeline-definition-PNZ67QCA-D38HJhop.js → timeline-definition-PNZ67QCA-Bej-EAnf.js} +1 -1
- package/workframe-ui/public/assets/{treeView-BLDUP644-CFCG3Sc_.js → treeView-BLDUP644-C7PnnzwX.js} +1 -1
- package/workframe-ui/public/assets/{treemap-LRROVOQU-BeiMs_dF.js → treemap-LRROVOQU-CqISLmkO.js} +1 -1
- package/workframe-ui/public/assets/{vennDiagram-CIIHVFJN-CKMeT6PW.js → vennDiagram-CIIHVFJN-eJMTTEW-.js} +1 -1
- package/workframe-ui/public/assets/{wardley-L42UT6IY-BljDy8ph.js → wardley-L42UT6IY-D4sMroBF.js} +1 -1
- package/workframe-ui/public/assets/{wardleyDiagram-YWT4CUSO-DEE8D94J.js → wardleyDiagram-YWT4CUSO-DC3DCUs7.js} +1 -1
- package/workframe-ui/public/assets/{xychartDiagram-2RQKCTM6-BRaAWV_g.js → xychartDiagram-2RQKCTM6-BeMaM6Aq.js} +1 -1
- package/workframe-ui/public/index.html +7 -6
- package/workframe-ui/public/workframe-build.json +5 -0
- package/workframe-ui/public/assets/architecture-7EHR7CIX-CvhbXEvt.js +0 -1
- package/workframe-ui/public/assets/channel-D63s_yN9.js +0 -1
- package/workframe-ui/public/assets/chunk-LZXEDZCA-CsRdBqUB.js +0 -2
- package/workframe-ui/public/assets/chunk-QZHKN3VN-Dj_Yxhgo.js +0 -1
- package/workframe-ui/public/assets/chunk-WU5MYG2G-AFchshb6.js +0 -1
- package/workframe-ui/public/assets/classDiagram-4FO5ZUOK-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/classDiagram-v2-Q7XG4LA2-Opj8t01I.js +0 -1
- package/workframe-ui/public/assets/eventmodeling-FCH6USID-LQOYL50n.js +0 -1
- package/workframe-ui/public/assets/index-B08aShJy.css +0 -1
- package/workframe-ui/public/assets/index-DCF3W3G_.js +0 -129
- package/workframe-ui/public/assets/stateDiagram-v2-BHNVJYJU-CvJhS_BJ.js +0 -1
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Route registry audit (WF-037).
|
|
3
|
+
|
|
4
|
+
Run: python services/workframe-api/test_route_registry.py
|
|
5
|
+
"""
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parent
|
|
11
|
+
sys.path.insert(0, str(ROOT))
|
|
12
|
+
|
|
13
|
+
import route_registry # noqa: E402
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _handler_source_text() -> str:
|
|
17
|
+
"""server.Handler bodies plus extracted handler_modules mixins (WF-032)."""
|
|
18
|
+
parts = [(ROOT / "server.py").read_text(encoding="utf-8")]
|
|
19
|
+
mods = ROOT / "handler_modules"
|
|
20
|
+
if mods.is_dir():
|
|
21
|
+
for path in sorted(mods.glob("*.py")):
|
|
22
|
+
if path.name == "__init__.py":
|
|
23
|
+
continue
|
|
24
|
+
parts.append(path.read_text(encoding="utf-8"))
|
|
25
|
+
return "\n".join(parts)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _sample_path_from_label(label: str, pattern: re.Pattern[str] | None = None) -> str:
|
|
29
|
+
sample = (
|
|
30
|
+
label.replace("{id}", "sample-id")
|
|
31
|
+
.replace("{slug}", "sample-slug")
|
|
32
|
+
.replace("{token}", "sample-token")
|
|
33
|
+
.replace("{provider}", "github")
|
|
34
|
+
.replace("{cid}", "cred-1")
|
|
35
|
+
.replace("{path}", "docs/readme.md")
|
|
36
|
+
.replace("{kind}", "og")
|
|
37
|
+
)
|
|
38
|
+
if pattern is not None and not pattern.fullmatch(sample):
|
|
39
|
+
raise AssertionError(f"sample path does not match pattern: {sample} {label}")
|
|
40
|
+
return sample
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Auth level coverage (4-value enum)
|
|
44
|
+
assert route_registry.resolve_auth_level("GET", "/api/health") == route_registry.AuthLevel.PUBLIC
|
|
45
|
+
assert route_registry.resolve_auth_level("GET", "/api/meta") == route_registry.AuthLevel.PUBLIC
|
|
46
|
+
assert route_registry.resolve_auth_level("GET", "/api/snapshot") == route_registry.AuthLevel.SESSION
|
|
47
|
+
assert route_registry.resolve_auth_level("POST", "/api/auth/start") == route_registry.AuthLevel.PUBLIC
|
|
48
|
+
assert route_registry.resolve_auth_level("GET", "/api/me") == route_registry.AuthLevel.SESSION
|
|
49
|
+
assert route_registry.resolve_auth_level("GET", "/api/admin/vault/status") == route_registry.AuthLevel.ROLE_OWNER_ADMIN
|
|
50
|
+
assert route_registry.resolve_auth_level("GET", "/api/workspace/default/rooms") == route_registry.AuthLevel.SESSION
|
|
51
|
+
|
|
52
|
+
# Unknown /api paths are unregistered → None (404 at dispatch, not auth-gated)
|
|
53
|
+
assert route_registry.resolve_auth_level("GET", "/api/not-a-real-handler") is None
|
|
54
|
+
assert route_registry.authorize_request(
|
|
55
|
+
"GET",
|
|
56
|
+
"/api/not-a-real-handler",
|
|
57
|
+
"",
|
|
58
|
+
deployment_mode="trusted_team",
|
|
59
|
+
dev_local_unsafe=False,
|
|
60
|
+
install_window_open=False,
|
|
61
|
+
validate_session=lambda _s: None,
|
|
62
|
+
attach_user=lambda _u: None,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Registered session route without session → 401
|
|
66
|
+
assert not route_registry.authorize_request(
|
|
67
|
+
"GET",
|
|
68
|
+
"/api/me",
|
|
69
|
+
"",
|
|
70
|
+
deployment_mode="trusted_team",
|
|
71
|
+
dev_local_unsafe=False,
|
|
72
|
+
install_window_open=False,
|
|
73
|
+
validate_session=lambda _s: None,
|
|
74
|
+
attach_user=lambda _u: None,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# ROUTES table: dispatched routes have handlers on server.Handler (+ handler_modules)
|
|
78
|
+
handler_src = _handler_source_text()
|
|
79
|
+
missing_handlers: list[str] = []
|
|
80
|
+
for route in route_registry.ROUTES:
|
|
81
|
+
if not route.handler:
|
|
82
|
+
continue
|
|
83
|
+
if f"def {route.handler}(" not in handler_src:
|
|
84
|
+
missing_handlers.append(f"{route.method} {route.path} -> {route.handler}")
|
|
85
|
+
assert not missing_handlers, f"missing handler methods: {missing_handlers}"
|
|
86
|
+
|
|
87
|
+
# Every pattern-backed route has a handler and auth via label-derived sample path
|
|
88
|
+
missing_pattern_handlers: list[str] = []
|
|
89
|
+
missing_pattern_auth: list[str] = []
|
|
90
|
+
for rp in route_registry.ROUTE_PATTERNS:
|
|
91
|
+
if not rp.handler:
|
|
92
|
+
missing_pattern_handlers.append(f"{rp.method} {rp.label}")
|
|
93
|
+
continue
|
|
94
|
+
if f"def {rp.handler}(" not in handler_src:
|
|
95
|
+
missing_pattern_handlers.append(f"{rp.method} {rp.label} -> {rp.handler}")
|
|
96
|
+
sample = _sample_path_from_label(rp.label, rp.pattern)
|
|
97
|
+
if route_registry.lookup_auth(rp.method, sample) is None:
|
|
98
|
+
missing_pattern_auth.append(f"{rp.method} {sample}")
|
|
99
|
+
assert not missing_pattern_handlers, f"missing pattern handlers: {missing_pattern_handlers}"
|
|
100
|
+
assert not missing_pattern_auth, f"missing pattern auth: {missing_pattern_auth}"
|
|
101
|
+
|
|
102
|
+
# Pattern handlers must not re-dispatch with legacy startswith guards
|
|
103
|
+
pattern_method_blocks = re.findall(
|
|
104
|
+
r"(def (_route_pattern_\w+)\([^)]*\)[^:]*:.*?)(?=\n def |\Z)",
|
|
105
|
+
handler_src,
|
|
106
|
+
flags=re.DOTALL,
|
|
107
|
+
)
|
|
108
|
+
legacy_guards: list[str] = []
|
|
109
|
+
for block, name in pattern_method_blocks:
|
|
110
|
+
if 'path.startswith("/api/' in block or "path.startswith('/api/" in block:
|
|
111
|
+
legacy_guards.append(name)
|
|
112
|
+
assert not legacy_guards, f"legacy startswith guards in pattern handlers: {legacy_guards}"
|
|
113
|
+
|
|
114
|
+
# Every if-chain literal /api path must be registered with explicit auth
|
|
115
|
+
server_src = (ROOT / "server.py").read_text(encoding="utf-8")
|
|
116
|
+
handler_paths = set(re.findall(r'if path == "(/api/[^"]+)"', server_src))
|
|
117
|
+
handler_paths |= set(re.findall(r"if path == \'(/api/[^\']+)\'", server_src))
|
|
118
|
+
unregistered: list[str] = []
|
|
119
|
+
for p in sorted(handler_paths):
|
|
120
|
+
if route_registry.lookup_auth("GET", p) is None and route_registry.lookup_auth("POST", p) is None:
|
|
121
|
+
unregistered.append(p)
|
|
122
|
+
assert not unregistered, f"unregistered auth for if-chain paths: {unregistered}"
|
|
123
|
+
|
|
124
|
+
# Pattern-backed routes from startswith / fullmatch in do_GET/do_POST
|
|
125
|
+
_pattern_probes = [
|
|
126
|
+
("GET", "/api/public/branding/og"),
|
|
127
|
+
("GET", "/api/workspace/ws-1/rooms"),
|
|
128
|
+
("GET", "/api/rooms/room-1/messages"),
|
|
129
|
+
("POST", "/api/rooms/room-1/messages/send"),
|
|
130
|
+
("PATCH", "/api/workspace/ws-1"),
|
|
131
|
+
("DELETE", "/api/memory/mem-1"),
|
|
132
|
+
]
|
|
133
|
+
for method, path in _pattern_probes:
|
|
134
|
+
assert route_registry.lookup_auth(method, path) is not None, f"missing pattern auth: {method} {path}"
|
|
135
|
+
|
|
136
|
+
# sessionless_ok data GETs
|
|
137
|
+
snap = route_registry.lookup_auth("GET", "/api/snapshot")
|
|
138
|
+
assert snap is not None and snap.sessionless_ok
|
|
139
|
+
assert not route_registry.authorize_request(
|
|
140
|
+
"GET",
|
|
141
|
+
"/api/agents",
|
|
142
|
+
"",
|
|
143
|
+
deployment_mode="trusted_team",
|
|
144
|
+
dev_local_unsafe=False,
|
|
145
|
+
install_window_open=False,
|
|
146
|
+
validate_session=lambda _s: None,
|
|
147
|
+
attach_user=lambda _u: None,
|
|
148
|
+
)
|
|
149
|
+
assert route_registry.authorize_request(
|
|
150
|
+
"GET",
|
|
151
|
+
"/api/agents",
|
|
152
|
+
"sid-ok",
|
|
153
|
+
deployment_mode="trusted_team",
|
|
154
|
+
dev_local_unsafe=False,
|
|
155
|
+
install_window_open=False,
|
|
156
|
+
validate_session=lambda s: {"user_id": "u1"} if s == "sid-ok" else None,
|
|
157
|
+
attach_user=lambda _u: None,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# owner/admin gate
|
|
161
|
+
assert not route_registry.authorize_request(
|
|
162
|
+
"GET",
|
|
163
|
+
"/api/admin/vault/status",
|
|
164
|
+
"sid-ok",
|
|
165
|
+
deployment_mode="trusted_team",
|
|
166
|
+
dev_local_unsafe=False,
|
|
167
|
+
install_window_open=False,
|
|
168
|
+
validate_session=lambda s: {"user_id": "u1"} if s == "sid-ok" else None,
|
|
169
|
+
attach_user=lambda _u: None,
|
|
170
|
+
get_workspace_role=lambda: "member",
|
|
171
|
+
)
|
|
172
|
+
assert route_registry.authorize_request(
|
|
173
|
+
"GET",
|
|
174
|
+
"/api/admin/vault/status",
|
|
175
|
+
"sid-ok",
|
|
176
|
+
deployment_mode="trusted_team",
|
|
177
|
+
dev_local_unsafe=False,
|
|
178
|
+
install_window_open=False,
|
|
179
|
+
validate_session=lambda s: {"user_id": "u1"} if s == "sid-ok" else None,
|
|
180
|
+
attach_user=lambda _u: None,
|
|
181
|
+
get_workspace_role=lambda: "owner",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
dispatched_get = [r for r in route_registry.ROUTES if r.method == "GET" and r.handler]
|
|
185
|
+
dispatched_post = [r for r in route_registry.ROUTES if r.method == "POST" and r.handler]
|
|
186
|
+
dispatched_patch = [r for r in route_registry.ROUTES if r.method == "PATCH" and r.handler]
|
|
187
|
+
pattern_dispatched = [rp for rp in route_registry.ROUTE_PATTERNS if rp.handler]
|
|
188
|
+
assert len(dispatched_get) >= 50, f"unexpected GET dispatch count: {len(dispatched_get)}"
|
|
189
|
+
assert len(dispatched_post) >= 40, f"unexpected POST dispatch count: {len(dispatched_post)}"
|
|
190
|
+
assert len(dispatched_patch) >= 4, f"unexpected PATCH dispatch count: {len(dispatched_patch)}"
|
|
191
|
+
assert len(pattern_dispatched) >= 60, f"unexpected pattern dispatch count: {len(pattern_dispatched)}"
|
|
192
|
+
assert len(dispatched_get) + len(dispatched_post) + len(dispatched_patch) + len(pattern_dispatched) >= 160
|
|
193
|
+
|
|
194
|
+
class _PostStub:
|
|
195
|
+
def __init__(self) -> None:
|
|
196
|
+
self.called = False
|
|
197
|
+
self.body: dict | None = None
|
|
198
|
+
|
|
199
|
+
def _route_post_auth_start(self, body: dict) -> None:
|
|
200
|
+
self.called = True
|
|
201
|
+
self.body = body
|
|
202
|
+
|
|
203
|
+
_stub = _PostStub()
|
|
204
|
+
assert route_registry.dispatch_post(_stub, "/api/auth/start", {"email": "a@b.c"})
|
|
205
|
+
assert _stub.called and _stub.body == {"email": "a@b.c"}
|
|
206
|
+
assert not route_registry.dispatch_post(_stub, "/api/not-registered", {})
|
|
207
|
+
|
|
208
|
+
class _GetStub:
|
|
209
|
+
def __init__(self) -> None:
|
|
210
|
+
self.called = False
|
|
211
|
+
self.pattern_called = False
|
|
212
|
+
|
|
213
|
+
def _route_get_admin_vault_status(self, qs: dict) -> None:
|
|
214
|
+
self.called = True
|
|
215
|
+
|
|
216
|
+
def _route_pattern_get_workspace_events(self, path: str, qs: dict) -> None:
|
|
217
|
+
self.pattern_called = True
|
|
218
|
+
|
|
219
|
+
_get_stub = _GetStub()
|
|
220
|
+
assert route_registry.dispatch_get(_get_stub, "/api/admin/vault/status", {})
|
|
221
|
+
assert _get_stub.called
|
|
222
|
+
assert route_registry.dispatch_get(_get_stub, "/api/admin/vault/status", {}) # idempotent call ok
|
|
223
|
+
|
|
224
|
+
assert route_registry.dispatch_pattern(
|
|
225
|
+
"GET", _get_stub, "/api/workspace/ws-1/events", qs={},
|
|
226
|
+
) is True
|
|
227
|
+
assert _get_stub.pattern_called
|
|
228
|
+
|
|
229
|
+
class _PatchStub:
|
|
230
|
+
def __init__(self) -> None:
|
|
231
|
+
self.called = False
|
|
232
|
+
|
|
233
|
+
def _route_patch_me(self, body: dict) -> None:
|
|
234
|
+
self.called = True
|
|
235
|
+
|
|
236
|
+
_patch_stub = _PatchStub()
|
|
237
|
+
assert route_registry.dispatch_patch(_patch_stub, "/api/me", {"display_name": "x"})
|
|
238
|
+
assert _patch_stub.called
|
|
239
|
+
|
|
240
|
+
# No legacy frozenset allowlists
|
|
241
|
+
assert not hasattr(route_registry, "GET_ALWAYS_PUBLIC_ROUTES")
|
|
242
|
+
assert not hasattr(route_registry, "GET_SINGLE_USER_PUBLIC_ROUTES")
|
|
243
|
+
assert not hasattr(route_registry, "GET_PUBLIC_ROUTES")
|
|
244
|
+
|
|
245
|
+
print("route registry self-check ok")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""WF-009 RunAuthorityGate self-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
9
|
+
|
|
10
|
+
from domain.entities import ActorType, FundingSource, RunSurface # noqa: E402
|
|
11
|
+
from run_authority import ( # noqa: E402
|
|
12
|
+
DENY_DELEGATION,
|
|
13
|
+
DENY_NO_CREDENTIAL_BYOK,
|
|
14
|
+
DENY_NO_CREDENTIAL_COMPANY,
|
|
15
|
+
DENY_PROVIDER_USER_ONLY,
|
|
16
|
+
RunAuthorityContext,
|
|
17
|
+
RunAuthorityRequest,
|
|
18
|
+
evaluate_run_authority,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _req() -> RunAuthorityRequest:
|
|
23
|
+
return RunAuthorityRequest(
|
|
24
|
+
surface=RunSurface.CHAT,
|
|
25
|
+
actor_type=ActorType.USER,
|
|
26
|
+
actor_id="user-1",
|
|
27
|
+
triggering_user_id="user-1",
|
|
28
|
+
profile_slug="u-user-1-native",
|
|
29
|
+
workspace_id="ws-1",
|
|
30
|
+
provider="openrouter",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_byok_user_cred() -> None:
|
|
35
|
+
decision = evaluate_run_authority(
|
|
36
|
+
_req(),
|
|
37
|
+
RunAuthorityContext(user_has_credential=True),
|
|
38
|
+
run_id="run-1",
|
|
39
|
+
)
|
|
40
|
+
assert decision.allowed
|
|
41
|
+
assert decision.payer_user_id == "user-1"
|
|
42
|
+
assert decision.funding_source == FundingSource.BYOK
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_byok_no_cred_denied() -> None:
|
|
46
|
+
decision = evaluate_run_authority(_req(), RunAuthorityContext())
|
|
47
|
+
assert not decision.allowed
|
|
48
|
+
assert decision.deny_reason == DENY_NO_CREDENTIAL_BYOK
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_company_workspace_cred() -> None:
|
|
52
|
+
decision = evaluate_run_authority(
|
|
53
|
+
_req(),
|
|
54
|
+
RunAuthorityContext(workspace_credential_mode="workspace", workspace_has_credential=True),
|
|
55
|
+
)
|
|
56
|
+
assert decision.allowed
|
|
57
|
+
assert decision.funding_source == FundingSource.COMPANY
|
|
58
|
+
assert decision.credential_scope == "workspace"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_company_user_cred_wins() -> None:
|
|
62
|
+
decision = evaluate_run_authority(
|
|
63
|
+
_req(),
|
|
64
|
+
RunAuthorityContext(
|
|
65
|
+
workspace_credential_mode="workspace",
|
|
66
|
+
user_has_credential=True,
|
|
67
|
+
workspace_has_credential=True,
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
assert decision.allowed
|
|
71
|
+
assert decision.funding_source == FundingSource.BYOK
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_user_only_no_fallback() -> None:
|
|
75
|
+
decision = evaluate_run_authority(
|
|
76
|
+
_req(),
|
|
77
|
+
RunAuthorityContext(provider_user_only=True, workspace_has_credential=True),
|
|
78
|
+
)
|
|
79
|
+
assert not decision.allowed
|
|
80
|
+
assert decision.deny_reason == DENY_PROVIDER_USER_ONLY
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_delegation_grantor() -> None:
|
|
84
|
+
decision = evaluate_run_authority(
|
|
85
|
+
_req(),
|
|
86
|
+
RunAuthorityContext(grantor_has_credential={"owner-1": True}),
|
|
87
|
+
)
|
|
88
|
+
assert decision.allowed
|
|
89
|
+
assert decision.payer_user_id == "owner-1"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_delegation_no_grantor_cred() -> None:
|
|
93
|
+
decision = evaluate_run_authority(
|
|
94
|
+
_req(),
|
|
95
|
+
RunAuthorityContext(
|
|
96
|
+
workspace_credential_mode="workspace",
|
|
97
|
+
grantor_has_credential={"owner-1": False},
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
assert not decision.allowed
|
|
101
|
+
assert decision.deny_reason == DENY_NO_CREDENTIAL_COMPANY
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
test_byok_user_cred()
|
|
106
|
+
test_byok_no_cred_denied()
|
|
107
|
+
test_company_workspace_cred()
|
|
108
|
+
test_company_user_cred_wins()
|
|
109
|
+
test_user_only_no_fallback()
|
|
110
|
+
test_delegation_grantor()
|
|
111
|
+
test_delegation_no_grantor_cred()
|
|
112
|
+
print("test_run_authority: ok")
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""WF-NS-P2 run ledger self-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
13
|
+
|
|
14
|
+
import run_ledger # noqa: E402
|
|
15
|
+
from domain.entities import ( # noqa: E402
|
|
16
|
+
ActorType,
|
|
17
|
+
FundingSource,
|
|
18
|
+
RunStatus,
|
|
19
|
+
RunSurface,
|
|
20
|
+
)
|
|
21
|
+
from run_authority import RunAuthorityDecision, chat_run_request # noqa: E402
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_schema_and_round_trip() -> None:
|
|
25
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
26
|
+
db_path = Path(tmp) / "workframe.db"
|
|
27
|
+
os.environ["WORKFRAME_API_DATA_DIR"] = tmp
|
|
28
|
+
run_ledger.WORKFRAME_DB = db_path
|
|
29
|
+
run_ledger._SCHEMA_READY.clear()
|
|
30
|
+
run_ledger.ensure_schema()
|
|
31
|
+
|
|
32
|
+
conn = sqlite3.connect(str(db_path))
|
|
33
|
+
conn.row_factory = sqlite3.Row
|
|
34
|
+
try:
|
|
35
|
+
decision = RunAuthorityDecision(
|
|
36
|
+
allowed=True,
|
|
37
|
+
deny_reason=None,
|
|
38
|
+
payer_user_id="user-1",
|
|
39
|
+
funding_source=FundingSource.BYOK,
|
|
40
|
+
credential_ref_id=None,
|
|
41
|
+
credential_scope="user",
|
|
42
|
+
grants=(),
|
|
43
|
+
)
|
|
44
|
+
run_ledger.record_authority_decision(
|
|
45
|
+
conn,
|
|
46
|
+
run_id="run-test-1",
|
|
47
|
+
request_surface=RunSurface.CHAT,
|
|
48
|
+
actor_type=ActorType.USER,
|
|
49
|
+
actor_id="user-1",
|
|
50
|
+
triggering_user_id="user-1",
|
|
51
|
+
workspace_id="ws-1",
|
|
52
|
+
agent_id="agent-1",
|
|
53
|
+
runtime_binding_id="bind-1",
|
|
54
|
+
profile_slug="u-user-1-native",
|
|
55
|
+
provider="openrouter",
|
|
56
|
+
room_id="room-1",
|
|
57
|
+
session_id="sess-1",
|
|
58
|
+
decision=decision,
|
|
59
|
+
)
|
|
60
|
+
conn.commit()
|
|
61
|
+
|
|
62
|
+
run = run_ledger.get_run(conn, "run-test-1")
|
|
63
|
+
assert run is not None
|
|
64
|
+
assert run.status == RunStatus.RUNNING
|
|
65
|
+
assert run.funding_source == FundingSource.BYOK
|
|
66
|
+
|
|
67
|
+
run_ledger.complete_run(
|
|
68
|
+
conn,
|
|
69
|
+
"run-test-1",
|
|
70
|
+
model="test/model",
|
|
71
|
+
provider="openrouter",
|
|
72
|
+
funding_source=FundingSource.BYOK,
|
|
73
|
+
payer_user_id="user-1",
|
|
74
|
+
)
|
|
75
|
+
conn.commit()
|
|
76
|
+
|
|
77
|
+
items = conn.execute(
|
|
78
|
+
"SELECT kind, funding_source, amount_usd, receipt_json FROM run_line_items WHERE run_id = ?",
|
|
79
|
+
("run-test-1",),
|
|
80
|
+
).fetchall()
|
|
81
|
+
assert len(items) == 1
|
|
82
|
+
assert str(items[0]["kind"]) == "llm_turn"
|
|
83
|
+
assert items[0]["amount_usd"] is None
|
|
84
|
+
receipt = json.loads(str(items[0]["receipt_json"]))
|
|
85
|
+
assert receipt["run_id"] == "run-test-1"
|
|
86
|
+
assert receipt["payer_user_id"] == "user-1"
|
|
87
|
+
finally:
|
|
88
|
+
conn.close()
|
|
89
|
+
|
|
90
|
+
events = run_ledger.list_run_events_for_room("room-1")
|
|
91
|
+
assert len(events) >= 2
|
|
92
|
+
assert events[0]["source"] == "run_ledger"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_dm_room_chat_run_recorded() -> None:
|
|
96
|
+
"""WF-NS-P2: DM rooms (room_type=direct) share chat_stream run path."""
|
|
97
|
+
dm_room_id = "550e8400-e29b-41d4-a716-446655440099"
|
|
98
|
+
req = chat_run_request(
|
|
99
|
+
triggering_user_id="user-a",
|
|
100
|
+
profile_slug="u-user-a-native",
|
|
101
|
+
workspace_id="ws-1",
|
|
102
|
+
provider="openrouter",
|
|
103
|
+
room_id=dm_room_id,
|
|
104
|
+
)
|
|
105
|
+
assert req.surface == RunSurface.CHAT
|
|
106
|
+
|
|
107
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
108
|
+
db_path = Path(tmp) / "workframe.db"
|
|
109
|
+
os.environ["WORKFRAME_API_DATA_DIR"] = tmp
|
|
110
|
+
run_ledger.WORKFRAME_DB = db_path
|
|
111
|
+
run_ledger._SCHEMA_READY.clear()
|
|
112
|
+
run_ledger.ensure_schema()
|
|
113
|
+
|
|
114
|
+
conn = sqlite3.connect(str(db_path))
|
|
115
|
+
conn.row_factory = sqlite3.Row
|
|
116
|
+
try:
|
|
117
|
+
decision = RunAuthorityDecision(
|
|
118
|
+
allowed=True,
|
|
119
|
+
deny_reason=None,
|
|
120
|
+
payer_user_id="user-a",
|
|
121
|
+
funding_source=FundingSource.BYOK,
|
|
122
|
+
credential_ref_id=None,
|
|
123
|
+
credential_scope="user",
|
|
124
|
+
grants=(),
|
|
125
|
+
)
|
|
126
|
+
run_ledger.record_authority_decision(
|
|
127
|
+
conn,
|
|
128
|
+
run_id="run-dm-1",
|
|
129
|
+
request_surface=req.surface,
|
|
130
|
+
actor_type=req.actor_type,
|
|
131
|
+
actor_id=req.actor_id,
|
|
132
|
+
triggering_user_id="user-a",
|
|
133
|
+
workspace_id="ws-1",
|
|
134
|
+
agent_id=req.agent_id,
|
|
135
|
+
runtime_binding_id=req.runtime_binding_id,
|
|
136
|
+
profile_slug=req.profile_slug,
|
|
137
|
+
provider="openrouter",
|
|
138
|
+
room_id=dm_room_id,
|
|
139
|
+
session_id="sess-dm-1",
|
|
140
|
+
decision=decision,
|
|
141
|
+
)
|
|
142
|
+
conn.commit()
|
|
143
|
+
row = conn.execute(
|
|
144
|
+
"SELECT surface, room_id FROM runs WHERE run_id = ?",
|
|
145
|
+
("run-dm-1",),
|
|
146
|
+
).fetchone()
|
|
147
|
+
assert row is not None
|
|
148
|
+
assert row["surface"] == RunSurface.CHAT.value
|
|
149
|
+
assert row["room_id"] == dm_room_id
|
|
150
|
+
finally:
|
|
151
|
+
conn.close()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
test_schema_and_round_trip()
|
|
156
|
+
test_dm_room_chat_run_recorded()
|
|
157
|
+
print("test_run_ledger: ok")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""WF-NS-P2 run surface wiring self-check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
12
|
+
|
|
13
|
+
import run_ledger # noqa: E402
|
|
14
|
+
import run_surface_wiring # noqa: E402
|
|
15
|
+
import server # noqa: E402
|
|
16
|
+
from domain.entities import ActorType, RunSurface # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _use_tmp_db(tmp: str) -> Path:
|
|
20
|
+
db_path = Path(tmp) / "workframe.db"
|
|
21
|
+
data = Path(tmp)
|
|
22
|
+
os.environ["WORKFRAME_API_DATA_DIR"] = tmp
|
|
23
|
+
server.DATA_DIR = data
|
|
24
|
+
server.AUTH_DB_PATH = data / "auth.db"
|
|
25
|
+
run_ledger.DATA_DIR = data
|
|
26
|
+
run_ledger.WORKFRAME_DB = db_path
|
|
27
|
+
run_ledger._SCHEMA_READY.clear()
|
|
28
|
+
return db_path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _line_items_for_run(db_path: Path, run_id: str) -> list[sqlite3.Row]:
|
|
32
|
+
conn = sqlite3.connect(str(db_path))
|
|
33
|
+
conn.row_factory = sqlite3.Row
|
|
34
|
+
try:
|
|
35
|
+
return conn.execute(
|
|
36
|
+
"SELECT kind, amount_usd, receipt_json FROM run_line_items WHERE run_id = ?",
|
|
37
|
+
(run_id,),
|
|
38
|
+
).fetchall()
|
|
39
|
+
finally:
|
|
40
|
+
conn.close()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_slash_audit_run_no_gate() -> None:
|
|
44
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
45
|
+
_use_tmp_db(tmp)
|
|
46
|
+
assert run_surface_wiring.slash_requires_authority("help") is False
|
|
47
|
+
assert run_surface_wiring.slash_requires_authority("chat") is True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_record_automated_cron() -> None:
|
|
51
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
52
|
+
db_path = _use_tmp_db(tmp)
|
|
53
|
+
run_ledger.ensure_schema()
|
|
54
|
+
result = run_surface_wiring.record_automated_surface_run(
|
|
55
|
+
{
|
|
56
|
+
"surface": "cron",
|
|
57
|
+
"profile_slug": "u-test-dev",
|
|
58
|
+
"triggering_user_id": "user-1",
|
|
59
|
+
"workspace_id": "ws-1",
|
|
60
|
+
"actor_id": "cron:daily",
|
|
61
|
+
"provider": "openrouter",
|
|
62
|
+
"event_type": "cron.triggered",
|
|
63
|
+
"payload": {"job": "daily"},
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
assert result["ok"] is True
|
|
67
|
+
rid = str(result["run_id"])
|
|
68
|
+
conn = sqlite3.connect(str(db_path))
|
|
69
|
+
conn.row_factory = sqlite3.Row
|
|
70
|
+
row = conn.execute("SELECT surface, status FROM runs WHERE run_id = ?", (rid,)).fetchone()
|
|
71
|
+
conn.close()
|
|
72
|
+
assert row is not None
|
|
73
|
+
assert row["surface"] == RunSurface.CRON.value
|
|
74
|
+
assert row["status"] == "completed"
|
|
75
|
+
items = _line_items_for_run(db_path, rid)
|
|
76
|
+
assert len(items) == 1
|
|
77
|
+
assert items[0]["amount_usd"] is None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_finish_surface_run_emits_receipt() -> None:
|
|
81
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
82
|
+
db_path = _use_tmp_db(tmp)
|
|
83
|
+
run_ledger.ensure_schema()
|
|
84
|
+
rid = run_surface_wiring.record_audit_surface_run(
|
|
85
|
+
surface=RunSurface.SLASH,
|
|
86
|
+
actor_type=ActorType.USER,
|
|
87
|
+
actor_id="user-1",
|
|
88
|
+
triggering_user_id="user-1",
|
|
89
|
+
profile_slug="u-test-dev",
|
|
90
|
+
workspace_id="ws-1",
|
|
91
|
+
event_type="slash.command",
|
|
92
|
+
payload={"command": "help"},
|
|
93
|
+
provider="openrouter",
|
|
94
|
+
)
|
|
95
|
+
run_surface_wiring.finish_surface_run(rid, ok=True, detail="ok")
|
|
96
|
+
items = _line_items_for_run(db_path, rid)
|
|
97
|
+
assert len(items) == 1
|
|
98
|
+
assert items[0]["amount_usd"] is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_record_automated_webhook() -> None:
|
|
102
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
103
|
+
_use_tmp_db(tmp)
|
|
104
|
+
run_ledger.ensure_schema()
|
|
105
|
+
result = run_surface_wiring.record_automated_surface_run(
|
|
106
|
+
{
|
|
107
|
+
"surface": "webhook",
|
|
108
|
+
"profile_slug": "u-test-dev",
|
|
109
|
+
"triggering_user_id": "user-1",
|
|
110
|
+
"workspace_id": "ws-1",
|
|
111
|
+
"actor_id": "webhook:github",
|
|
112
|
+
"event_type": "webhook.received",
|
|
113
|
+
"provider": "openrouter",
|
|
114
|
+
"payload": {"source": "github"},
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
assert result["ok"] is True
|
|
118
|
+
assert result["surface"] == RunSurface.WEBHOOK.value
|
|
119
|
+
rid = str(result["run_id"])
|
|
120
|
+
items = _line_items_for_run(Path(tmp) / "workframe.db", rid)
|
|
121
|
+
assert len(items) == 1
|
|
122
|
+
assert items[0]["amount_usd"] is None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
test_slash_audit_run_no_gate()
|
|
127
|
+
test_record_automated_cron()
|
|
128
|
+
test_record_automated_webhook()
|
|
129
|
+
test_finish_surface_run_emits_receipt()
|
|
130
|
+
print("test_run_surface_wiring ok")
|