diff --git a/build/seafile_14.0/package_provenance.py b/build/seafile_14.0/package_provenance.py index cbd167f4..ba00df61 100644 --- a/build/seafile_14.0/package_provenance.py +++ b/build/seafile_14.0/package_provenance.py @@ -17,7 +17,7 @@ COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") REQUIRED_FILES = ( "seafile/bin/seaf-server", "seafile/bin/fileserver", "seafile/bin/seaf-fuse", - "seafile/lib/libcloudfile_acl.so.1", "seahub/cloudfile_extensions/apps.py", + "seahub/cloudfile_extensions/apps.py", "seahub/thirdpart/bin/gunicorn", ) diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index ed77d1e7..e0e48cab 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -208,21 +208,8 @@ services: JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY:-} CF_LOCK_BACKEND: ${CF_LOCK_BACKEND:-cloudfile} CF_ENABLE_CHECKOUT: ${CF_ENABLE_CHECKOUT:-false} - CLOUDFILE_OIDC_ENABLED: ${CLOUDFILE_OIDC_ENABLED:-false} - CLOUDFILE_OIDC_JIT_ENABLED: ${CLOUDFILE_OIDC_JIT_ENABLED:-false} - CLOUDFILE_OIDC_CONFIG_JSON: ${CLOUDFILE_OIDC_CONFIG_JSON:-} - CLOUDFILE_POLICY_CONFIG_JSON: ${CLOUDFILE_POLICY_CONFIG_JSON:-} # Keep explicit policy/transfer settings effective on both app and worker. - CLOUDFILE_AUTHORIZATION_ENABLED: ${CLOUDFILE_AUTHORIZATION_ENABLED:-false} - CLOUDFILE_TRANSFER_ENABLED: ${CLOUDFILE_TRANSFER_ENABLED:-false} - CLOUDFILE_POLICY_WORKER_HOOKS: ${CLOUDFILE_POLICY_WORKER_HOOKS:-false} - CLOUDFILE_LOCAL_EDIT_ENABLED: ${CLOUDFILE_LOCAL_EDIT_ENABLED:-false} # Native administrator policy is initialized automatically from existing SQL settings. - CLOUDFILE_LOCAL_EDIT_INSTANCE: ${CLOUDFILE_LOCAL_EDIT_INSTANCE:-} - CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED: ${CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED:-false} - CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED: ${CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED:-false} - CLOUDFILE_EDITING_ENABLED: ${CLOUDFILE_EDITING_ENABLED:-false} - CLOUDFILE_RESOURCE_SECRET: ${CLOUDFILE_RESOURCE_SECRET:-} CF_ENABLE_LOCAL_APP: ${CF_ENABLE_LOCAL_APP:-false} CF_LIBRARY_STORAGE: ${CF_LIBRARY_STORAGE:-} CF_ENABLE_S3_STORAGE: ${CF_ENABLE_S3_STORAGE:-false} @@ -471,21 +458,8 @@ services: CF_ENABLE_CONVERT_EXPORT: ${CF_ENABLE_CONVERT_EXPORT:-false} CF_LOCK_BACKEND: ${CF_LOCK_BACKEND:-cloudfile} CF_ENABLE_CHECKOUT: ${CF_ENABLE_CHECKOUT:-false} - CLOUDFILE_OIDC_ENABLED: ${CLOUDFILE_OIDC_ENABLED:-false} - CLOUDFILE_OIDC_JIT_ENABLED: ${CLOUDFILE_OIDC_JIT_ENABLED:-false} - CLOUDFILE_OIDC_CONFIG_JSON: ${CLOUDFILE_OIDC_CONFIG_JSON:-} - CLOUDFILE_POLICY_CONFIG_JSON: ${CLOUDFILE_POLICY_CONFIG_JSON:-} # Keep explicit policy/transfer settings effective on both app and worker. - CLOUDFILE_AUTHORIZATION_ENABLED: ${CLOUDFILE_AUTHORIZATION_ENABLED:-false} - CLOUDFILE_TRANSFER_ENABLED: ${CLOUDFILE_TRANSFER_ENABLED:-false} - CLOUDFILE_POLICY_WORKER_HOOKS: ${CLOUDFILE_POLICY_WORKER_HOOKS:-false} - CLOUDFILE_LOCAL_EDIT_ENABLED: ${CLOUDFILE_LOCAL_EDIT_ENABLED:-false} # Native administrator policy is initialized automatically from existing SQL settings. - CLOUDFILE_LOCAL_EDIT_INSTANCE: ${CLOUDFILE_LOCAL_EDIT_INSTANCE:-} - CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED: ${CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED:-false} - CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED: ${CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED:-false} - CLOUDFILE_EDITING_ENABLED: ${CLOUDFILE_EDITING_ENABLED:-false} - CLOUDFILE_RESOURCE_SECRET: ${CLOUDFILE_RESOURCE_SECRET:-} CF_ENABLE_LOCAL_APP: ${CF_ENABLE_LOCAL_APP:-false} CF_LIBRARY_STORAGE: ${CF_LIBRARY_STORAGE:-} CF_ENABLE_S3_STORAGE: ${CF_ENABLE_S3_STORAGE:-false} diff --git a/docs/EXTENSION-POINTS.md b/docs/EXTENSION-POINTS.md index 3bd0a204..383ebb9a 100644 --- a/docs/EXTENSION-POINTS.md +++ b/docs/EXTENSION-POINTS.md @@ -118,7 +118,7 @@ core 依据适用的目录 ACL/共享授予资格(`server/cloudfile-policy.h:4 - 库根与任何未共享路径一律不放行,答案由 `cf_v2_share.c` 向 Hub 的 `/cloudfile/acl/check/` 求证(同 Go fileserver 用的那个内部 JWT 通道,见 `cf_v2_acl_check_json()`),响应里的 `permissions` 逐检查项给出级别; -- 注册仅检查标准 V2 三表 `cf_library_storage`、`cf_dir_acl`、`cf_sso_group_map` 与内部 Hub 通道;不依赖已退役的 `cf_managed_library` 或新增功能开关。未安装 V2、表不齐或通道不可用时保留原生拒绝;实际路径授权仍由 Hub 判定。 +- 注册仅检查共享目录接缝所需的 `cf_library_storage`、`cf_dir_acl`、`cf_sso_group_map`(当前完整扩展模型为五表) 与内部 Hub 通道;不依赖已退役的 `cf_managed_library` 或新增功能开关。未安装 V2、表不齐或通道不可用时保留原生拒绝;实际路径授权仍由 Hub 判定。 - C/Go 的终判不变:Go 写入口仍走 `cfACLPath`/`cfACLFinal`,C 仍走提交事务内的检查。 这条缝**只覆盖 API 写入口**(上传链接、新建目录、重命名、移动、删除、seafdav)。 diff --git a/docs/directory-access-v09-http.md b/docs/directory-access-v09-http.md index e4babcda..ffff98d9 100644 --- a/docs/directory-access-v09-http.md +++ b/docs/directory-access-v09-http.md @@ -1,5 +1,8 @@ # v0.9 目录管理与地址接口 +> 历史记录,非当前 V2 契约。文中旧 managed marker、Resource/Intent、委托与旧授权路由已退役;当前实施以 [权限终判契约](/docs/permission-adjudication-contract.md) 和现行源码为准。 + + 状态:目录管理、发现、地址解析和目录分页 API 已实现,新增目录访问下载/上传预检查与原生 scoped 终判已接通;管理界面已统一编辑 ACL 权限与共享开关。完整发布验收仍需真实身份 HTTP 及制品验证。权限语义见 [目录资格契约](directory-access-v09.md)。 ## 接口 diff --git a/docs/features/local-migration.md b/docs/features/local-migration.md index 327da9dc..61f47a31 100644 --- a/docs/features/local-migration.md +++ b/docs/features/local-migration.md @@ -1,5 +1,8 @@ # 本地文件迁移与大资料库评估 +> 历史记录,非当前 V2 契约。文中旧 managed marker、Resource/Intent、委托与旧授权路由已退役;当前实施以 [权限终判契约](/docs/permission-adjudication-contract.md) 和现行源码为准。 + + ## v0.2 离线管理员单库导入 CF02-07 MVP 复用 v0.1 `a963c177e9e6e5e122c2e088620b2cc2bb63c2b1` 的 diff --git a/docs/permission-adjudication-contract.md b/docs/permission-adjudication-contract.md index 74519836..afe68780 100644 --- a/docs/permission-adjudication-contract.md +++ b/docs/permission-adjudication-contract.md @@ -4,6 +4,8 @@ ## 1. 唯一权威与边界 +默认复用 Seafile CE 原生能力,只在已确认的目录 ACL 与共享目录需求上补足执行接缝。新增表、权限引擎、共享机制或配置开关等设计须先确认,不以修复缺陷为由扩大架构。 + CloudFile 不存在托管库这一概念。每个库均保留 Seafile CE 原生 RepoOwner/SharedRepo/RepoGroup 的库资格,以及同一 `cf_dir_acl` 存储的目录 ACL 和共享目录资格。目录规则是路径级事实,不能使整个库进入另一权限模式。 - 库级资格由 CE 原生权限和可信身份计算,正常情况下 `r/rw`;目录规则默认继承,可在有效作用域内缩权或扩权。 @@ -28,6 +30,7 @@ CloudFile 不存在托管库这一概念。每个库均保留 Seafile CE 原生 - 明确权限拒绝返回 403;授权来源/SQL/内部 HTTP 不可用应在支持的入口返回 503;**禁止授权异常时回退到原生宽权限**。 - Commit 最终事务必须重验可信操作者和实际变更路径,保留 Branch CAS、Checkout Guard 和必要的锁,不能仅依靠上传开始时的检查。 +- 撤权不要求实时生效:允许既有权限缓存存活至配置的 TTL,到期后的新裁决必须重新读取授权来源;来源不可用时不得继续沿用已过期的允许结果。保留既有缓存和失效机制,不增加实时撤权通道。 - 任何共享撤权或成员变动必须通过统一生效权限进入搜索、列表、票据、代理、本地编辑等路径。既有下载票据不能被视为永久内容授权。 ## 4. 禁止恢复的旧设计 @@ -38,4 +41,14 @@ CloudFile 不存在托管库这一概念。每个库均保留 Seafile CE 原生 ## 5. 验收 -正常库根与兄弟目录访问;库 `r` 的局部扩权与库 `rw` 的局部缩权;个人/组织/角色冲突;无库权限共享根;撤权后搜索、下载、上传;原生整库协议的范围证明;ACL 不可用;最终提交并发。源码合入、定向测试、真实 DEV 验收必须分开报告。 +正常库根与兄弟目录访问;库 `r` 的局部扩权与库 `rw` 的局部缩权;个人/组织/角色冲突;无库权限共享根;缓存过期后的撤权与搜索、下载、上传;原生整库协议的范围证明;ACL 不可用;最终提交并发。源码合入、定向测试、真实 DEV 验收必须分开报告。 + +## 6. 旧授权清理边界 + +已批准清理七类旧机制:C 独立 ACL 计算;Python PolicyHost/PolicyCore;目录管理员侧表;旧 ACL/share 写入账本;浏览器/Smart Link 双重裁决;委托、资源票据和 epoch 强刷链;旧授权 URL 别名及其专用契约。只保留当前 CE/V2 所需适配器和身份校验,不新增兼容授权入口。 + +- 旧 C/Go `/cloudfile/read` 及旧 ticket/publish RPC 注销,调用这些接口不再获得服务;不把旧凭据转译为新授权。原生 scoped download token 与现行 V2 编辑凭据保留。 +- 原生 Branch 锁、CAS、Guard 仍保留。C 无用户/路径证明的整库提交仍须通过操作范围门禁;有范围的 V2 编辑由现有 Go 路径处理。 +- Docker 不再生成旧 PolicyHost 设置或安装其共享库。显式启用已退役配置会报错;只清除生成器拥有的旧 gunicorn 区块,保留操作员配置。上线前应移除已退役环境变量。 +- 删除依赖退役表、RPC 或票据的专用旧夹具;当前 V2 用例继续作为验证依据。历史验收记录不计入当前交付。 +- 三仓需配套集成;此前 `partial`/503 语义要求 Hub 至少包含 `86a763cea`,本次还要求 Hub、Server、Docker 使用同批清理版本。构建发布时记录完整 SHA,不以历史绿色 CI 代替新版本验收。 diff --git a/docs/upstream-patches/cloudfile-hub.txt b/docs/upstream-patches/cloudfile-hub.txt index fcd56f3c..69c97729 100644 --- a/docs/upstream-patches/cloudfile-hub.txt +++ b/docs/upstream-patches/cloudfile-hub.txt @@ -112,3 +112,16 @@ tests/api/endpoints/test_file_tag.py tests/api/endpoints/test_group_libraries.py tests/api/test_group_repos.py tests/api/test_repo_history_limit.py + +# 2026-10-21 权限收敛修复:seahub/views/__init__.py:复用 ACLUnavailable 与现有权限中间件,避免策略故障被误报为 403;内容权限仍交给现有 Authority。 + +# 2026-10-21 七类权限清理:注销旧票据/PolicyHost/独立 ACL 引擎;现行 Authority、CE 入口、V2 编辑与 Guard/CAS 保留。 +frontend/src/components/file-uploader/file-uploader.js +frontend/src/components/logout/index.js +frontend/src/features/library-view/toolbar/selected-dirents-toolbar/index.js +frontend/src/hooks/file-operations/download.js +seahub/templates/react_app.html + +# 2026-10-21 复检修复:seahub/api2/endpoints/file_tag.py:原生标签接口复用现有 tag_native/Authority 按目标路径裁决,校验定义与绑定的库归属;旧标签读保留,写收敛 CE FileTags。 + +# 2026-10-21 VirtualRepo 兼容:cloudfile_ext/v2/acl_runtime.py 保留 CE 虚拟库共享资格,沿 origin_repo_id/origin_path 交给同一 Authority;不暴露源库路径,不创建新的共享表或引擎。轻量 share_mode 共享继续独立按目录资格处理。 diff --git a/docs/upstream-patches/cloudfile-server.txt b/docs/upstream-patches/cloudfile-server.txt index abb2a64e..6c2b9f21 100644 --- a/docs/upstream-patches/cloudfile-server.txt +++ b/docs/upstream-patches/cloudfile-server.txt @@ -64,5 +64,16 @@ common/user-mgr.h fileserver/fileserver.go server/gc/seafile-session.c -# 2026-10-21:cf-lock.c/h 与 cloudfile-policy.c 是独立 CloudFile 文件。 +# 2026-10-21:cf-lock.c/h 保留当前 Guard;cloudfile-policy.c 旧独立引擎与死函数已删除。 # C 普通写入/锁屏障使用 V2 单 Guard,Checkout 关闭不查询锁;不改原生调用点。 + +# 2026-10-21 权限收敛修复:common/branch-mgr.c:最终 ACL 检查移至原生 Branch 锁与旧 head 校验之后;fileserver/fileop.go:拒绝未绑定父目录的 block upload token,保留 CE 目录规范化行为。 + +# 2026-10-21 七类权限清理:注销旧票据/PolicyHost/独立 ACL 引擎;现行 Authority、CE 入口、V2 编辑与 Guard/CAS 保留。 +common/branch-mgr.h +server/web-accesstoken-mgr.c +server/web-accesstoken-mgr.h + +# 2026-10-21 复检修复:common/branch-mgr.c:add/del/update 三个 MySQL 分支写入口先锁 Branch(master),再执行现有最终 ACL 校验,与 ACLStore 写入锁顺序一致;允许原生新建库尚无 master。 + +# 2026-10-21 VirtualRepo 兼容:common/cf-v2-acl.c 公开链接复用 CE origin/path 映射;原生转存不再仅因虚拟库类型拒绝。缺少范围证明的受 ACL 限制操作仍保留现有边界。server/Makefile.am 删除已退役头文件引用。 diff --git a/scripts/scripts_14.0/cloudfile.py b/scripts/scripts_14.0/cloudfile.py index 13d0c0b6..adec86e4 100644 --- a/scripts/scripts_14.0/cloudfile.py +++ b/scripts/scripts_14.0/cloudfile.py @@ -98,41 +98,8 @@ def invalid_constant(_): return value -def _policy_config(raw_value): - return _strict_config(raw_value, "CLOUDFILE_POLICY_CONFIG_JSON") -def _oidc_config(raw_value): - value = _strict_config(raw_value, "CLOUDFILE_OIDC_CONFIG_JSON") - if value is None: - return None - required = {"issuer", "client_id", "client_secret", "redirect_uri", - "authorization_url", "token_url", "userinfo_url", "jwks_url"} - optional = {"user_id_claim", "ca_bundle", "end_session_url", "post_logout_redirect_uri"} - if not required <= value.keys() or value.keys() - required - optional: - raise ValueError("OIDC configuration has missing or unsupported fields") - if any(not isinstance(value[name], str) or not value[name] or "\x00" in value[name] - for name in required): - raise ValueError("OIDC configuration requires non-empty strings") - for name in ("issuer", "redirect_uri", "authorization_url", "token_url", "userinfo_url", "jwks_url", - "end_session_url", "post_logout_redirect_uri"): - if value.get(name) is None: - continue - url = value[name] - if not isinstance(url, str) or any(ord(char) < 33 for char in url): - raise ValueError("OIDC endpoints require fixed HTTP or HTTPS URLs") - try: - parsed = urlparse(url) - valid = (parsed.scheme in ("http", "https") and parsed.hostname and not parsed.username - and not parsed.password and not parsed.fragment) - parsed.port - except ValueError: - valid = False - if not valid: - raise ValueError("OIDC endpoints require fixed HTTP or HTTPS URLs") - if (value.get("end_session_url") is None) != (value.get("post_logout_redirect_uri") is None): - raise ValueError("OIDC logout endpoint and return URI must be configured together") - return value def _boolean(environment, name, default=False): @@ -225,170 +192,35 @@ def _authentik_settings(environment): } +RETIRED_POLICY_SETTINGS = ('CLOUDFILE_POLICY_CONFIG_JSON', 'CLOUDFILE_POLICY_WORKER_HOOKS', 'CLOUDFILE_AUTHORIZATION_ENABLED', 'CLOUDFILE_ADMIN_POLICY_ENABLED', 'CLOUDFILE_DIRECTORY_AUTHORIZATION', 'CLOUDFILE_OIDC_ENABLED', 'CLOUDFILE_OIDC_CONFIG_JSON', 'CLOUDFILE_OIDC_JIT_ENABLED', 'CLOUDFILE_OIDC_BACKCHANNEL_ENABLED', 'CLOUDFILE_TRANSFER_ENABLED', 'CLOUDFILE_EDITING_ENABLED', 'CLOUDFILE_LOCAL_EDIT_ENABLED', 'CLOUDFILE_LOCAL_EDIT_INSTANCE', 'CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED', 'CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED', 'CLOUDFILE_RESOURCE_SECRET', 'CLOUDFILE_RESOURCE_SEARCH_ENABLED', 'CLOUDFILE_ANNOTATIONS_ENABLED', 'CLOUDFILE_AUDIT_QUERY_ENABLED', 'CLOUDFILE_AUDIT_EXPORT_ENABLED', 'CLOUDFILE_AUDIT_CURSOR_SECRET', 'CLOUDFILE_AUDIT_RESULT_ROOT') + def render_settings(environment=None): environment = os.environ if environment is None else environment + requested = [name for name in RETIRED_POLICY_SETTINGS + if str(environment.get(name, '')).strip().lower() not in ('', 'false', '0')] + if requested: + raise ValueError('Retired policy settings are unsupported: ' + ', '.join(requested)) extension_apps = _extension_apps(environment.get("CLOUDFILE_EXTENSION_APPS", "")) urlconfs = _extension_urlconfs(environment.get("CLOUDFILE_EXTENSION_URLCONFS_JSON", "")) capabilities = _capabilities(environment.get("CLOUDFILE_CAPABILITIES_JSON", "")) - policy_config = _policy_config(environment.get("CLOUDFILE_POLICY_CONFIG_JSON", "")) - oidc_config = _oidc_config(environment.get("CLOUDFILE_OIDC_CONFIG_JSON", "")) - oidc_enabled = _boolean(environment, "CLOUDFILE_OIDC_ENABLED", False) - oidc_jit_enabled = _boolean(environment, "CLOUDFILE_OIDC_JIT_ENABLED", False) - oidc_backchannel_enabled = _boolean(environment, "CLOUDFILE_OIDC_BACKCHANNEL_ENABLED", False) authentik = _authentik_settings(environment) - if oidc_enabled and (oidc_config is None or policy_config is None - or not _boolean(environment, "CLOUDFILE_POLICY_WORKER_HOOKS", False)): - raise ValueError("enabled OIDC requires OIDC config, policy config and worker hooks") - # Native OIDC and the legacy Authentik OAuth preset coexist: the Hub keeps - # the OAuth login entry and dispatches browser sessions by authentication - # backend (cloudfile-hub a4cd1dc8e, docs/OIDC-OAUTH-COMPATIBILITY.md). - # Directories sharing and read tickets need the native OIDC host, so this - # combination must stay deployable instead of being rejected here. - if oidc_jit_enabled and not oidc_enabled: - raise ValueError("OIDC JIT requires the explicit OIDC host") - if oidc_backchannel_enabled and not oidc_enabled: - raise ValueError("OIDC backchannel requires the explicit OIDC host") capabilities.setdefault("auth.basic", {"enabled": True, "version": "14"}) - webdav_enabled = _boolean(environment, "CLOUDFILE_WEBDAV_ENABLED", False) - authorization_enabled = _boolean(environment, "CLOUDFILE_AUTHORIZATION_ENABLED", False) - local_edit_enabled = _boolean(environment, "CLOUDFILE_LOCAL_EDIT_ENABLED", False) - # The old administrator control plane requires the retired multi-table - # schema. Current V2 DirectoryRules owns management and stays available. - admin_policy_enabled = _boolean(environment, "CLOUDFILE_ADMIN_POLICY_ENABLED", False) - if admin_policy_enabled and authorization_enabled: - raise ValueError("administrator policy requires distinct route ownership") - editing_enabled = _boolean(environment, "CLOUDFILE_EDITING_ENABLED", False) - editing_secret = environment.get("CLOUDFILE_RESOURCE_SECRET", "") - if editing_enabled and (not oidc_enabled or policy_config is None or - not _boolean(environment, "CLOUDFILE_POLICY_WORKER_HOOKS", False) or - len(editing_secret.encode("utf-8")) < 32): - raise ValueError("editing requires native OIDC, policy worker and a stable resource secret") - if editing_enabled and (not _boolean(environment, "CF_ENABLE_FILE_LOCK", False) or - environment.get("CF_LOCK_BACKEND", "cloudfile").lower() != "cloudfile"): - raise ValueError("editing requires the CloudFile native file-lock barrier") - local_edit_instance = environment.get("CLOUDFILE_LOCAL_EDIT_INSTANCE", "") - optimistic_upload_enabled = _boolean(environment, "CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED", False) - exclusive_enabled = _boolean(environment, "CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED", False) - if local_edit_enabled: - if (not oidc_enabled or policy_config is None or - not _boolean(environment, "CLOUDFILE_POLICY_WORKER_HOOKS", False) or - len(editing_secret.encode("utf-8")) < 32): - raise ValueError("local edit requires native OIDC, policy worker and a stable resource secret") - parsed = urlparse(local_edit_instance) - if (parsed.scheme not in ("http", "https") or not parsed.hostname or - parsed.username or parsed.password or parsed.path or parsed.query or parsed.fragment or - parsed.netloc != parsed.netloc.lower() or parsed.netloc.endswith(":") or - local_edit_instance != parsed.scheme + "://" + parsed.netloc or - any(ord(c) < 33 or ord(c) > 126 for c in local_edit_instance) or - any(c in local_edit_instance for c in "%\\") or len(local_edit_instance) > 255): - raise ValueError("local edit requires a canonical public HTTP/HTTPS origin without a path") - try: - if parsed.port is not None and not 1 <= parsed.port <= 65535: - raise ValueError() - except ValueError: - raise ValueError("local edit requires a valid public origin port") from None - # Device proof instances and browser sessions must share one deployment origin. - callback = urlparse(oidc_config["redirect_uri"]) - if parsed.scheme != callback.scheme or parsed.netloc != callback.netloc: - raise ValueError("local edit origin must match the registered native OIDC callback") - if optimistic_upload_enabled and not local_edit_enabled: - raise ValueError("optimistic Agent upload requires local edit") - if exclusive_enabled and not (local_edit_enabled and editing_enabled): - raise ValueError("exclusive Agent edit requires local edit and editing") - transfer_enabled = _boolean(environment, "CLOUDFILE_TRANSFER_ENABLED", False) - audit_query_enabled = _boolean(environment, "CLOUDFILE_AUDIT_QUERY_ENABLED", False) - audit_export_enabled = _boolean(environment, "CLOUDFILE_AUDIT_EXPORT_ENABLED", False) - audit_result_root = environment.get("CLOUDFILE_AUDIT_RESULT_ROOT") if audit_export_enabled else None - if audit_export_enabled and (not audit_query_enabled or not isinstance(audit_result_root, str) - or not os.path.isabs(audit_result_root) or "\x00" in audit_result_root): - raise ValueError("audit export requires audit query and an absolute private result directory") - raw_audit_secret = environment.get("CLOUDFILE_AUDIT_CURSOR_SECRET", "") - audit_secret = raw_audit_secret.encode("utf-8") if audit_query_enabled and raw_audit_secret else None - if audit_query_enabled and (not oidc_enabled or not authorization_enabled - or audit_secret is None or len(audit_secret) < 32): - raise ValueError("audit query requires OIDC, authorization and a cursor secret of at least 32 bytes") - if (authorization_enabled or transfer_enabled) and (policy_config is None - or not _boolean(environment, "CLOUDFILE_POLICY_WORKER_HOOKS", False)): - raise ValueError("enabled authorization or transfer requires policy config and worker hooks") - if transfer_enabled and not _boolean(environment, "ENABLE_GO_FILESERVER", False): - raise ValueError("enabled CloudFile transfer requires ENABLE_GO_FILESERVER=true") - capabilities.setdefault( - "protocol.webdav", - {"enabled": webdav_enabled, "version": "14"}, - ) - capabilities.setdefault( - "auth.oidc", - {"enabled": False, "version": "1", "provider": "authentik"}, - ) - + capabilities.setdefault("protocol.webdav", { + "enabled": _boolean(environment, "CLOUDFILE_WEBDAV_ENABLED", False), "version": "14"}) + capabilities.setdefault("auth.oidc", {"enabled": False, "version": "1", "provider": "authentik"}) lines = [ BEGIN_MARKER, f"EXTRA_INSTALLED_APPS = list(dict.fromkeys(globals().get('EXTRA_INSTALLED_APPS', []) + {pprint.pformat(['cloudfile_extensions', *extension_apps], sort_dicts=True)}))", "SITE_ROOT_URLCONF = 'cloudfile_extensions.root_urls'", - # A permission-runtime failure inside check_folder_permission is marked - # on the request; this middleware answers 503 instead of publishing the - # deny-on-error as a false 403 "Permission denied." (EXTRA_MIDDLEWARE is - # appended to MIDDLEWARE by seahub's load_local_settings.) "EXTRA_MIDDLEWARE = ['cloudfile_extensions.permission_middleware.CloudFilePermissionMiddleware']", f"CLOUDFILE_EXTENSION_URLCONFS = {pprint.pformat(urlconfs, sort_dicts=True)}", f"CLOUDFILE_CAPABILITIES = {pprint.pformat(capabilities, sort_dicts=True)}", - f"CLOUDFILE_POLICY_CONFIG = {pprint.pformat(policy_config, sort_dicts=True)}", - # v0.1 already requires an explicit Seafile owner for managed groups. f"CF_SSO_GROUP_OWNER = {environment.get('CF_SSO_GROUP_OWNER', '')!r}", f"CF_SSO_MAX_REMOVAL_RATIO = {environment.get('CF_SSO_MAX_REMOVAL_RATIO', '0.5')!r}", - f"CLOUDFILE_OIDC_ENABLED = {oidc_enabled!r}", - f"CLOUDFILE_OIDC_CONFIG = {pprint.pformat(oidc_config, sort_dicts=True)}", - f"CLOUDFILE_OIDC_JIT_ENABLED = {oidc_jit_enabled!r}", - f"CLOUDFILE_OIDC_BACKCHANNEL_ENABLED = {oidc_backchannel_enabled!r}", - f"CLOUDFILE_WEBDAV_SERVICE_ENABLED = {webdav_enabled!r}", - f"CLOUDFILE_AUTHORIZATION_ENABLED = {authorization_enabled!r}", - f"CLOUDFILE_LOCAL_EDIT_ENABLED = {local_edit_enabled!r}", - f"CLOUDFILE_ADMIN_POLICY_ENABLED = {admin_policy_enabled!r}", + f"CLOUDFILE_WEBDAV_SERVICE_ENABLED = {_boolean(environment, 'CLOUDFILE_WEBDAV_ENABLED', False)!r}", f"CF_NATIVE_DATABASE_NAME = {environment.get('SEAFILE_MYSQL_DB_CCNET_DB_NAME', 'ccnet_db')!r}", - f"CLOUDFILE_LOCAL_EDIT_INSTANCE = {local_edit_instance!r}" if local_edit_enabled else "CLOUDFILE_LOCAL_EDIT_INSTANCE = None", - f"CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED = {optimistic_upload_enabled!r}", - f"CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED = {exclusive_enabled!r}", - ("def CLOUDFILE_LOCAL_EDIT_VERSION_READER(cursor, reference, evidence):\n" - " from cloudfile_extensions.editing.runtime import native_file_version\n" - " return native_file_version(cursor, reference, evidence)") if local_edit_enabled else - "CLOUDFILE_LOCAL_EDIT_VERSION_READER = None", - f"CLOUDFILE_EDITING_ENABLED = {editing_enabled!r}", - # Login and directory authority also use lifecycle-bound resources. - # Preserve their configured key even when online editing is disabled; - # PolicyHost validates the key/reader pair before accepting requests. - f"CLOUDFILE_RESOURCE_SECRET = {editing_secret.encode('utf-8')!r}" if editing_secret else - "CLOUDFILE_RESOURCE_SECRET = None", - ("def CLOUDFILE_RESOURCE_LIFECYCLE_READER(cursor, reference):\n" - " from cloudfile_extensions.resources.native import NativeResourceReader\n" - " from seaserv import seafile_api\n" - " return NativeResourceReader(seafile_api)(cursor, reference)") - if (editing_enabled or local_edit_enabled or authorization_enabled or transfer_enabled or oidc_enabled) else - "CLOUDFILE_RESOURCE_LIFECYCLE_READER = None", - f"CLOUDFILE_TRANSFER_ENABLED = {transfer_enabled!r}", - f"CLOUDFILE_AUDIT_QUERY_ENABLED = {audit_query_enabled!r}", - f"CLOUDFILE_AUDIT_EXPORT_ENABLED = {audit_export_enabled!r}", - f"CLOUDFILE_AUDIT_RESULT_ROOT = {audit_result_root!r}", - f"CLOUDFILE_AUDIT_CURSOR_SECRET = {audit_secret!r}", ] - lines.extend( - f"{name} = {pprint.pformat(value, sort_dicts=True)}" - for name, value in authentik.items() - ) - # Native OIDC owns the default login entry only when no legacy OAuth is - # configured. With one configured, both coexist: the Hub keeps the OAuth - # entry and dispatches browser sessions by authentication backend - # (cloudfile-hub a4cd1dc8e, docs/OIDC-OAUTH-COMPATIBILITY.md). Writing - # ENABLE_OAUTH = False here would silently switch off an operator's legacy - # login, which bootstrap.py explicitly refuses to do. - legacy_oauth = bool(authentik) or ( - _boolean(environment, "CF_ENABLE_SSO", False) - and bool(environment.get("CF_SSO_OAUTH_CLIENT_ID", "").strip())) - if oidc_enabled and not legacy_oauth: - lines.append("ENABLE_OAUTH = False") - callback_path = urlparse(oidc_config["redirect_uri"]).path - if not callback_path.endswith("callback/"): - raise ValueError("native OIDC requires its registered callback path") - lines.append(f"LOGIN_URL = {callback_path[:-len('callback/')] + 'begin/'!r}") + lines.extend(f"{name} = {pprint.pformat(value, sort_dicts=True)}" for name, value in authentik.items()) lines.extend((END_MARKER, "")) return "\n".join(lines) @@ -423,26 +255,8 @@ def write_settings(path, environment=None): def write_policy_worker_hooks(path, environment=None, *, v2=False): - """Manage only our block; preserve and compose existing Gunicorn hooks.""" + """Remove the retired owned block; never install a policy worker.""" environment = os.environ if environment is None else environment - enabled = _boolean(environment, "CLOUDFILE_POLICY_WORKER_HOOKS", False) - if v2: - # The old PolicyHost validates the 42-migration Resource/Intent schema. - # V2 native OAuth, ACL and transfers use their direct adapters instead. - # Never silently enable retired routes or start that host on four tables. - retired = ( - "CLOUDFILE_OIDC_ENABLED", "CLOUDFILE_ADMIN_POLICY_ENABLED", "CLOUDFILE_AUTHORIZATION_ENABLED", - "CLOUDFILE_TRANSFER_ENABLED", "CLOUDFILE_EDITING_ENABLED", - "CLOUDFILE_LOCAL_EDIT_ENABLED", "CLOUDFILE_ANNOTATIONS_ENABLED", - "CLOUDFILE_AUDIT_QUERY_ENABLED", "CLOUDFILE_AUDIT_EXPORT_ENABLED", - "CLOUDFILE_RESOURCE_SEARCH_ENABLED", - ) - requested = [name for name in retired if _boolean(environment, name, False)] - if requested: - raise ValueError("V2 does not support retired schema-dependent routes: " + ", ".join(requested)) - # Removing our generated block preserves operator hooks. It does not - # change environment variables, schema, or native V2 permissions. - enabled = False begin_marker = "# BEGIN CLOUDFILE POLICY WORKER HOOKS" end_marker = "# END CLOUDFILE POLICY WORKER HOOKS" try: @@ -461,24 +275,7 @@ def write_policy_worker_hooks(path, environment=None, *, v2=False): base = current[:begin] + current[end:] else: base = current - block = "" - if enabled: - block = '\n'.join((begin_marker, - "_cf_previous_post_worker_init = globals().get('post_worker_init')", - "_cf_previous_worker_exit = globals().get('worker_exit')", - "def post_worker_init(worker):", - " if _cf_previous_post_worker_init is not None:", - " _cf_previous_post_worker_init(worker)", - " from cloudfile_extensions.authorization.gunicorn import post_worker_init as cf_init", - " cf_init(worker)", - "def worker_exit(server, worker):", - " try:", - " if _cf_previous_worker_exit is not None:", - " _cf_previous_worker_exit(server, worker)", - " finally:", - " from cloudfile_extensions.authorization.gunicorn import worker_exit as cf_exit", - " cf_exit(server, worker)", end_marker, "")) - updated = base + ("\n" if block and base and not base.endswith("\n") else "") + block + updated = base if updated == current: return False with open(path, "w", encoding="utf-8") as target: diff --git a/tests/audit_export_runtime.py b/tests/audit_export_runtime.py deleted file mode 100644 index c8a09018..00000000 --- a/tests/audit_export_runtime.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Disposable OIDC audit export through HTTP, an independent worker and native reauthorization.""" -import base64 -from dataclasses import asdict -from datetime import datetime, timedelta, timezone -import json -import os -from pathlib import Path -import subprocess -import sys -import tempfile -import csv -import io -from uuid import UUID - - -def exercise(*, db, user, initiate, complete, require, configuration_dir): - from django.conf import settings - from django.test import Client - from seaserv import seafile_api - from cloudfile_extensions.jobs.store import JobStore - - checks = [] - browser = Client(enforce_csrf_checks=True) - browser.get('/accounts/login/', secure=True, HTTP_HOST='cloudfile-smoke.invalid') - require(complete(browser, initiate(browser)).status_code == 302, 'audit_oidc_login') - # Actual CE ownership and C root policy, rather than an injected authorization callback. - repo = seafile_api.create_repo('Disposable audit', 'runtime export fixture', user.username) - require(str(UUID(repo)) == repo, 'audit_native_repo') - with tempfile.NamedTemporaryFile() as uploaded: - uploaded.write(b'audit upload fixture'); uploaded.flush() - seafile_api.post_file(repo, uploaded.name, '/', 'audit.txt', user.username) - from cloudfile_extensions.authorization import gunicorn - from cloudfile_extensions.identity.transfer_audit import record_transfer - reference = dict(repo_id=repo, path='/audit.txt', kind='file') - for action, result in [('file.upload', 'succeeded'), ('file.update', 'denied')]: - record_transfer(gunicorn._host.deployment.factory.resources, 'fixture-user-1', reference, - 'audit-source-' + action, action, result, client_ip='127.0.0.1') - prefix = '/api/v2.1/cloudfile/extensions/audit/v1/exports/' - subject = base64.urlsafe_b64encode(b'fixture-user-1').decode().rstrip('=') - headers = dict(secure=True, HTTP_HOST='cloudfile-smoke.invalid', - HTTP_X_CLOUDFILE_EXPECTED_SUBJECT=subject) - now = datetime.now(timezone.utc) - request = dict(repo_id=repo, - start=(now - timedelta(days=1)).isoformat().replace('+00:00', 'Z'), - end=(now + timedelta(days=1)).isoformat().replace('+00:00', 'Z')) - from urllib.parse import urlencode - audit_prefix = '/api/v2.1/cloudfile/extensions/audit/v1/events/library/' - trace = [] - def diagnostic(frame, event, argument): - if event == 'exception' and '/cloudfile_extensions/events/' in frame.f_code.co_filename: - name = argument[0].__name__ - if name not in {'StopIteration', 'GeneratorExit'}: - trace.append(frame.f_code.co_filename.rsplit('/', 1)[-1] + ':' + str(frame.f_lineno) + ':' + name) - return diagnostic - sys.settrace(diagnostic) - try: - queried = browser.get(audit_prefix + 'updates/?' + urlencode(request), **headers) - finally: - sys.settrace(None) - if queried.status_code == 503: - require(False, 'audit_trace_' + '_'.join(trace[-12:])) - require(queried.status_code == 200, 'audit_updates_query_' + str(queried.status_code) + '_' + json.loads(queried.content).get('code', 'unknown')) - rows = json.loads(queried.content)['items'] - require({row['operation'] for row in rows} == {'file.upload', 'file.update'} and - {row['result'] for row in rows} == {'succeeded', 'denied'}, 'audit_updates_nonempty_facts') - accessed = browser.get(audit_prefix + 'access/?' + urlencode(request), **headers) - require(accessed.status_code == 200 and json.loads(accessed.content)['items'] == [], 'audit_access_separated') - checks.append('actual_transfer_audit_writer_and_nonempty_query_category_filters') - response = browser.post(prefix, data=json.dumps(request), content_type='application/json', - HTTP_ORIGIN='https://cloudfile-smoke.invalid', - HTTP_X_CSRFTOKEN=browser.cookies[settings.CSRF_COOKIE_NAME].value, - HTTP_IDEMPOTENCY_KEY='audit-runtime-1', **headers) - require(response.status_code == 202, 'audit_submit_' + str(response.status_code)) - submitted = json.loads(response.content) - job_id = submitted['job_id'] - require(str(UUID(job_id)) == job_id and submitted['repo_id'] == repo and - submitted['status'] == 'queued', 'audit_submit_durable') - require(JobStore(db).get(job_id)['status'] == 'queued', 'audit_job_stored') - checks.append('authenticated_native_owner_creates_durable_export') - - # A separate process must normalize OIDC settings before opening PolicyHost. - config = Path(configuration_dir) / 'cf_audit_settings.py' - descriptor = os.open(config, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - with os.fdopen(descriptor, 'w') as output: - output.write('from seahub.settings import *\nENABLE_OAUTH = False\n') - for name, value in dict(CLOUDFILE_OIDC_ENABLED=True, - CLOUDFILE_AUTHORIZATION_ENABLED=True, CLOUDFILE_AUDIT_QUERY_ENABLED=True, - CLOUDFILE_AUDIT_EXPORT_ENABLED=True, - CLOUDFILE_POLICY_CONFIG=settings.CLOUDFILE_POLICY_CONFIG, - CLOUDFILE_OIDC_CONFIG=asdict(settings.CLOUDFILE_OIDC_CONFIG), - CLOUDFILE_AUDIT_CURSOR_SECRET=settings.CLOUDFILE_AUDIT_CURSOR_SECRET, - CLOUDFILE_AUDIT_RESULT_ROOT=settings.CLOUDFILE_AUDIT_RESULT_ROOT).items(): - output.write(name + ' = ' + repr(value) + '\n') - environment = {**os.environ, 'DJANGO_SETTINGS_MODULE': 'cf_audit_settings', - 'PYTHONPATH': str(configuration_dir) + ':' + ':'.join(sys.path[:4]), - 'PYTHONDONTWRITEBYTECODE': '1'} - worker = subprocess.run([sys.executable, os.environ['CF_AUDIT_WORKER_SCRIPT'], '--once'], - env=environment, capture_output=True, text=True, timeout=45) - diagnostic = next((line.removeprefix('CF_AUDIT_WORKER_DIAGNOSTIC=') - for line in worker.stderr.splitlines() if line.startswith('CF_AUDIT_WORKER_DIAGNOSTIC=')), '') - require(worker.returncode == 0, 'audit_worker_exit_' + str(worker.returncode) + - (';worker=' + diagnostic if diagnostic else '')) - require(JobStore(db).get(job_id)['status'] == 'succeeded', 'audit_worker_completed') - checks.append('standalone_worker_writes_private_result') - - status = browser.get(prefix + job_id + '/', **headers) - require(status.status_code == 200, 'audit_status') - metadata = json.loads(status.content) - require(metadata['status'] == 'succeeded' and metadata['result_url'] == - prefix + job_id + '/result/', 'audit_result_location') - downloaded = browser.get(metadata['result_url'], **headers) - require(downloaded.status_code == 200 and downloaded['Content-Type'].startswith('text/csv') and - downloaded.content.startswith(b'"id","event_id"'), 'audit_result_delivery') - exported = list(csv.DictReader(io.StringIO(downloaded.content.decode('utf-8-sig')))) - require(len([row for row in exported if row['operation'] in {'file.upload', 'file.update'}]) == 2 and - {'file.upload', 'file.update'} <= {row['operation'] for row in exported}, - 'audit_csv_contains_actual_query_facts') - with db.cursor() as cursor: - cursor.execute('SELECT COUNT(*) FROM cf_audit_event WHERE operation=%s AND repo_id=%s AND result=%s', - ('audit.export.download', repo, 'attempted')) - require(cursor.fetchone()[0] == 1, 'audit_download_attempt_fact') - checks.append('result_reauthorized_and_attempt_recorded') - - user.is_active = False - user.save() - denied = browser.get(metadata['result_url'], **headers) - require(denied.status_code in {401, 403, 503}, 'audit_disabled_owner_denied') - checks.append('native_disabled_owner_cannot_redownload') - - # The same packaged worker must eventually remove an expired result, not - # just deny its HTTP URL. This disposable job is the only row changed. - job = JobStore(db).get(job_id) - artifact = Path(settings.CLOUDFILE_AUDIT_RESULT_ROOT) / (job_id + '.' + str(job['lease_epoch']) + '.csv') - require(artifact.is_file(), 'audit_result_exists_before_expiry') - checkpoint = dict(job['checkpoint'], expires_at=(now - timedelta(seconds=1)).timestamp()) - with db.cursor() as cursor: - cursor.execute('UPDATE cf_background_job SET checkpoint=%s WHERE job_id=%s AND status=%s', - (json.dumps(checkpoint, sort_keys=True), job_id, 'succeeded')) - require(cursor.rowcount == 1, 'audit_expiry_fixture_updated') - cleanup = subprocess.run([sys.executable, os.environ['CF_AUDIT_WORKER_SCRIPT'], '--once'], - env=environment, capture_output=True, text=True, timeout=45) - require(cleanup.returncode == 0 and not artifact.exists(), 'audit_expired_result_removed') - require(JobStore(db).get(job_id)['status'] == 'succeeded', 'audit_job_facts_retained') - checks.append('expired_private_csv_removed_without_deleting_job') - return checks diff --git a/tests/e2e/EDITING_NATIVE_ACCEPTANCE.md b/tests/e2e/EDITING_NATIVE_ACCEPTANCE.md index 6bb646d6..75a71e50 100644 --- a/tests/e2e/EDITING_NATIVE_ACCEPTANCE.md +++ b/tests/e2e/EDITING_NATIVE_ACCEPTANCE.md @@ -1,5 +1,8 @@ # Editing Native 验收报告 +> 历史记录,非当前 V2 契约。文中旧 managed marker、Resource/Intent、委托与旧授权路由已退役;当前实施以 [权限终判契约](/docs/permission-adjudication-contract.md) 和现行源码为准。 + + 2026-09-30:真实单文件原生闭环通过,建议 **Editing Core Foundation = READY TO FREEZE**。这是原生事务基础的验收结论;本地编辑功能仍默认关闭,不代表可以正式开放完整产品功能。 同日开发合并前使用当前 Hub 工作区重复执行原生回归,通过结果及 Agent 兼容边界见[开发合并回归](EDITING_DEV_MERGE_20260930.md)。原生 Server 构建快照与本次 Server 源码内容一致,报告后续引用的首次跑次仍保留为历史证据。 diff --git a/tests/e2e/native_cdc_editing_e2e.py b/tests/e2e/native_cdc_editing_e2e.py deleted file mode 100644 index 2beebf07..00000000 --- a/tests/e2e/native_cdc_editing_e2e.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Real Seafile storage test for CloudFile guarded CDC CommitBlocks. - -Uses the same upstream native libsearpc and guarded Branch CAS as full-file -publish_edit; no generic commitonly token, no production database. It verifies -that raw block staging is pathless, edits have the same durable CommitIntent -receipt and a second version uploads *only new* block data. - -Trusted OIDC actors are isolated fixtures. This is NOT installed Agent E2E. -""" -import hashlib -import json -from pathlib import Path -from uuid import uuid4 - -import native_editing_e2e as lab - - -def sha1(payload): - return hashlib.sha1(payload).hexdigest() - - -def file_object_id(parts): - names = ", ".join(json.dumps(sha1(b)) for b in parts) - raw = '{"block_ids": [' + names + '], "size": ' + str(sum(map(len, parts))) + ', "type": 1, "version": 1}' - return hashlib.sha1(raw.encode('utf-8')).hexdigest() - - -repo, target, actor = lab.repo, lab.target, lab.identity -base = lab.rpc.get_file_id_by_path(repo, lab.path) -head = lab.rpc.get_repo(repo).head_cmmt_id -with lab.tx() as cursor: - guard = lab.store.acquire(cursor, **target, **actor, mode="checkout", - source="web", native_user=lab.owner, - base_file_id=base) -proof = dict(actor, guard_id=guard["guard_id"], - generation=int(guard["generation"]), - credential_epoch=int(guard["credential_epoch"])) - -# Reuse nontrivial stored blocks on the second version. Seafile block storage -# SHA-1 names refer to raw bytes, not to the user's local filename. -A = b"A" * (7 * 1024 * 1024) -B = b"B" * (8 * 1024 * 1024) -C = b"C" * (5 * 1024 * 1024) -D = b"D" * (5 * 1024 * 1024) -reports = [] -total_sent = 0 - -for i, (parts, action) in enumerate((((A, B, C), "commit"), - ((A, B, D), "checkin")), 1): - content = b"".join(parts) - ids = [sha1(part) for part in parts] - assert len(set(ids)) == len(ids) - missing = json.loads(lab.rpc.check_repo_blocks_missing(repo, json.dumps(ids))) - want = ids if i == 1 else [sha1(D)] - assert missing == want, (missing, want) - stage_bytes = 0 - for id, part in zip(ids, parts): - if id not in missing: - continue - path = Path("/lab/cdc-block-" + id) - path.write_bytes(part) - try: - assert lab.rpc.cloudfile_stage_raw_block(repo, id, str(path), lab.owner) == 0 - finally: - path.unlink(missing_ok=True) - stage_bytes += len(part) - total_sent += stage_bytes - assert json.loads(lab.rpc.check_repo_blocks_missing(repo, json.dumps(ids))) == [] - - # One attempt to stage a block under an incorrect SHA-1 must fail. - if i == 1: - bad_path = Path("/lab/cdc-wrong-block") - bad_path.write_bytes(b"unsafe not matching the supplied identity") - try: - try: - lab.rpc.cloudfile_stage_raw_block(repo, ids[0], str(bad_path), lab.owner) - except Exception: - pass - else: - raise AssertionError("Accepted a staged block whose SHA-1 is wrong") - finally: - bad_path.unlink(missing_ok=True) - - intent = str(uuid4()) - with lab.tx() as cursor: - lab.store.prepare(cursor, **target, **proof, intent_id=intent, - expected_file_id=base, staged_file_id=None, - content_digest=hashlib.sha256(content).hexdigest(), - action=action, - snapshot=dict(id=intent, size=len(content), - source="cdc-native-fixture")) - condition = lab.condition(head, base, intent) - condition["editing"].update( - guard_id=proof["guard_id"], - generation=str(proof["generation"]), - credential_epoch=str(proof["credential_epoch"]), - holder=actor["holder"], token=actor["token"]) - - # A valid manifest in the wrong order is NOT a valid current version. - before = lab.rpc.get_repo(repo).head_cmmt_id - try: - lab.rpc.cloudfile_publish_edit_blocks(repo, json.dumps(list(reversed(ids))), - str(len(content)), "/", lab.path[1:], lab.owner, json.dumps(condition)) - except Exception: - pass - else: - raise AssertionError("Mismatched ordered block manifest published") - assert lab.rpc.get_repo(repo).head_cmmt_id == before - assert lab.receipt(intent)["state"] == "prepared" - - # The real guarded native publisher builds the canonical Seafile file - # object from the ordered block IDs and linearizes the receipt in Branch. - result = lab.rpc.cloudfile_publish_edit_blocks(repo, json.dumps(ids), - str(len(content)), "/", lab.path[1:], lab.owner, json.dumps(condition)) - row = lab.receipt(intent) - published = lab.rpc.get_file_id_by_path(repo, lab.path) - assert row["state"] == "published" - assert published == result == row["result_file_id"] == file_object_id(parts), ( - published, result, row["result_file_id"], file_object_id(parts)) - assert row["checked_in"] is (action == "checkin") - assert lab.rpc.get_repo(repo).head_cmmt_id != head - assert lab.download(content) == hashlib.sha256(content).hexdigest() - # Repeated original Intent cannot change the already-committed Branch. - final_head = lab.rpc.get_repo(repo).head_cmmt_id - with lab.tx() as cursor: - state = lab.store.load(cursor, **target) - assert (state["guard_id"] is None) == (action == "checkin") - assert state["pending_intent"] is None - assert state["base_file_id"] == (None if action == "checkin" else published) - reports.append(dict(action=action, result_file_id=published, - staged_bytes=stage_bytes, snapshot_bytes=len(content), - intent_id=intent, checked_in=row["checked_in"], native_branch=final_head)) - print("PASS guarded native CDC", action, "bytes", len(content), - "new raw bytes", stage_bytes, "seafile object", published, flush=True) - base, head = published, final_head - -assert total_sent < sum(r["snapshot_bytes"] for r in reports) -assert reports[1]["staged_bytes"] == len(D) -result = dict(status="PASSED_IN_ISOLATED_NATIVE_SERVER", - note="Device/OIDC identity is a fixture; GUI HTTPS and macOS/Windows credentials not tested", - first_file_id=reports[0]["result_file_id"], - final_file_id=reports[-1]["result_file_id"], - total_staged_bytes=total_sent, - full_upload_baseline=sum(r["snapshot_bytes"] for r in reports), - items=reports) -Path("/lab/native-cdc-result.json").write_text(json.dumps(result, indent=2)) -print("PASS Native direct CDC two versions and final Checkin with persisted receipts", flush=True) diff --git a/tests/e2e/native_device_editing_e2e.py b/tests/e2e/native_device_editing_e2e.py deleted file mode 100644 index 52f18680..00000000 --- a/tests/e2e/native_device_editing_e2e.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Paired identity fixtures -> Django raw upload -> real Native Branch/HTTP read. - -OIDC login and platform credentials are outside this isolated native fixture. -""" -import base64,hashlib,json,os,sys,subprocess -from contextlib import contextmanager -from pathlib import Path -from types import SimpleNamespace -from unittest.mock import patch -from uuid import uuid4 -sys.path[:0]=['/hub','/tests','/backend/seafile-server/seafile/lib/python3.12/site-packages'] -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature -from django.conf import settings -if not settings.configured: settings.configure(SECRET_KEY='isolated-fixture',DEFAULT_CHARSET='utf-8') -from django.test import RequestFactory -from cloudfile_extensions.local_edit.device_proof import DevicePublicKey -from cloudfile_extensions.local_edit.device_store import DeviceStore -from cloudfile_extensions.local_edit.session_store import LocalSessionStore -from cloudfile_extensions.local_edit.agent_runtime import AgentClaimRuntime -from cloudfile_extensions.local_edit.commit_contract import bind_request -from cloudfile_extensions.local_edit.upload_grant import seal -from cloudfile_extensions.local_edit.upload_http import AgentUploadView -import native_editing_e2e as lab - -private=ec.generate_private_key(ec.SECP256R1()) -def encode(value): return base64.urlsafe_b64encode(value).decode().rstrip('=') -def sign(challenge): - r,s=decode_dss_signature(private.sign(challenge.message(),ec.ECDSA(hashes.SHA256()))) - return encode(r.to_bytes(32,'big')+s.to_bytes(32,'big')) -public=private.public_key().public_numbers() -key=DevicePublicKey.parse(dict(kty='EC',crv='P-256',x=encode(public.x.to_bytes(32,'big')),y=encode(public.y.to_bytes(32,'big')))) -devices,sessions,store=DeviceStore(),LocalSessionStore(),lab.store -device_id=str(uuid4());owner=dict(provider='fixture',actor='employee',device_id=device_id) -origin='https://cloudfile.invalid';secret=b'isolated-device-publication-secret-0001' -with lab.tx() as sql: - devices.create_pending(sql,**owner,public_key=key) - challenge=devices.issue(sql,**owner,instance=origin,session_id=str(uuid4()),operation='pair',request_sha256='a'*64) - devices.consume(sql,provider='fixture',actor='employee',challenge=challenge,signature=sign(challenge)) - guard=store.acquire(sql,**lab.target,**lab.identity,mode='checkout',source='web',native_user=lab.owner,base_file_id=lab.base) - snapshot=dict(resource=dict(repo_id=lab.repo,path=lab.path,kind='file'),resource_uid=lab.uid, - lifecycle_ref=lab.target['lifecycle'],base_version=lab.base,resource_revision='1',local_open_type='',mode='exclusive-edit', - lease=dict(fencing=guard['generation'],token_digest=hashlib.sha256(lab.identity['token'].encode()).hexdigest())) - issued,created,_,_=sessions.create_exclusive(sql,**owner,snapshot=snapshot,secret=secret,token=lab.identity['token']) - assert created -with lab.tx() as sql: - replay,created,_,_=sessions.create_exclusive(sql,**owner,snapshot=snapshot,secret=secret,token=lab.identity['token']) - assert not created and replay==issued - args=dict(**owner,session_id=issued.session_id,ticket=issued.ticket,current_snapshot=snapshot,instance=origin) - challenge=sessions.challenge(sql,**args) - sessions.claim(sql,**args,challenge=challenge,signature=sign(challenge)) -@contextmanager -def connection(): yield lab.db -runtime=object.__new__(AgentClaimRuntime);runtime.pid=os.getpid();runtime.agent2_exclusive_enabled=True -runtime.resources=SimpleNamespace(provider='fixture',connection=connection) -runtime.instance,runtime.secret,runtime.sessions=origin,secret,sessions -Path('/lab/seafile-data/httptemp').mkdir(exist_ok=True);os.environ['SEAFILE_DATA_DIR']='/lab/seafile-data' -view=AgentUploadView.as_view(runtime=runtime);results=[] -def call(method,args,deadline): - if method=='seafile_get_repo': return {'head_cmmt_id':lab.rpc.get_repo(args[0]).head_cmmt_id} - assert method=='seafile_cloudfile_publish_edit' - try: result=lab.rpc.cloudfile_publish_edit(*args) - except Exception as error: - print('Native fixture publication failed:',type(error).__name__,str(error),flush=True) - raise - if lose_reply: raise ConnectionError('real native publication reply lost') - return result -for index,action in enumerate(('commit','commit','checkin'),1): - content=('device committed version %d\n'%index).encode();intent=str(uuid4()) - with lab.tx() as sql: - guard=store.load(sql,**lab.target);row=sessions._row(sql,**owner,session_id=issued.session_id) - saved=json.loads(row[4]) - body=dict(session_id=issued.session_id,device_id=device_id,intent_id=intent,guard_id=guard['guard_id'], - generation=str(guard['generation']),credential_epoch=str(guard['credential_epoch']),token=lab.identity['token'], - base_file_id=saved['base_version'],content_digest=hashlib.sha256(content).hexdigest(),snapshot_size=len(content),action=action) - digest=bind_request(body,snapshot=saved,session_row=row,edit_guard=guard,current_native=lab.owner) - challenge=devices.issue(sql,**owner,instance=origin,session_id=issued.session_id,operation='commit',request_sha256=digest) - devices.consume(sql,provider='fixture',actor='employee',challenge=challenge,signature=sign(challenge)) - store.prepare(sql,**lab.target,**lab.identity,guard_id=guard['guard_id'],generation=guard['generation'], - credential_epoch=guard['credential_epoch'],intent_id=intent,expected_file_id=body['base_file_id'],staged_file_id=None, - content_digest=body['content_digest'],action=action,snapshot=dict(id=intent,size=len(content),source='agent2-device', - session_id=issued.session_id,device_id=device_id)) - condition=dict(path=lab.path,context=dict(provider='fixture',userId='employee',epoch='d'*32), - scopes=[dict(type='provider',provider='fixture',external_id='fixture'),dict(type='user',provider='fixture',external_id='employee'),dict(type='repo',provider='cloudfile',external_id=lab.repo)], - local_commit=dict(session_id=issued.session_id,device_id=device_id,device_revision=str(row[3]),session_revision=str(row[9])), - editing=dict(repo_id=lab.repo,resource_uid=lab.uid,guard_id=guard['guard_id'],generation=str(guard['generation']),credential_epoch=str(guard['credential_epoch']),holder=lab.identity['holder'],token=lab.identity['token'],intent_id=intent,base_file_id=body['base_file_id'])) - grant=dict(instance=origin,condition=condition,native_user=lab.owner,content_digest=body['content_digest'],snapshot_size=len(content),action=action) - ticket=seal(secret,grant) - def request(): - value=RequestFactory().post('/device-upload/',data=content,content_type='application/octet-stream',secure=True,HTTP_AUTHORIZATION='Bearer '+ticket,HTTP_IDEMPOTENCY_KEY=intent) - value.META.pop('HTTP_COOKIE',None) - return value - lose_reply=False - if index==1: - before_head=lab.rpc.get_repo(lab.repo).head_cmmt_id - mutations=[ - ("device revoked", "UPDATE cf_local_device SET state='revoked' WHERE device_id=%s", (device_id,), - "UPDATE cf_local_device SET state='active' WHERE device_id=%s", (device_id,)), - ("session expired", "UPDATE cf_edit_session SET expires_at=1 WHERE session_id=%s", (issued.session_id,), - "UPDATE cf_edit_session SET expires_at=%s WHERE session_id=%s", (row[8],issued.session_id)), - ("stale session revision", "UPDATE cf_edit_session SET revision=revision+1 WHERE session_id=%s", (issued.session_id,), - "UPDATE cf_edit_session SET revision=%s WHERE session_id=%s", (row[9],issued.session_id)), - ("guard expired", "UPDATE cf_edit_guard SET lease_until=UTC_TIMESTAMP(6) WHERE resource_uid=%s", (lab.uid,), - "UPDATE cf_edit_guard SET lease_until=%s WHERE resource_uid=%s", (guard['lease_until'],lab.uid)), - ("library readonly", "UPDATE RepoInfo SET status=1 WHERE repo_id=%s", (lab.repo,), - "UPDATE RepoInfo SET status=0 WHERE repo_id=%s", (lab.repo,)), - ] - for name,sql,args,restore,restore_args in mutations: - with lab.tx() as cursor: cursor.execute(sql,args) - try: - with patch('cloudfile_extensions.identity.ticket_transport._call',side_effect=call): rejected=view(request()) - assert rejected.status_code!=200,(name,rejected.content) - assert lab.rpc.get_repo(lab.repo).head_cmmt_id==before_head - with lab.tx() as cursor: assert store.intent(cursor,uid=lab.uid,intent_id=intent)['state']=='prepared' - print('PASS device native rejects '+name+'; intent and Branch retained',flush=True) - finally: - with lab.tx() as cursor: cursor.execute(restore,restore_args) - lose_reply=index==2 - with patch('cloudfile_extensions.identity.ticket_transport._call',side_effect=call): response=view(request()) - assert response.status_code==200,(response.status_code,response.content) - receipt=json.loads(response.content)['intent'];head=lab.rpc.get_repo(lab.repo).head_cmmt_id - file_id=lab.rpc.get_file_id_by_path(lab.repo,lab.path) - assert receipt['state']=='published' and file_id==receipt['result_file_id'] and receipt['checked_in']==(action=='checkin') - with lab.tx() as sql: - updated=sessions._row(sql,**owner,session_id=issued.session_id);after=store.load(sql,**lab.target) - assert json.loads(updated[4])['base_version']==file_id and updated[9]==row[9]+1 - assert updated[5]==('completed' if action=='checkin' else 'active') - assert (after['guard_id'] is None)==(action=='checkin') - sql.execute('SELECT commit_id FROM Branch WHERE repo_id=%s',(lab.repo,));assert sql.fetchone()[0]==head - with patch('cloudfile_extensions.identity.ticket_transport._call',side_effect=AssertionError('duplicate publish')): repeated=view(request()) - assert repeated.status_code==200 and json.loads(repeated.content)['intent']==receipt - read=lab.condition(head,file_id,str(uuid4()));read.pop('editing') - ticket_read=lab.rpc.seafile_cloudfile_issue_read_ticket(lab.repo,lab.path,head,file_id,'download',lab.owner,json.dumps(read)) - downloaded=lab.requests.get('http://127.0.0.1:8082/cloudfile/read',headers={'Authorization':'Bearer '+ticket_read,'X-CloudFile-Filename':lab.path[1:],'X-Forwarded-Proto':'https'},timeout=10) - assert downloaded.status_code==200 and downloaded.content==content - results.append(dict(action=action,intent=receipt,file_id=file_id,head=head,session_state=updated[5],session_revision=updated[9],download_sha256=hashlib.sha256(content).hexdigest(),lost_native_reply=lose_reply,repeated_without_publication=True)) - print('PASS device native %s %d; session %s; bytes verified'%(action,index,updated[5]),flush=True) -Path('/lab/native-device-result.json').write_text(json.dumps(dict(product_scope='Paired device/current identity fixtures, Django HTTP adapter and real native storage; browser TLS/OIDC and OS credentials require platform acceptance.',native_build_info=Path('/backend/build-info.txt').read_text(),native_binary_sha256=hashlib.sha256(Path('/backend/seafile-server/seafile/bin/seaf-server').read_bytes()).hexdigest(),hub_commit=subprocess.check_output(['git','-C','/hub','rev-parse','HEAD'],text=True).strip(),negative_cases=[name for name,*_ in mutations],checkout_replay_same_session=True,device_id=device_id,session_id=issued.session_id,repo=lab.repo,results=results),indent=2)) diff --git a/tests/e2e/native_editing.sh b/tests/e2e/native_editing.sh deleted file mode 100644 index ef745182..00000000 --- a/tests/e2e/native_editing.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -# Dedicated native fixture only. No existing deployment or database is touched. -set -euo pipefail -here=$(cd "$(dirname "$0")" && pwd) -workspace=$(cd "$here/../../.." && pwd) -backend=${CF_NATIVE_BACKEND:-$workspace/cloudfile-docker/build/cloudfile_14.0/cloudfile-backend} -image=${CF_NATIVE_BASE_IMAGE:-cloudfile-build-base:ce14-v2} -test -x "$backend/seafile-server/seafile/bin/seaf-server" -test -x "$backend/fileserver/fileserver" -lab=${CF_NATIVE_REPORT_DIR:-$(mktemp -d /tmp/cf-native-editing.XXXXXX)} -mkdir -p "$lab" -lab=$(cd "$lab" && pwd) -suffix="$(date +%s)-$$" -network="cf-native-edit-$suffix" -db="cf-native-db-$suffix" -cache="cf-native-redis-$suffix" -runtime="cf-native-runtime-$suffix" -cleanup() { - result=$? - if [[ "$result" != 0 && "${CF_NATIVE_KEEP_ON_FAILURE:-0}" = 1 ]]; then - printf 'Failed fixture retained: %s (network %s, evidence %s)\n' "$runtime" "$network" "$lab" >&2 - return - fi - docker rm -f "$runtime" "$cache" "$db" >/dev/null 2>&1 || true - docker network rm "$network" >/dev/null 2>&1 || true -} -trap cleanup EXIT -docker network create "$network" >/dev/null -docker run --rm -d --name "$db" --network "$network" -e MYSQL_ALLOW_EMPTY_PASSWORD=yes mysql:8 >/dev/null -docker run --rm -d --name "$cache" --network "$network" redis:7-alpine >/dev/null -docker run --rm -d --name "$runtime" --network "$network" \ - -e CF_NATIVE_DB_HOST="$db" -e CF_NATIVE_REDIS_HOST="$cache" \ - -e LD_LIBRARY_PATH=/backend/seafile-server/seafile/lib:/backend/seafile-server/seafile/lib/seafile \ - -e JWT_PRIVATE_KEY=isolated-fixture-only-not-production \ - -e SEAFILE_MYSQL_DB_HOST="$db" -e SEAFILE_MYSQL_DB_PORT=3306 \ - -e SEAFILE_MYSQL_DB_USER=cf_lab -e SEAFILE_MYSQL_DB_PASSWORD=isolated-fixture-only \ - -e SEAFILE_MYSQL_DB_CCNET_DB_NAME=cf_lab_ccnet -e SEAFILE_MYSQL_DB_SEAFILE_DB_NAME=cf_lab_seafile \ - -v "$lab:/lab" -v "$backend:/backend:ro" \ - -v "$workspace/cloudfile-hub:/hub:ro" -v "$workspace/cloudfile-server:/server:ro" \ - -v "$here:/tests:ro" "$image" sleep infinity >/dev/null -ready=false -for attempt in $(seq 1 90); do - if docker exec "$db" mysqladmin ping --silent >/dev/null 2>&1; then ready=true; break; fi - sleep 1 -done -test "$ready" = true -docker exec "$runtime" python3 -m pip install --break-system-packages redis django==4.2.24 requests-oauthlib cryptography > "$lab/dependencies.log" 2>&1 -docker exec "$runtime" python3 /tests/native_editing_bootstrap.py -docker exec -d "$runtime" /backend/seafile-server/seafile/bin/seaf-server \ - -c /lab/ccnet -d /lab/seafile-data -F /lab/conf -f -l /lab/seafile.log -p /lab/seafile-data -ready=false -for attempt in $(seq 1 30); do - if docker exec "$runtime" test -S /lab/seafile-data/seafile.sock; then ready=true; break; fi - sleep 1 -done -test "$ready" = true -docker exec -d "$runtime" /backend/fileserver/fileserver \ - -F /lab/conf -d /lab/seafile-data -l /lab/fileserver.log -p /lab/seafile-data -ready=false -for attempt in $(seq 1 30); do - if docker exec "$runtime" python3 -c 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:8082/protocol-version",timeout=1)' >/dev/null 2>&1; then ready=true; break; fi - sleep 1 -done -test "$ready" = true -cp "$backend/build-info.txt" "$lab/build-info.txt" -docker exec "$runtime" python3 /tests/native_device_editing_e2e.py | tee "$lab/test.log" -if [[ "${CF_NATIVE_MANUAL_BASELINE_E2E:-0}" = 1 ]]; then - docker exec "$runtime" python3 /tests/native_local_edit_baseline_e2e.py | tee "$lab/manual-baseline-test.log" - printf 'Native baseline evidence: %s/native-local-edit-result.json\n' "$lab" -fi -# The old optimistic Agent fixture needed retired Resource/Session and library -# enrollment tables. V2 uses the Agent2 Guard and scoped Commit tests instead. -if [[ "${CF_NATIVE_CDC_E2E:-0}" = 1 ]]; then - docker exec "$runtime" python3 /tests/native_cdc_editing_e2e.py | tee "$lab/cdc-test.log" - printf 'Guarded CDC evidence: %s/native-cdc-result.json\n' "$lab" -fi -printf 'Native evidence: %s/native-result.json\nDevice evidence: %s/native-device-result.json\n' "$lab" "$lab" diff --git a/tests/e2e/native_editing_bootstrap.py b/tests/e2e/native_editing_bootstrap.py deleted file mode 100644 index e8a97972..00000000 --- a/tests/e2e/native_editing_bootstrap.py +++ /dev/null @@ -1,56 +0,0 @@ -import sys, os -from pathlib import Path -import pymysql -sys.path.insert(0, '/hub') -from cloudfile_extensions.schema.runner import SchemaRunner -c=pymysql.connect(host=os.environ['CF_NATIVE_DB_HOST'],user='root',password='',autocommit=True) -with c.cursor() as q: - for name in ('cf_lab_ccnet','cf_lab_seafile','cf_lab_hub'): - q.execute(f'CREATE DATABASE `{name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin') - q.execute("CREATE USER 'cf_lab'@'%' IDENTIFIED BY 'isolated-fixture-only'") - for name in ('cf_lab_ccnet','cf_lab_seafile','cf_lab_hub'): - q.execute(f"GRANT ALL ON `{name}`.* TO 'cf_lab'@'%'") - for schema, source in [('cf_lab_ccnet','ccnet'),('cf_lab_seafile','seafile')]: - q.execute(f'USE `{schema}`') - for statement in Path(f'/server/scripts/sql/mysql/{source}.sql').read_text().split(';'): - if statement.strip(): q.execute(statement) - q.execute('USE cf_lab_hub') - q.execute('CREATE TABLE profile_profile(user VARCHAR(255) UNIQUE,login_id VARCHAR(225) UNIQUE) ENGINE=InnoDB') - q.execute("INSERT INTO profile_profile VALUES('editor@example.test','employee'),('other@example.test','other-employee')") - q.execute('CREATE TABLE django_session(session_key VARCHAR(40) PRIMARY KEY,expire_date DATETIME(6)) ENGINE=InnoDB') - q.execute("INSERT INTO django_session VALUES(%s,TIMESTAMPADD(HOUR,1,UTC_TIMESTAMP(6)))",('s'*32,)) - q.execute('USE cf_lab_seafile') -SchemaRunner(c).apply() -print('isolated native schemas ready') - -lab=Path('/lab') -for name in ('conf','ccnet','seafile-data'): - (lab/name).mkdir(exist_ok=True) -(lab/'conf/seafile.conf').write_text(f"""[database] -type = mysql -host = {os.environ['CF_NATIVE_DB_HOST']} -port = 3306 -user = cf_lab -password = isolated-fixture-only -db_name = cf_lab_seafile -connection_charset = utf8mb4 -[cloudfile] -identity_database = cf_lab_hub -subject_redis_host = {os.environ['CF_NATIVE_REDIS_HOST']} -subject_redis_port = 6379 -subject_redis_prefix = cf-lab: -file_lock_enabled = true -lock_backend = cloudfile -trusted_tls_proxies = 127.0.0.1/32 -[fileserver] -port = 8082 -use_go_fileserver = true -""") -(lab/'conf/ccnet.conf').write_text(f"""[Database] -ENGINE = mysql -HOST = {os.environ['CF_NATIVE_DB_HOST']} -PORT = 3306 -USER = cf_lab -PASSWD = isolated-fixture-only -DB = cf_lab_ccnet -""") diff --git a/tests/e2e/native_local_edit_baseline_e2e.py b/tests/e2e/native_local_edit_baseline_e2e.py deleted file mode 100644 index 3353a881..00000000 --- a/tests/e2e/native_local_edit_baseline_e2e.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Native guarded manual-update baseline acceptance: original File ID wins. - -This is the isolated Server fixture, not real Browser/Agent HTTPS acceptance. -""" -import json -from pathlib import Path -from uuid import uuid4 - -import native_editing_e2e as lab -from seafile.rpcclient import SearpcError - - -def submit(expected_file, content, *, expected_head=None): - current_head = expected_head or lab.rpc.get_repo(lab.repo).head_cmmt_id - condition = lab.condition(current_head, expected_file, str(uuid4())) - condition.pop("editing") - condition.pop("path") - condition["expected_file_id"] = expected_file - file = Path("/lab/manual-baseline-publish.txt") - file.write_bytes(content) - return lab.rpc.cloudfile_put_file_with_barriers( - lab.repo, str(file), "/", lab.path[1:], lab.owner, - json.dumps(condition, separators=(",", ":"))) - - -baseline = lab.rpc.get_file_id_by_path(lab.repo, lab.path) -head = lab.rpc.get_repo(lab.repo).head_cmmt_id -assert baseline != lab.F1, "fixture must carry a changed file baseline" -try: - submit(lab.F1, b"unsafe overwrite from old local copy") -except SearpcError as error: - assert error.code == 601, (error.code, error) -else: - raise AssertionError("stale original File ID overwritten") -assert lab.rpc.get_repo(lab.repo).head_cmmt_id == head -assert lab.rpc.get_file_id_by_path(lab.repo, lab.path) == baseline -print("PASS stale local file ID refused even with current head",flush=True) - -updated = b"new local copy with correct expected original file ID\n" -result = submit(baseline, updated) -assert result == lab.rpc.get_file_id_by_path(lab.repo, lab.path) -assert lab.download(updated) -print("PASS original File ID condition publishes correct file, downloads byte-identically",flush=True) - -head_after = lab.rpc.get_repo(lab.repo).head_cmmt_id -try: - submit(baseline, updated) -except SearpcError as error: - assert error.code == 601, (error.code, error) -else: - raise AssertionError("identical bytes accepted stale base ID") -assert lab.rpc.get_repo(lab.repo).head_cmmt_id == head_after -print("PASS identical-content stale baseline cannot bypass version fence",flush=True) -Path("/lab/native-local-edit-result.json").write_text(json.dumps( - dict(verified="isolated_seafile_native", original_baseline=baseline, - updated_file_id=result, final_head=head_after, - stale_rejected=True, current_baseline_accepted=True, - identical_stale_rejected=True), indent=2)) diff --git a/tests/e2e/native_optimistic_cdc_e2e.py b/tests/e2e/native_optimistic_cdc_e2e.py deleted file mode 100644 index 85de3066..00000000 --- a/tests/e2e/native_optimistic_cdc_e2e.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Real optimistic CDC publication on the same resource after full publication. - -Executed by native_optimistic_agent_e2e.py in the isolated native test process, -sharing its paired device fixture and current repo. -""" -A = b"A" * (7 * 1024 * 1024) -B = b"B" * (8 * 1024 * 1024) -C = b"C" * (5 * 1024 * 1024) -blocks = (A, B, C) -large = b"".join(blocks) -ids = [hashlib.sha1(x).hexdigest() for x in blocks] - -def cdc_id(parts): - raw = '{"block_ids": [' + ", ".join( - json.dumps(hashlib.sha1(x).hexdigest()) for x in parts - ) + '], "size": ' + str(sum(map(len, parts))) + ', "type": 1, "version": 1}' - return hashlib.sha1(raw.encode()).hexdigest() - -# The same ordinary edit session now supports another deliberate cloud save. -# Native Branch must atomically advance its base File ID and session revision. -with lab.tx() as sql: - sql.execute("SELECT JSON_UNQUOTE(JSON_EXTRACT(snapshot,'$.base_version')),revision " - "FROM cf_edit_session WHERE session_id=%s",(issued.session_id,)) - advanced_base,advanced_revision=sql.fetchone() - assert advanced_base==published and int(advanced_revision)>int(row[9]), ( - advanced_base,advanced_revision,published,row[9]) - sql.execute("SELECT JSON_UNQUOTE(JSON_EXTRACT(s.snapshot,'$.resource_revision'))," - "CAST(r.revision AS CHAR) FROM cf_edit_session s " - "JOIN cf_resource r ON r.uid=%s " - "WHERE s.session_id=%s",(uid,issued.session_id)) - stored_resource_revision,current_resource_revision=sql.fetchone() - assert stored_resource_revision==current_resource_revision, ( - stored_resource_revision,current_resource_revision) - newer=issued - newerrow=sessions._row(sql,**owner,session_id=issued.session_id) - cdc_intent=str(uuid4()) - sql.execute("INSERT INTO cf_optimistic_upload_intent " - "(intent_id,session_id,device_id,repo_id,base_file_id,content_digest," - "snapshot_size,state,created_at) " - "VALUES(%s,%s,%s,%s,%s,%s,%s,'prepared',UTC_TIMESTAMP(6))", - (cdc_intent,newer.session_id,device,repo,published, - hashlib.sha256(large).hexdigest(),len(large))) -print("PASS ordinary edit session base/revision advanced after prior save",flush=True) - -for part, blockid in zip(blocks, ids): - snapshot_file = Path("/lab/optimistic-cdc-" + blockid) - snapshot_file.write_bytes(part) - try: - assert lab.rpc.cloudfile_stage_raw_block(repo, blockid, str(snapshot_file), lab.owner) == 0 - finally: - snapshot_file.unlink(missing_ok=True) -assert json.loads(lab.rpc.check_repo_blocks_missing(repo, json.dumps(ids))) == [] - -conditions = dict(condition, head_id=head, expected_file_id=published) -conditions["agent_upload"] = dict( - session_id=newer.session_id, device_id=device, - device_revision=str(newerrow[3]), session_revision=str(newerrow[9]), - intent_id=cdc_intent) - -old_head = lab.rpc.get_repo(repo).head_cmmt_id -try: - lab.rpc.cloudfile_publish_optimistic_blocks( - repo, json.dumps(list(reversed(ids))), str(len(large)), - "/", path[1:], lab.owner, json.dumps(conditions)) -except Exception: - pass -else: - raise AssertionError("Unordered optimistic CDC should not publish") -assert lab.rpc.get_repo(repo).head_cmmt_id == old_head -with lab.tx() as sql: - sql.execute("SELECT state FROM cf_optimistic_upload_intent WHERE intent_id=%s", (cdc_intent,)) - assert sql.fetchone()[0] == "prepared" - -cdc_published = lab.rpc.cloudfile_publish_optimistic_blocks( - repo, json.dumps(ids), str(len(large)), - "/", path[1:], lab.owner, json.dumps(conditions)) -assert cdc_published == cdc_id(blocks) == lab.rpc.get_file_id_by_path(repo, path) -with lab.tx() as sql: - sql.execute("SELECT state,result_file_id,result_commit_id " - "FROM cf_optimistic_upload_intent WHERE intent_id=%s", (cdc_intent,)) - durable_cdc = sql.fetchone() -assert durable_cdc[0] == "published" -assert durable_cdc[1] == cdc_published and len(durable_cdc[2]) == 40 -current_head = lab.rpc.get_repo(repo).head_cmmt_id -read_proof = lab.condition(current_head, cdc_published, str(uuid4())) -read_proof.pop("editing") -read_proof["path"] = path -for entry in read_proof["scopes"]: - if entry["type"] == "repo": - entry["external_id"] = repo -read_ticket = lab.rpc.seafile_cloudfile_issue_read_ticket( - repo, path, current_head, cdc_published, "download", lab.owner, - json.dumps(read_proof)) -read_result = lab.requests.get("http://127.0.0.1:8082/cloudfile/read", headers={ - "Authorization": "Bearer " + read_ticket, - "X-CloudFile-Filename": path[1:], "X-Forwarded-Proto": "https"}, timeout=30) -assert read_result.status_code == 200 and read_result.content == large -print("PASS ordinary Agent native CDC ordered blocks, durable Branch receipt, byte-identical HTTP read", flush=True) diff --git a/tests/identity_backchannel_runtime.py b/tests/identity_backchannel_runtime.py deleted file mode 100644 index 790b9a48..00000000 --- a/tests/identity_backchannel_runtime.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Hosted signed notification/fences and a separate native session deletion process.""" -from dataclasses import asdict -import json -import os -from pathlib import Path -import subprocess -import secrets -import sys -import time -from urllib.parse import urlencode - - -def exercise(*, db, fixture, prefix, initiate, complete, require, configuration_dir, claims, notification): - from django.conf import settings - from django.contrib.sessions.models import Session - from django.test import Client - from cloudfile_extensions.identity.native_session import LOGOUT_HINT_KEY - from cloudfile_extensions.jobs.store import JobStore - - checks = [] - config = Path(configuration_dir) / 'cf_logout_settings.py' - descriptor = os.open(config, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - with os.fdopen(descriptor, 'w') as output: - output.write('from seahub.settings import *\nENABLE_OAUTH = False\n' - 'CLOUDFILE_OIDC_ENABLED = True\nCLOUDFILE_OIDC_BACKCHANNEL_ENABLED = True\n') - output.write('CLOUDFILE_POLICY_CONFIG = ' + repr(settings.CLOUDFILE_POLICY_CONFIG) + '\n') - output.write('CLOUDFILE_OIDC_CONFIG = ' + repr(asdict(settings.CLOUDFILE_OIDC_CONFIG)) + '\n') - environment = {**os.environ, 'DJANGO_SETTINGS_MODULE': 'cf_logout_settings', - 'PYTHONPATH': str(configuration_dir) + ':' + ':'.join(sys.path[:4]), 'PYTHONDONTWRITEBYTECODE': '1'} - - def rows(sql, values=()): - with db.cursor() as cursor: - cursor.execute(sql, values) - return cursor.fetchall() - - def login(): - browser = Client(enforce_csrf_checks=True) - response = complete(browser, initiate(browser)) - require(response.status_code == 302, 'backchannel_login_' + str(response.status_code)) - return browser, browser.session.session_key, claims(browser.session[LOGOUT_HINT_KEY]['id_token']) - - def post(token, **headers): - # Fresh client: no implicit browser cookie or CSRF token is allowed. - return Client(enforce_csrf_checks=True).post(prefix + 'logout/backchannel/', - data=urlencode({'logout_token': token}), content_type='application/x-www-form-urlencoded', - secure=True, HTTP_HOST='cloudfile-smoke.invalid', **headers) - - def run_worker(expected_job): - result = subprocess.run([sys.executable, '/scripts/cloudfile-logout-worker.py', '--once'], - env=environment, capture_output=True, text=True, timeout=30) - require(result.returncode == 0, 'logout_process_exit_' + str(result.returncode)) - events = [json.loads(line) for line in result.stdout.splitlines()] - require(events == [{'state': 'ready'}, {'state': 'job_processed', 'job_id': expected_job}, - {'state': 'stopped'}], 'logout_process_lifecycle') - require(JobStore(db).get(expected_job)['status'] == 'succeeded', 'logout_job_succeeded') - - # One real IdP sid can own multiple application sessions. Use one to prove - # immediate request fencing and another untouched row to prove worker deletion. - fixture['session_sid'] = secrets.token_urlsafe(16) - first, first_key, first_claims = login() - same_sid, same_sid_key, same_sid_claims = login() - require(same_sid_claims['sid'] == first_claims['sid'] and same_sid_key != first_key, 'shared_idp_sid') - fixture.pop('session_sid') - second, second_key, second_claims = login() - target = {'sub': first_claims['sub'], 'sid': first_claims['sid']} - require(post(notification({**target, 'aud': 'wrong-audience'})).status_code == 401, 'wrong_aud_denied') - require(post(notification({**target, 'nonce': 'forbidden'})).status_code == 401, 'logout_nonce_denied') - require(post(first.session[LOGOUT_HINT_KEY]['id_token']).status_code == 401, 'id_token_not_logout_token') - token = notification(target) - require(post(token, HTTP_COOKIE='language=en').status_code == 400, 'notification_cookie_denied') - require(post(token, HTTP_AUTHORIZATION='Bearer alternate').status_code == 400, 'alternate_auth_denied') - require(not rows('SELECT job_id FROM cf_background_job WHERE kind=%s', ('identity.logout',)), - 'invalid_notifications_never_queued') - checks.append('backchannel_signature_purpose_and_cookie_boundary') - - directory_calls = fixture['directory_calls'] - fixture['mode'] = 'outage' - response = post(token) - require(response.status_code == 200 and response.content == b'', 'signed_cookie_free_ack') - require(fixture['directory_calls'] == directory_calls, 'intake_independent_of_employee_directory') - jobs = rows('SELECT job_id FROM cf_background_job WHERE kind=%s', ('identity.logout',)) - require(len(jobs) == 1 and JobStore(db).get(jobs[0][0])['status'] == 'queued', 'durable_ack_not_execution') - require(Session.objects.filter(session_key=first_key).exists(), 'ack_before_async_delete') - require(post(token).status_code == 200 and len(rows( - 'SELECT job_id FROM cf_background_job WHERE kind=%s', ('identity.logout',))) == 1, 'notification_idempotent_replay') - require(first.get('/api2/account/info/', secure=True, - HTTP_HOST='cloudfile-smoke.invalid').status_code in {401, 403}, 'sid_fence_before_worker') - require(Session.objects.filter(session_key=same_sid_key).exists(), 'untouched_sid_row_before_worker') - checks.append('backchannel_durable_fence_and_replay') - run_worker(jobs[0][0]) - require(not Session.objects.filter(session_key=first_key).exists() and not rows( - 'SELECT session_key FROM cf_oidc_session WHERE session_key=%s', (first_key,)), 'sid_native_delete_and_index') - require(not Session.objects.filter(session_key=same_sid_key).exists() and not rows( - 'SELECT session_key FROM cf_oidc_session WHERE session_key=%s', (same_sid_key,)), 'worker_actually_deleted_native_row') - require(Session.objects.filter(session_key=second_key).exists() and second.get('/api2/account/info/', - secure=True, HTTP_HOST='cloudfile-smoke.invalid').status_code == 200, 'other_sid_preserved') - checks.append('backchannel_standalone_sid_deletion') - - # Sub notification drains older sessions, while a later fresh login survives. - sub_token = notification({'sub': second_claims['sub']}) - require(post(sub_token).status_code == 200, 'subject_ack') - second_job = rows('SELECT job_id FROM cf_background_job WHERE kind=%s AND status=%s', - ('identity.logout', 'queued')) - require(len(second_job) == 1, 'subject_job') - fixture['mode'] = 'active' - time.sleep(1.05) # Actual new ID Token iat exceeds accepted notification cutoff. - fresh, fresh_key, fresh_claims = login() - require(fresh_claims['iat'] > second_claims['iat'], 'fresh_authentication_after_cutoff') - run_worker(second_job[0][0]) - require(not Session.objects.filter(session_key=second_key).exists() and not rows( - 'SELECT session_key FROM cf_oidc_session WHERE session_key=%s', (second_key,)), 'subject_native_delete') - require(Session.objects.filter(session_key=fresh_key).exists() and fresh.get('/api2/account/info/', - secure=True, HTTP_HOST='cloudfile-smoke.invalid').status_code == 200, 'late_worker_preserves_fresh_login') - checks.append('backchannel_subject_cutoff_and_later_login') - with config.open('a') as output: - output.write('CLOUDFILE_OIDC_BACKCHANNEL_ENABLED = False\n') - result = subprocess.run([sys.executable, '/scripts/cloudfile-logout-worker.py', '--once'], - env=environment, capture_output=True, text=True, timeout=30) - require(result.returncode == 1 and not result.stdout.strip(), 'disabled_logout_worker_refused') - checks.append('backchannel_disabled_worker_refused') - return checks diff --git a/tests/identity_logout_runtime.py b/tests/identity_logout_runtime.py deleted file mode 100644 index 4fba4049..00000000 --- a/tests/identity_logout_runtime.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Real native sessions and RP protocol transport in the disposable identity fixture.""" -from html.parser import HTMLParser -import secrets -import time -from urllib.parse import urlsplit - - -class LogoutForm(HTMLParser): - def __init__(self): - super().__init__() - self.action = self.method = None - self.fields = {} - self.forms = 0 - - def handle_starttag(self, tag, attrs): - values = dict(attrs) - if tag == 'form': - self.forms += 1 - self.action, self.method = values.get('action'), values.get('method') - elif tag == 'input': - name = values.get('name') - if name in self.fields: - raise ValueError('duplicate logout form input') - self.fields[name] = values.get('value') - - -def exercise(*, db, fixture, origin, prefix, initiate, complete, require, ca_bundle): - from django.conf import settings - from django.contrib.sessions.models import Session - from django.test import Client - import requests - from seahub.auth import SESSION_KEY - from cloudfile_extensions.identity.native_session import LOGOUT_HINT_KEY - from cloudfile_extensions.identity.logout_state import LOGOUT_COOKIE - - checks = [] - - def indexed(key): - with db.cursor() as cursor: - cursor.execute('SELECT COUNT(*) FROM cf_oidc_session WHERE session_key=%s', (key,)) - return cursor.fetchone()[0] == 1 - - def expire(browser, response): - for name, cookie in response.cookies.items(): - if str(cookie['max-age']) == '0' and name in browser.cookies: - del browser.cookies[name] - - def login(browser=None): - browser = browser or Client(enforce_csrf_checks=True) - browser.get('/accounts/login/', secure=True, HTTP_HOST='cloudfile-smoke.invalid') - response = complete(browser, initiate(browser)) - require(response.status_code == 302, 'rp_login_' + str(response.status_code)) - require(browser.session.get(SESSION_KEY), 'rp_authenticated_session') - require(LOGOUT_HINT_KEY in browser.session, 'verified_hint_server_side') - require(indexed(browser.session.session_key), 'rp_session_indexed') - return browser - - def post_logout(browser, *, csrf=True): - headers = {'HTTP_ORIGIN': 'https://cloudfile-smoke.invalid'} - if csrf: - headers['HTTP_X_CSRFTOKEN'] = browser.cookies[settings.CSRF_COOKIE_NAME].value - response = browser.post(prefix + 'logout/idp/', data='', - content_type='application/octet-stream', secure=True, - HTTP_HOST='cloudfile-smoke.invalid', **headers) - expire(browser, response) - return response - - def carrier(browser): - old_key = browser.session.session_key - hint = browser.session[LOGOUT_HINT_KEY]['id_token'] - response = post_logout(browser) - require(response.status_code == 200 and 'Location' not in response, 'rp_post_form_response') - require(not Session.objects.filter(session_key=old_key).exists() and not indexed(old_key), - 'rp_native_session_and_index_deleted') - form = LogoutForm() - form.feed(response.content.decode()) - require(form.forms == 1 and form.method == 'post' and form.action == origin + '/logout', - 'fixed_https_logout_form') - require(set(form.fields) == {'id_token_hint', 'post_logout_redirect_uri', 'state'} - and form.fields['id_token_hint'] == hint, 'verified_hint_only_in_post_body') - require(response["Content-Security-Policy"].startswith("default-src 'none';") - and 'form-action ' + origin + ';' in response['Content-Security-Policy'], 'rp_csp_origin') - require(response.cookies[LOGOUT_COOKIE]['secure'] and response.cookies[LOGOUT_COOKIE]['httponly'] - and response.cookies[LOGOUT_COOKIE]['samesite'] == 'Lax', 'rp_browser_cookie') - return form, old_key - - def provider(form): - with requests.Session() as transport: - transport.trust_env = False - return transport.post(form.action, data=form.fields, verify=ca_bundle, - allow_redirects=False, timeout=10) - - browser = login() - preserved_key = browser.session.session_key - require(post_logout(browser, csrf=False).status_code == 403, 'rp_csrf_required') - require(Session.objects.filter(session_key=preserved_key).exists() and indexed(preserved_key), - 'csrf_rejection_preserves_session') - checks.append('rp_csrf_and_server_hint') - other = login() - other_key = other.session.session_key - form, old_key = carrier(browser) - require(Session.objects.filter(session_key=other_key).exists() and indexed(other_key), - 'rp_local_logout_only_current_session') - require(other.get('/api2/account/info/', secure=True, - HTTP_HOST='cloudfile-smoke.invalid').status_code == 200, 'other_session_still_usable') - response = provider(form) - require(response.status_code == 302 and fixture['rp_requests'] == 1, 'rp_https_provider_post') - returned = urlsplit(response.headers['Location']) - require(returned.scheme == 'https' and returned.netloc == 'cloudfile-smoke.invalid', 'fixed_return_origin') - return_path = returned.path + '?' + returned.query - require('id_token_hint' not in returned.query, 'hint_not_in_return_url') - checks.append('rp_local_delete_and_https_post') - - attacker = Client(enforce_csrf_checks=True) - attacker.cookies[LOGOUT_COOKIE] = secrets.token_urlsafe(32) - require(attacker.get(return_path, secure=True, HTTP_HOST='cloudfile-smoke.invalid').status_code == 401, - 'rp_return_wrong_browser_denied') - binding = browser.cookies[LOGOUT_COOKIE].value - time.sleep(1.05) # Fresh actual IdP iat exceeds the native logout cutoff. - login(browser) - new_key = browser.session.session_key - require(new_key != old_key, 'intervening_login_new_session') - response = browser.get(return_path, secure=True, HTTP_HOST='cloudfile-smoke.invalid') - require(response.status_code == 200 and response.json() == {'rp_returned': True, 'idp_logged_out': None}, - 'rp_return_correlation_only') - expire(browser, response) - require(Session.objects.filter(session_key=new_key).exists() and indexed(new_key) - and browser.get('/api2/account/info/', secure=True, - HTTP_HOST='cloudfile-smoke.invalid').status_code == 200, 'return_preserves_intervening_login') - browser.cookies[LOGOUT_COOKIE] = binding - require(browser.get(return_path, secure=True, HTTP_HOST='cloudfile-smoke.invalid').status_code == 401, - 'rp_consumed_state_replay_denied') - checks.append('rp_browser_state_and_delayed_return') - - # The local session is already terminated before handing off to the IdP. - browser.cookies.pop(LOGOUT_COOKIE, None) - form, failed_key = carrier(browser) - fixture['rp_mode'] = 'outage' - require(provider(form).status_code == 503 and fixture['rp_requests'] == 2, 'rp_real_provider_outage') - require(not Session.objects.filter(session_key=failed_key).exists() and not indexed(failed_key), - 'idp_outage_does_not_restore_local_session') - require(browser.get('/api2/account/info/', secure=True, - HTTP_HOST='cloudfile-smoke.invalid').status_code in {401, 403}, 'idp_outage_local_access_denied') - checks.append('rp_idp_failure_keeps_local_session_deleted') - return checks diff --git a/tests/identity_provisioning_runtime.py b/tests/identity_provisioning_runtime.py deleted file mode 100644 index d33966aa..00000000 --- a/tests/identity_provisioning_runtime.py +++ /dev/null @@ -1,187 +0,0 @@ -"""JIT scenarios for the disposable CE14 identity fixture, not a unit fake.""" - - -def exercise(*, db, fixture, origin, prefix, initiate, complete, require, groups, configuration_dir): - from dataclasses import asdict - import json - import os - import re - from pathlib import Path - import subprocess - import sys - import selectors - import signal - from django.conf import settings - from django.test import Client - from seahub.auth import SESSION_KEY - from seahub.base.accounts import User - from seaserv import ccnet_api - from cloudfile_extensions.jobs.store import JobStore - - checks = [] - # New process reads the same trusted configuration through Django's normal - # app-ready validation. This private fixture file is deleted with its CA. - config = Path(configuration_dir) / 'cf_jit_settings.py' - descriptor = os.open(config, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - with os.fdopen(descriptor, 'w') as output: - output.write('from seahub.settings import *\nENABLE_OAUTH = False\n' - 'CLOUDFILE_OIDC_ENABLED = True\nCLOUDFILE_OIDC_JIT_ENABLED = True\n') - output.write('CLOUDFILE_POLICY_CONFIG = ' + repr(settings.CLOUDFILE_POLICY_CONFIG) + '\n') - output.write('CLOUDFILE_OIDC_CONFIG = ' + repr(asdict(settings.CLOUDFILE_OIDC_CONFIG)) + '\n') - environment = {**os.environ, 'DJANGO_SETTINGS_MODULE': 'cf_jit_settings', - 'PYTHONPATH': str(configuration_dir) + ':' + ':'.join(sys.path[:4]), - 'PYTHONDONTWRITEBYTECODE': '1'} - worker_script = os.environ.get('CF_JIT_WORKER_SCRIPT', '/scripts/cloudfile-jit-worker.py') - - def rows(sql, values=()): - with db.cursor() as cursor: - cursor.execute(sql, values) - return cursor.fetchall() - - def pending(browser, token, expected): - response = browser.get(prefix + 'pending/', secure=True, HTTP_HOST='cloudfile-smoke.invalid', - HTTP_AUTHORIZATION='CloudFilePending ' + token) - require(response.status_code == 200, 'pending_http_' + str(response.status_code)) - value = response.json() - require(set(value) == {'job_id', 'status', 'retryable'} and value['status'] == expected, - 'pending_status_' + expected) - require(not browser.session.get(SESSION_KEY), 'pending_never_authenticates') - return value - - def queued(user_id): - fixture.update(mode='active', claim_user=user_id, claim_sub='subject-' + user_id) - browser = Client(enforce_csrf_checks=True) - response = complete(browser, initiate(browser)) - require(response.status_code == 202, 'jit_callback_' + str(response.status_code)) - value = response.json() - require(set(value) == {'job_id', 'status', 'status_token'} and value['status'] == 'pending', - 'bounded_callback') - require(pending(browser, value['status_token'], 'queued')['job_id'] == value['job_id'], 'job_binding') - foreign = Client().get(prefix + 'pending/', secure=True, HTTP_HOST='cloudfile-smoke.invalid', - HTTP_AUTHORIZATION='CloudFilePending ' + value['status_token']) - require(foreign.status_code == 401, 'pending_foreign_browser_denied') - return browser, value - - def identity(user_id): - users = rows('SELECT user FROM seahub_db.profile_profile WHERE login_id=%s', (user_id,)) - require(len(users) == 1, 'one_profile') - username = users[0][0] - accounts = rows('SELECT passwd,is_active,is_staff FROM ccnet_db.EmailUser WHERE email=%s', (username,)) - require(accounts == (('!', 1, 0),), 'active_native_no_password_or_admin') - require(User.objects.get(email=username).username == username, 'rpc_reload_jit_user') - require(rows('SELECT COUNT(*) FROM cf_audit_event WHERE operation=%s AND operator=%s', - ('identity.created', user_id))[0][0] == 1, 'one_identity_created_audit') - require(rows('SELECT COUNT(*) FROM seahub_db.social_auth_usersocialauth WHERE username=%s', - (username,))[0][0] == 1, 'one_oidc_binding') - return username - - def worker(job_id, status): - result = subprocess.run([sys.executable, worker_script, '--once'], - env=environment, capture_output=True, text=True, timeout=30) - if result.returncode != 0: - lines = result.stderr.strip().splitlines() - kind = lines[-1].split(':', 1)[0] if lines else 'no_stderr' - frames = re.findall(r'File "([^"]+)", line ([0-9]+)', result.stderr) - location = (frames[-1][0].rsplit('/', 1)[-1] + ':' + frames[-1][1]) if frames else 'no_frame' - diagnostic = next((line.removeprefix('CF_WORKER_DIAGNOSTIC=') - for line in lines if line.startswith('CF_WORKER_DIAGNOSTIC=')), '') - host_diagnostic = next((line.removeprefix('CF_HOST_DIAGNOSTIC=') - for line in lines if line.startswith('CF_HOST_DIAGNOSTIC=')), '') - require(False, 'standalone_worker_exit_' + str(result.returncode) + '_' + kind + '_' + location + '_' + diagnostic + '_' + host_diagnostic) - events = [json.loads(line) for line in result.stdout.splitlines()] - require(events == [{'state': 'ready'}, {'state': 'job_processed', 'job_id': job_id}, - {'state': 'stopped'}], 'standalone_worker_lifecycle') - job = JobStore(db).get(job_id) - require(job['status'] == status, 'worker_' + status + '_' + str(job.get('error_code'))) - return job - - def authenticated(user_id, username): - browser = Client(enforce_csrf_checks=True) - response = complete(browser, initiate(browser)) - require(response.status_code == 302, 'fresh_oidc_login_' + str(response.status_code)) - require(browser.session.get(SESSION_KEY) == username, 'fresh_session_native_identity') - require(browser.get('/api2/account/info/', secure=True, - HTTP_HOST='cloudfile-smoke.invalid').status_code == 200, 'authenticated_rpc_account') - require({row[0] for row in rows('SELECT group_id FROM ccnet_db.GroupUser WHERE user_name=%s', - (username,))} == groups, 'jit_projected_org_role') - require({group.id for group in ccnet_api.get_groups(username)} == groups, - 'jit_rpc_projected_org_role') - identity(user_id) - - user_id = 'jit-success' - browser, value = queued(user_id) - require(not rows('SELECT user FROM seahub_db.profile_profile WHERE login_id=%s', (user_id,)), - 'request_does_not_create_identity') - checks.append('jit_pending_browser_proof') - worker(value['job_id'], 'succeeded') - username = identity(user_id) - pending(browser, value['status_token'], 'succeeded') - status = browser.get('/api2/account/info/', secure=True, - HTTP_HOST='cloudfile-smoke.invalid').status_code - require(status in {401, 403}, 'worker_no_browser_session_status_' + str(status)) - checks.append('jit_native_worker_and_projection') - authenticated(user_id, username) - authenticated(user_id, username) - require(rows('SELECT COUNT(*) FROM cf_background_job WHERE actor=%s AND kind=%s', - (user_id, 'identity.provision'))[0][0] == 1, 'one_success_job') - checks.append('jit_fresh_login_and_reuse') - - user_id = 'jit-recovery' - fixture['fail_fetch'] = 2 # HTTPS failure after the identity transaction commits. - browser, value = queued(user_id) - failure = worker(value['job_id'], 'failed') - require(failure['error_code'] == 'UPSTREAM_UNAVAILABLE' and failure['attempts'] == 1 - and fixture['calls_by_user'][user_id] == 2, 'actual_second_https_fetch_failure') - username = identity(user_id) - require(pending(browser, value['status_token'], 'failed')['retryable'], 'failed_retryable') - checks.append('jit_failure_after_identity_commit') - fixture.pop('fail_fetch') - retry_browser, retry = queued(user_id) # Fresh verified OIDC requeues the same job. - require(retry['job_id'] == value['job_id'], 'retry_same_job') - recovered = worker(retry['job_id'], 'succeeded') - require(recovered['attempts'] == 2 and recovered['error_code'] is None, - 'recovered_attempt_and_error_clear') - require(identity(user_id) == username, 'recovery_same_native_user') - pending(retry_browser, retry['status_token'], 'succeeded') - authenticated(user_id, username) - checks.append('jit_resume_without_duplicate_identity') - - user_id = 'jit-disabled' - browser, value = queued(user_id) - fixture['mode'] = 'disabled' - failure = worker(value['job_id'], 'failed') - require(failure['error_code'] == 'SUBJECT_DISABLED', 'disabled_directory_failure_code') - require(not rows('SELECT user FROM seahub_db.profile_profile WHERE login_id=%s', (user_id,)), - 'disabled_never_created') - pending(browser, value['status_token'], 'failed') - checks.append('jit_disabled_subject_not_created') - # An idle standalone worker also drains its owned connection/Redis host. - result = subprocess.run([sys.executable, worker_script, '--once'], - env=environment, capture_output=True, text=True, timeout=30) - require(result.returncode == 0 and [json.loads(line) for line in result.stdout.splitlines()] == - [{'state': 'ready'}, {'state': 'stopped'}], 'standalone_idle_shutdown') - checks.append('jit_standalone_process_lifecycle') - process = subprocess.Popen([sys.executable, worker_script], env=environment, - stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - try: - with selectors.DefaultSelector() as selector: - selector.register(process.stdout, selectors.EVENT_READ) - require(bool(selector.select(timeout=15)), 'idle_worker_ready_deadline') - require(json.loads(process.stdout.readline()) == {'state': 'ready'}, 'idle_worker_ready') - process.send_signal(signal.SIGTERM) - stdout, stderr = process.communicate(timeout=15) - require(process.returncode == 0 and [json.loads(line) for line in stdout.splitlines()] == - [{'state': 'stopped'}], 'idle_worker_signal_shutdown') - finally: - if process.poll() is None: - process.kill() - process.communicate(timeout=10) - checks.append('jit_sigterm_shutdown') - # Explicit disablement rejects before claiming any queued job. - with config.open('a') as output: - output.write('CLOUDFILE_OIDC_JIT_ENABLED = False\n') - result = subprocess.run([sys.executable, worker_script, '--once'], - env=environment, capture_output=True, text=True, timeout=30) - require(result.returncode == 1 and not result.stdout.strip(), 'disabled_worker_never_ready') - checks.append('jit_disabled_process_refused') - return checks diff --git a/tests/probe_identity_runtime.py b/tests/probe_identity_runtime.py deleted file mode 100644 index fa4bce8c..00000000 --- a/tests/probe_identity_runtime.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Run only inside smoke_ce14_runtime's disposable, initialized CE14 container. - -Actual TLS OIDC/directory fixture, SQL/native RPC and full Django request chain. -This is not an Authentik deployment or real browser/TLS ingress acceptance. -""" -import base64 -from datetime import datetime, timedelta, timezone -import hashlib -import ipaddress -import json -import os -from pathlib import Path -import secrets -import ssl -import sys -import tempfile -import threading -import time -import traceback -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import parse_qs, urlencode, urlsplit - - -def require(condition, label): - if not condition: - raise RuntimeError('identity_check=' + label) - - -def run(): - require(os.environ.get('CF_DISPOSABLE_IDENTITY_PROBE') == 'true', 'disposable_marker') - scenario = os.environ.get('CF_IDENTITY_SCENARIO', 'prebound') - require(scenario in {'prebound', 'provisioning', 'logout', 'backchannel', 'transfer', 'web', 'annotations', 'audit', 'search'}, 'identity_scenario') - package = '/opt/seafile/seafile-server-latest' - os.chdir(package + '/seahub') - sys.path[:0] = [package + '/seahub', package + '/seahub/thirdpart', - package + '/seafile/lib/python3/site-packages', package + '/pro/python'] - os.environ.update(DJANGO_SETTINGS_MODULE='seahub.settings', SEAHUB_DIR=package + '/seahub', - SEAFES_DIR=package + '/pro/python', SEAFILE_DATA_DIR='/shared/seafile/seafile-data', - SEAFILE_CENTRAL_CONF_DIR='/shared/seafile/conf', - SEAFILE_RPC_PIPE_PATH=package + '/runtime') - import django - django.setup() - from django.conf import settings - from django.contrib.sessions.models import Session - from django.test import Client - from django.urls import clear_url_caches - from cryptography import x509 - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import rsa - from cryptography.x509.oid import NameOID - import jwt - import pymysql - import redis - import requests - from seaserv import ccnet_api - from seahub.base.accounts import User - from seahub.profile.models import Profile - from seahub.auth import SESSION_KEY, BACKEND_SESSION_KEY - from cloudfile_extensions.authorization import gunicorn - from cloudfile_extensions.identity.configuration import configure_oidc_host, LOGIN_PREFIX - from cloudfile_extensions.identity.management import IdentityManagement - from cloudfile_extensions.identity.native_session import BACKEND - from cloudfile_extensions.schema.runner import SchemaRunner - - checks = [] - stage = 'schema' - db = pymysql.connect(host='db', user=os.environ['SEAFILE_MYSQL_DB_USER'], - password=os.environ['SEAFILE_MYSQL_DB_PASSWORD'], database='seafile_db', - autocommit=True, charset='utf8mb4') - cache = redis.Redis(host='identity-redis', port=6379, socket_timeout=2) - server = None - schema_failure = None - try: - runner = SchemaRunner(db) - original_query = runner._query - def diagnostic_query(sql, parameters=()): - nonlocal schema_failure - try: - return original_query(sql, parameters) - except Exception as failure: - schema_failure = (type(failure).__name__, failure.args[0] if failure.args and isinstance(failure.args[0], int) else None) - raise - runner._query = diagnostic_query - runner.apply() - SchemaRunner(db).require_current() - checks.append(stage) - stage = 'native_fixture' - manager = User.objects.get(email='admin@smoke.invalid') - Profile.objects.add_or_update(manager.username, login_id='fixture-manager') - user = User.objects.create_user('oidc-user@smoke.invalid', password=secrets.token_hex(24), - is_active=True, is_staff=False) - # Seed real native groups through existing RPC; only mapping ownership - # is fixture data. The normal projection owns its SQL effects. - dept = ccnet_api.create_group('Fixture department', manager.username, parent_group_id=-1) - role = ccnet_api.create_group('Fixture role', manager.username) - manual = ccnet_api.create_group('Fixture manual group', manager.username) - ccnet_api.group_add_member(manual, manager.username, user.username) - with db.cursor() as cursor: - for kind, namespace, external_id, group in ( - ('dept', 'directory', 'dept-1', dept), ('group', 'role', 'role-1', role)): - cursor.execute('INSERT INTO cf_sso_group_map(provider,subject_type,namespace,external_id,group_id,name) ' - 'VALUES(%s,%s,%s,%s,%s,%s)', ('etech', kind, namespace, external_id, group, external_id)) - checks.append(stage) - stage = 'tls_fixture' - signing_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - tls_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, 'CloudFile disposable IdP')]) - now = datetime.now(timezone.utc) - certificate = (x509.CertificateBuilder().subject_name(name).issuer_name(name) - .public_key(tls_key.public_key()).serial_number(x509.random_serial_number()) - .not_valid_before(now - timedelta(minutes=1)).not_valid_after(now + timedelta(hours=1)) - .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) - .add_extension(x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address('127.0.0.1'))]), - critical=False).sign(tls_key, hashes.SHA256())) - with tempfile.TemporaryDirectory(prefix='cf-identity-') as temporary: - ca_file = Path(temporary) / 'ca.pem' - key_file = Path(temporary) / 'tls.pem' - ca_file.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) - key_file.write_bytes(tls_key.private_bytes(serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) - key_file.chmod(0o600) - codes, tokens = {}, {} - fixture = {'mode': 'active', 'claim_user': 'fixture-user-1', - 'claim_sub': 'fixture-subject-1', 'directory_calls': 0, 'calls_by_user': {}, - 'issued_ids': set(), 'rp_requests': 0, 'rp_mode': 'active'} - origin = '' - redirect_uri = 'https://cloudfile-smoke.invalid/' + LOGIN_PREFIX + 'callback/' - client_secret = secrets.token_hex(32) - directory_secret = secrets.token_hex(32) - - class Handler(BaseHTTPRequestHandler): - def log_message(self, *args): - pass # Codes, bearer tokens and credentials never go to diagnostics. - - def respond(self, value, status=200): - body = json.dumps(value).encode() - self.send_response(status) - self.send_header('Content-Type', 'application/json') - self.send_header('Content-Length', str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_GET(self): - path = urlsplit(self.path) - if path.path == '/authorize': - params = parse_qs(path.query) - required = {'state', 'nonce', 'code_challenge', 'code_challenge_method', - 'redirect_uri', 'client_id', 'response_type', 'scope'} - if (set(params) != required or any(len(v) != 1 for v in params.values()) - or params['code_challenge_method'] != ['S256'] - or params['redirect_uri'] != [redirect_uri] - or params['client_id'] != ['fixture-client'] - or params['response_type'] != ['code']): - return self.respond({}, 400) - code = secrets.token_urlsafe(32) - codes[code] = {key: value[0] for key, value in params.items()} - self.send_response(302) - self.send_header('Location', redirect_uri + '?' + urlencode({ - 'state': params['state'][0], 'code': code})) - self.send_header('Content-Length', '0') - self.end_headers() - elif path.path == '/jwks': - jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(signing_key.public_key())) - self.respond({'keys': [{**jwk, 'kid': 'fixture-key', 'use': 'sig', 'alg': 'RS256'}]}) - elif path.path == '/userinfo': - token = self.headers.get('Authorization', '').removeprefix('Bearer ') - if token not in tokens: - return self.respond({}, 401) - self.respond(tokens[token]) - elif (path.path.startswith('/directory/users/') and path.path.endswith('/context') - and path.path.count('/') == 4): - user_id = path.path.split('/')[3] - fixture['directory_calls'] += 1 - count = fixture['calls_by_user'].get(user_id, 0) + 1 - fixture['calls_by_user'][user_id] = count - if self.headers.get('Authorization') != 'Bearer ' + directory_secret: - return self.respond({}, 401) - if fixture['mode'] == 'outage' or count == fixture.get('fail_fetch'): - return self.respond({}, 503) - disabled = fixture['mode'] == 'disabled' - self.respond({'userId': user_id, - 'status': 'disabled' if disabled else 'active', 'attributes': {}, - 'organizations': [] if disabled else [{'namespace': 'directory', - 'external_id': 'dept-1', 'is_primary': True}], - 'organization_ancestors': [], - 'roles': [] if disabled else [{'namespace': 'role', 'external_id': 'role-1'}], - 'revision': '1', 'etag': 'fixture-v1', - 'generated_at': datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}) - else: - self.respond({}, 404) - - def do_POST(self): - if self.path == '/logout': - length = int(self.headers.get('Content-Length', '0')) - if not 0 < length <= 65536: - return self.respond({}, 400) - params = parse_qs(self.rfile.read(length).decode()) - if (set(params) != {'id_token_hint', 'post_logout_redirect_uri', 'state'} - or any(len(value) != 1 for value in params.values()) - or params['id_token_hint'][0] not in fixture['issued_ids'] - or params['post_logout_redirect_uri'] != [ - 'https://cloudfile-smoke.invalid/' + LOGIN_PREFIX + 'logout/return/']): - return self.respond({}, 400) - jwt.decode(params['id_token_hint'][0], signing_key.public_key(), - algorithms=['RS256'], issuer=origin, audience='fixture-client') - fixture['rp_requests'] += 1 - if fixture['rp_mode'] == 'outage': - return self.respond({}, 503) - self.send_response(302) - self.send_header('Location', params['post_logout_redirect_uri'][0] + '?' + urlencode({ - 'state': params['state'][0]})) - self.send_header('Content-Length', '0') - self.end_headers() - return - if self.path != '/token': - return self.respond({}, 404) - length = int(self.headers.get('Content-Length', '0')) - if not 0 < length <= 8192: - return self.respond({}, 400) - params = parse_qs(self.rfile.read(length).decode()) - code = params.get('code', [''])[0] - transaction = codes.pop(code, None) - basic = 'Basic ' + base64.b64encode(('fixture-client:' + client_secret).encode()).decode() - verifier = params.get('code_verifier', [''])[0] - challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode() - if (transaction is None or self.headers.get('Authorization') != basic - or challenge != transaction['code_challenge'] - or params.get('redirect_uri') != [redirect_uri] - or params.get('grant_type') != ['authorization_code']): - return self.respond({}, 400) - token = secrets.token_urlsafe(32) - tokens[token] = {'sub': fixture['claim_sub'], 'userId': fixture['claim_user']} - issued = int(time.time()) - id_token = jwt.encode({'iss': origin, 'aud': 'fixture-client', - 'sub': fixture['claim_sub'], 'userId': fixture['claim_user'], - 'iat': issued, 'exp': issued + 300, 'nonce': transaction['nonce'], - 'sid': fixture.get('session_sid') or secrets.token_urlsafe(16)}, signing_key, algorithm='RS256', - headers={'kid': 'fixture-key'}) - fixture['issued_ids'].add(id_token) - self.respond({'access_token': token, 'token_type': 'Bearer', - 'expires_in': 300, 'id_token': id_token}) - - server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) - origin = 'https://127.0.0.1:' + str(server.server_port) - tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - tls.load_cert_chain(str(ca_file), str(key_file)) - server.socket = tls.wrap_socket(server.socket, server_side=True) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - settings.CLOUDFILE_POLICY_CONFIG = {'database': {'host': 'db', - 'user': os.environ['SEAFILE_MYSQL_DB_USER'], 'name': 'seafile_db', - 'password': os.environ['SEAFILE_MYSQL_DB_PASSWORD']}, - 'redis': {'host': 'identity-redis', 'port': 6379, 'password': ''}, - 'provider': 'etech', 'native_schema': 'ccnet_db', 'identity_schema': 'seahub_db', - 'directory_url': origin + '/directory', 'directory_ca_bundle': str(ca_file), - 'directory_bearer_token': directory_secret, 'attribute_allowlist': [], - 'core_library': package + '/seafile/lib/libcloudfile_acl.so.1', 'cloud_mode': False} - settings.CLOUDFILE_OIDC_ENABLED = True - settings.CLOUDFILE_OIDC_JIT_ENABLED = scenario == 'provisioning' - settings.CLOUDFILE_OIDC_CONFIG = {'issuer': origin, 'client_id': 'fixture-client', - 'client_secret': client_secret, 'redirect_uri': redirect_uri, - 'authorization_url': origin + '/authorize', 'token_url': origin + '/token', - 'userinfo_url': origin + '/userinfo', 'jwks_url': origin + '/jwks', - 'ca_bundle': str(ca_file)} - if scenario in {'logout', 'backchannel'}: - settings.CLOUDFILE_OIDC_CONFIG.update(end_session_url=origin + '/logout', - post_logout_redirect_uri='https://cloudfile-smoke.invalid/' + LOGIN_PREFIX + 'logout/return/') - settings.CLOUDFILE_OIDC_BACKCHANNEL_ENABLED = scenario == 'backchannel' - settings.ENABLE_OAUTH = False - configure_oidc_host(settings) - if scenario in {'transfer', 'web', 'audit', 'search'}: - machine_secret, delegation_secret = secrets.token_hex(32), secrets.token_hex(32) - settings.CLOUDFILE_POLICY_CONFIG.update( - service_credentials={'login-v1': {'service_id': 'etech-login', - 'issuer': 'etech-login', 'audience': 'cloudfile-authorization', - 'secret': machine_secret, 'scopes': ['subject.refresh', 'user.delegation.issue'], - 'maximum_ttl': 120}}, refresh_provider_grants={'etech-login': ['etech']}, - delegation_signing_keys={'etech-login': {'kid': 'delegation-v1', - 'issuer': 'cloudfile', 'audience': 'cloudfile-download', 'secret': delegation_secret}}) - settings.CLOUDFILE_AUTHORIZATION_ENABLED = True - settings.CLOUDFILE_TRANSFER_ENABLED = scenario in {'transfer', 'web'} - if scenario in {'annotations', 'search'}: - from seaserv import seafile_api - from cloudfile_extensions.resources.native import NativeResourceReader - settings.CLOUDFILE_ANNOTATIONS_ENABLED = True - # The base fixture explicitly disables OIDC discovery; opt in - # here without weakening the production explicit-disable rule. - settings.CLOUDFILE_CAPABILITIES = dict(settings.CLOUDFILE_CAPABILITIES, **{'auth.oidc': True}) - settings.CLOUDFILE_SYSTEM_TAG_PROVIDER_ENABLED = True - from cloudfile_extensions.identity.service_tokens import ServiceCredential - settings.CLOUDFILE_SYSTEM_TAG_PROVIDER_CREDENTIALS = {'tags-v1': ServiceCredential( - 'etech-tags', 'etech-tags', 'cloudfile-tags', secrets.token_bytes(32), - frozenset({'tags.system.write'}))} - settings.CLOUDFILE_SYSTEM_TAG_PROVIDER_GRANTS = {'etech-tags': dict( - provider='etech', namespaces=['etech:project'])} - settings.CLOUDFILE_RESOURCE_SECRET = secrets.token_bytes(32) - settings.CLOUDFILE_RESOURCE_LIFECYCLE_READER = NativeResourceReader(seafile_api) - if scenario == 'search': - settings.CLOUDFILE_RESOURCE_SEARCH_ENABLED = True - settings.CLOUDFILE_RESOURCE_SEARCH_CONFIG = dict(endpoint='http://meilisearch:7700', - index='fixture_resources', generation='fixture-generation', read_key=os.environ['CF_SEARCH_FIXTURE_KEY'], - write_key=os.environ['CF_SEARCH_FIXTURE_KEY'], cursor_secret=secrets.token_bytes(32)) - if scenario == 'audit': - result_root = Path(temporary) / 'audit-results' - result_root.mkdir(mode=0o700) - settings.CLOUDFILE_AUTHORIZATION_ENABLED = True - settings.CLOUDFILE_AUDIT_QUERY_ENABLED = True - settings.CLOUDFILE_AUDIT_EXPORT_ENABLED = True - settings.CLOUDFILE_AUDIT_CURSOR_SECRET = secrets.token_bytes(32) - settings.CLOUDFILE_AUDIT_RESULT_ROOT = str(result_root) - gunicorn.post_worker_init(None) - clear_url_caches() - management = IdentityManagement(db, native_schema='ccnet_db', identity_schema='seahub_db', - directory_provider='etech', actor_user_id='fixture-manager', request_id='fixture-prebind') - stage = 'authorized_prebind' - management.prebind(issuer=origin, subject='fixture-subject-1', user_id='fixture-user-1', - username=user.username, reason='Disposable identity runtime fixture') - checks.append(stage) - prefix = '/' + LOGIN_PREFIX - - def initiate(browser): - begin = browser.get(prefix + 'begin/', secure=True, HTTP_HOST='cloudfile-smoke.invalid') - require(begin.status_code == 302, 'begin_status_' + str(begin.status_code)) - with requests.Session() as transport: - transport.trust_env = False - authorize = transport.get(begin['Location'], verify=str(ca_file), - allow_redirects=False, timeout=10) - require(authorize.status_code == 302, 'authorize') - callback = urlsplit(authorize.headers['Location']) - return callback.path + '?' + callback.query - - def complete(browser, callback): - response = browser.get(callback, secure=True, HTTP_HOST='cloudfile-smoke.invalid') - # Django Client retains expired cookies; an actual browser - # removes them. Model deletion without changing host guards. - for name, cookie in response.cookies.items(): - if str(cookie['max-age']) == '0' and name in browser.cookies: - del browser.cookies[name] - return response - - if scenario == 'annotations': - stage = 'annotations_http' - from annotations_http_runtime import exercise - checks.extend(exercise(db=db, user=user, manager=manager, - initiate=initiate, complete=complete, require=require)) - return dict(result='passed', checks=checks, - scope='Actual OIDC TLS fixture/session, shared native qualification/C Policy Core, native resource history and HTTP/SQL; no external eTech or deployment acceptance') - - if scenario == 'search': - stage = 'search_http' - from search_runtime import exercise - checks.extend(exercise(db=db, user=user, manager=manager, initiate=initiate, complete=complete, require=require, configuration_dir=temporary)) - return dict(result='passed', checks=checks, - scope='Controlled real Meili, CE14 native SQL/RPC, OIDC/C authorization and HTTP; development overlay, no external dev deployment or byte-event bridge claim') - - if scenario == 'audit': - stage = 'audit_export_http' - from audit_export_runtime import exercise - checks.extend(exercise(db=db, user=user, initiate=initiate, complete=complete, - require=require, configuration_dir=temporary)) - return dict(result='passed', checks=checks, - scope='Controlled OIDC/native policy, audit HTTP, standalone worker and private CSV delivery; no external eTech or browser acceptance') - - if scenario == 'provisioning': - stage = 'jit_provisioning' - from identity_provisioning_runtime import exercise - checks.extend(exercise(db=db, fixture=fixture, origin=origin, prefix=prefix, - initiate=initiate, complete=complete, require=require, groups={dept, role}, - configuration_dir=temporary)) - return {'result': 'passed', 'checks': checks, - 'scope': 'TLS fixture JIT worker, real native SQL/RPC, pending proofs and fresh OIDC login; no external IdP/eTech/ingress claim'} - - if scenario == 'logout': - stage = 'rp_logout' - from identity_logout_runtime import exercise - checks.extend(exercise(db=db, fixture=fixture, origin=origin, prefix=prefix, - initiate=initiate, complete=complete, require=require, ca_bundle=str(ca_file))) - return {'result': 'passed', 'checks': checks, - 'scope': 'Actual RP logout form/HTTPS fixture/return and native session SQL; Django Client, no real IdP/global logout/browser ingress claim'} - - if scenario == 'backchannel': - stage = 'backchannel_logout' - from identity_backchannel_runtime import exercise - def claims(token): - return jwt.decode(token, signing_key.public_key(), algorithms=['RS256'], - issuer=origin, audience='fixture-client') - def notification(values): - issued = int(time.time()) - value = {'iss': origin, 'aud': 'fixture-client', 'iat': issued, 'exp': issued + 300, - 'jti': secrets.token_urlsafe(24), - 'events': {'http://schemas.openid.net/event/backchannel-logout': {}}, **values} - return jwt.encode(value, signing_key, algorithm='RS256', - headers={'kid': 'fixture-key', 'typ': 'logout+jwt'}) - checks.extend(exercise(db=db, fixture=fixture, prefix=prefix, - initiate=initiate, complete=complete, require=require, configuration_dir=temporary, - claims=claims, notification=notification)) - return {'result': 'passed', 'checks': checks, - 'scope': 'Hosted cookie-free backchannel, real JWT/JWKS TLS, native SQL/session fences and standalone deletion process; Django Client, no external IdP/eTech/ingress claim'} - - if scenario in {'transfer', 'web'}: - stage = 'delegated_transfer' - from delegated_transfer_runtime import exercise - checks.extend(exercise(db=db, user=user, manager=manager, machine_secret=machine_secret, - initiate=initiate, complete=complete, require=require, - certificate=str(ca_file), tls_key=str(key_file), package=package, web=scenario == 'web')) - return {'result': 'passed', 'checks': checks, - 'scope': ('OIDC Web read/manual replacement via full Django Client plus actual C/Go bytes/Range; ' if scenario == 'web' else '') + 'controlled HTTPS service delegation; no actual browser/external eTech/production ingress claim'} - - stage = 'cold_cache_login' - browser = Client(enforce_csrf_checks=True) - browser.get('/accounts/login/', secure=True, HTTP_HOST='cloudfile-smoke.invalid') - callback = initiate(browser) - response = complete(browser, callback) - require(response.status_code == 302, 'callback_status_' + str(response.status_code)) - session = browser.session - require(session[SESSION_KEY] == user.username and session[BACKEND_SESSION_KEY] == BACKEND, - 'native_session_identity') - require(Session.objects.filter(session_key=session.session_key).exists(), 'persisted_session') - with db.cursor() as cursor: - cursor.execute('SELECT group_id FROM ccnet_db.GroupUser WHERE user_name=%s', (user.username,)) - require({row[0] for row in cursor.fetchall()} == {dept, role, manual}, 'owned_projection') - cursor.execute('SELECT COUNT(*) FROM cf_oidc_session') - require(cursor.fetchone()[0] == 1, 'session_index') - require(fixture['directory_calls'] == 1, 'cold_directory_fetch') - checks.append(stage) - stage = 'session_reload' - require(browser.get('/api2/account/info/', secure=True, - HTTP_HOST='cloudfile-smoke.invalid').status_code == 200, 'guarded_native_account') - checks.append(stage) - stage = 'callback_replay' - require(complete(Client(), callback).status_code == 401, 'browser_proof_replay') - checks.append(stage) - stage = 'local_logout' - old_key = session.session_key - # Existing CE login page supplies the browser's CSRF cookie. - csrf = browser.cookies[settings.CSRF_COOKIE_NAME].value - response = browser.post(prefix + 'logout/', data='', content_type='application/octet-stream', - secure=True, HTTP_HOST='cloudfile-smoke.invalid', - HTTP_ORIGIN='https://cloudfile-smoke.invalid', HTTP_X_CSRFTOKEN=csrf) - require(response.status_code == 200, 'logout_status_' + str(response.status_code)) - require(not Session.objects.filter(session_key=old_key).exists(), 'session_deleted') - with db.cursor() as cursor: - cursor.execute('SELECT COUNT(*) FROM cf_oidc_session') - require(cursor.fetchone()[0] == 0, 'index_deleted') - checks.append(stage) - for mode, claim, active, expected in ( - ('active', 'conflicting-user', True, 409), - ('active', 'fixture-user-1', False, 403), - ('disabled', 'fixture-user-1', True, 403), - ('outage', 'fixture-user-1', True, 503)): - stage = 'reject_' + ('claim_conflict' if claim != 'fixture-user-1' else - 'native_disabled' if not active else mode) - fixture.update(mode=mode, claim_user=claim) - user.is_active = active - user.save() - cache.flushdb() # Dedicated disposable Redis only. - other = Client(enforce_csrf_checks=True) - response = complete(other, initiate(other)) - require(response.status_code == expected, stage + '_status_' + str(response.status_code)) - require(not other.session.get(SESSION_KEY), stage + '_no_session') - checks.append(stage) - return {'result': 'passed', 'checks': checks, - 'scope': 'TLS test IdP/directory; actual CE SQL/RPC and Django client, no Authentik/eTech or ingress claim'} - except Exception as error: - label = str(error) if str(error).startswith('identity_check=') else (type(error).__name__ + ';frames=' + json.dumps([(item.filename.rsplit('/', 1)[-1], item.name, item.lineno) for item in traceback.extract_tb(error.__traceback__)])) - if stage == 'schema': - label += ';sql_failure=' + json.dumps(schema_failure) - try: - label += ';ledger=' + json.dumps([ - {key: row[key] for key in ('version', 'state', 'step', 'error_code')} - for row in SchemaRunner(db).status() if row['state'] != 'applied']) - except Exception: - label += ';ledger_unavailable' - raise RuntimeError('identity_stage=' + stage + ';' + label) from None - finally: - if gunicorn._host is not None: - gunicorn.worker_exit(None, None) - if server is not None: - server.shutdown() - server.server_close() - cache.close() - db.close() - - -if __name__ == '__main__': - print(json.dumps(run())) diff --git a/tests/resource_annotations_runtime.py b/tests/resource_annotations_runtime.py deleted file mode 100644 index 4ab8fc89..00000000 --- a/tests/resource_annotations_runtime.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Native path-birth + real SQL metadata probe, not an identity/HTTP gate.""" -import os -import sys -import tempfile -from unittest.mock import Mock - - -def run(repo): - package = '/opt/seafile/seafile-server-latest' - os.chdir(package + '/seahub') - sys.path[:0] = [package + '/seahub', package + '/seahub/thirdpart', - package + '/seafile/lib/python3/site-packages'] - os.environ.update(SEAFILE_CENTRAL_CONF_DIR='/shared/seafile/conf', - SEAFILE_RPC_PIPE_PATH=package + '/runtime', SEAFILE_DATA_DIR='/shared/seafile/seafile-data') - import pymysql - from seaserv import seafile_api - from cloudfile_extensions.resources.native import NativeResourceReader - from cloudfile_extensions.resources.store import ResourceStore - from cloudfile_extensions.authorization.read import ContentMetadataWriteAuthority - from cloudfile_extensions.schema.runner import SchemaRunner - from cloudfile_extensions.common.errors import ContractError - db = pymysql.connect(host='db', user=os.environ['SEAFILE_MYSQL_DB_USER'], - password=os.environ['SEAFILE_MYSQL_DB_PASSWORD'], database='seafile_db', autocommit=True) - try: - SchemaRunner(db).apply() - reader = NativeResourceReader(seafile_api) - ref = dict(repo_id=repo, path='/annotations-probe.prt', kind='file') - owner = 'admin@smoke.invalid' - store = ResourceStore(db, inspector=Mock(), write_guard=Mock(), - secret=b'isolated-runtime-fixture-secret-32-bytes', mutation_hook=Mock()) - # Authorization itself already has separate runtime evidence. This - # fixture isolates the new native reader and actual metadata transaction. - authority = Mock(spec=ContentMetadataWriteAuthority) - authority.actor = 'fixture-user'; authority.state = Mock(connection=db, provider='fixture-directory') - authority.effective_access = dict(read=True, write=True) - def consume(reference, callback): - db.begin() - try: - with db.cursor() as cursor: - result = callback(cursor, reference) - db.commit() - return result - finally: - db.rollback() - authority.consume.side_effect = consume - def snapshot(): - return store.resolve_authorized(ref, authority=authority, lifecycle_reader=reader, include_tags=True) - with tempfile.NamedTemporaryFile() as temporary: - temporary.write(b'original native file'); temporary.flush() - seafile_api.post_file(repo, temporary.name, '/', 'annotations-probe.prt', owner) - initial = snapshot() - assert initial['uid'] is None - saved, _ = store.write_authorized(ref, dict(description='eTech drawing', local_open_type='UG12'), - expected_revision=initial['revision'], authority=authority, lifecycle_reader=reader, idempotency_key='attributes-1') - tagged, _ = store.replace_user_tags_authorized(ref, [], expected_revision=saved['revision'], - authority=authority, lifecycle_reader=reader, request_id='native-fixture', - tag_values=[dict(label='drawing')], idempotency_key='tags-1') - temporary.seek(0); temporary.truncate(); temporary.write(b'edited file'); temporary.flush() - seafile_api.put_file(repo, temporary.name, '/', 'annotations-probe.prt', owner, None) - after = snapshot() - assert all(after[key] == value for key, value in tagged.items()) - assert after['description'] == 'eTech drawing' and after['local_open_type'] == 'UG12' - assert after['tags'][0]['label'] == 'drawing' - seafile_api.del_file(repo, '/', '["annotations-probe.prt"]', owner) - seafile_api.post_file(repo, temporary.name, '/', 'annotations-probe.prt', owner) - try: - snapshot() - except ContractError as error: - assert error.code == 'PATH_STATE_PENDING' - else: - raise AssertionError('recreated native object reused old annotations') - return dict(result='passed', checks=['sparse_read', 'description_open_type_and_user_tag_save', - 'native_replace_preserves_annotations', 'native_recreate_rejects_old_state'], - scope='Actual CE14 RPC/commit history + SQL; authorization mocked; not HTTP or release acceptance') - finally: - db.close() - - -if __name__ == '__main__': - import json - try: - print(json.dumps(run(sys.argv[1]))) - except Exception as error: - import traceback - print(json.dumps(dict(result='failed', error_type=type(error).__name__, - code=getattr(error, 'code', None), - location=[(os.path.basename(frame.filename), frame.lineno) - for frame in traceback.extract_tb(error.__traceback__)[-3:]]))) diff --git a/tests/smoke_ce14_runtime.py b/tests/smoke_ce14_runtime.py index cd8f620d..147074c6 100644 --- a/tests/smoke_ce14_runtime.py +++ b/tests/smoke_ce14_runtime.py @@ -69,6 +69,12 @@ def run(image, native_regression=False, extensions_regression=False, identity_ru cf_import_runtime=False): if not __debug__: raise RuntimeError('Acceptance requires Python assertions enabled') + if identity_runtime: + raise RuntimeError('Retired identity-runtime fixture is not a V2 acceptance test') + if native_regression or extensions_regression: + raise RuntimeError( + 'Retired CloudFile extension regression cannot run on V2; ' + 'use cloudfile-server/tests/test_cf_v2_acl_native.py with matched C/Go binaries and Hub source') prefix = 'cf02-smoke-' + secrets.token_hex(6) network = prefix + '-net' containers = [] @@ -225,19 +231,8 @@ def download(): status = request(base + '/file/?p=/probe.txt')['status'] if status not in (401, 403): raise RuntimeError('anonymous_file_status=' + str(status)) - status = request('/seafhttp/cloudfile/read')['status'] - if status != 401: - raise RuntimeError('secure_read_status=' + str(status)) checks.append(stage) - if native_regression or extensions_regression: - raise RuntimeError("Retired CloudFile extension regression cannot run on V2; use the current Hub/Server ACL contracts.") - if identity_runtime: - # The pre-V2 identity fixtures import the retired CloudFile - # extension tree and use tables outside the five-table contract. - # Refuse this obsolete option; current V2 HTTP/ACL tests are the - # supported acceptance route. - raise RuntimeError("Retired identity-runtime fixture is not a V2 acceptance test") - return {'result': 'passed', 'scope': 'CE14 local admin/native file baseline; optional identity fixture separately scoped', + return {'result': 'passed', 'scope': 'CE14 local admin/native file baseline; directory ACL acceptance is separate', 'image_id': metadata['Id'], 'package_sha256': digest, 'source_seahub': labels.get('com.cloudfile.source.seahub'), 'source_server': labels.get('com.cloudfile.source.seafile-server'), @@ -264,11 +259,11 @@ def download(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--image', required=True) parser.add_argument('--native-regression', action='store_true', - help='Also run the native actor import regression under real Seahub configuration') + help='Retired option; use current Server V2 native ACL acceptance') parser.add_argument('--extensions-regression', action='store_true', - help='Run extensions with a separate disposable SQL/Redis fixture') + help='Retired option; use current Server V2 native ACL acceptance') parser.add_argument('--identity-runtime', action='store_true', - help='Probe real TLS fixture OIDC/directory with actual CE accounts and SQL') + help='Retired option; unavailable on V2') parser.add_argument('--identity-scenario', choices=['prebound', 'provisioning', 'logout', 'backchannel', 'transfer', 'web', 'annotations', 'audit', 'search'], default='prebound') parser.add_argument('--development-worker-overlay', action='store_true', help='JIT development only: use host worker script; not packaged release evidence') diff --git a/tests/test_audit_worker.py b/tests/test_audit_worker.py deleted file mode 100644 index 92ac25cc..00000000 --- a/tests/test_audit_worker.py +++ /dev/null @@ -1,65 +0,0 @@ -import importlib.util -from pathlib import Path -import unittest -from types import ModuleType, SimpleNamespace -from unittest.mock import Mock, patch - -path = Path(__file__).resolve().parents[1] / 'scripts/scripts_14.0/cloudfile-audit-worker.py' -spec = importlib.util.spec_from_file_location('audit_worker', path) -worker = importlib.util.module_from_spec(spec) -spec.loader.exec_module(worker) - - -class AuditWorkerTests(unittest.TestCase): - def test_worker_drains_and_closes_host_on_success_and_failure(self): - for fail in (False, True): - settings = SimpleNamespace(CLOUDFILE_POLICY_CONFIG={'database': dict( - host='db', user='user', name='seafile', password='secret')}) - host = SimpleNamespace(deployment=SimpleNamespace(audit_factory=object())) - hooks = Mock(_host=None) - hooks.post_worker_init.side_effect = lambda _: setattr(hooks, '_host', host) - runtime = Mock() - runtime.__enter__ = Mock(return_value=runtime) - runtime.__exit__ = Mock(return_value=False) - if fail: - runtime.run.side_effect = RuntimeError('sensitive diagnostic') - background = Mock(return_value=runtime) - configure_oidc = Mock() - modules = {} - for name, attributes in { - 'django': {}, 'django.conf': {'settings': settings}, - 'cloudfile_extensions': {}, - 'cloudfile_extensions.authorization': {'gunicorn': hooks}, - 'cloudfile_extensions.identity': {}, - 'cloudfile_extensions.identity.configuration': {'configure_oidc_host': configure_oidc}, - 'cloudfile_extensions.events': {}, - 'cloudfile_extensions.events.background': {'AuditBackground': background}, - 'cloudfile_extensions.events.configuration': {'require_export_configuration': Mock()}, - }.items(): - modules[name] = ModuleType(name) - modules[name].__dict__.update(attributes) - with patch.dict('sys.modules', modules), patch.object(worker, 'bootstrap'), \ - patch.object(worker.signal, 'signal') as signals, patch('sys.stderr') as stderr: - self.assertEqual(worker.main(['--once']), int(fail)) - hooks.post_worker_init.assert_called_once_with(None) - configure_oidc.assert_called_once_with(settings) - hooks.worker_exit.assert_called_once_with(None, None) - runtime.__exit__.assert_called_once() - self.assertTrue(runtime.run.call_args.kwargs['once']) - self.assertEqual(signals.call_count, 4) - self.assertNotIn('sensitive diagnostic', str(stderr.mock_calls)) - - def test_invalid_limits_fail_before_bootstrap(self): - for arguments in (['--poll-seconds', '0'], ['--poll-seconds', '31'], - ['--lease-seconds', '0'], ['--lease-seconds', '301']): - with patch.object(worker, 'bootstrap') as bootstrap, patch('sys.stderr'): - self.assertEqual(worker.main(arguments), 1) - bootstrap.assert_not_called() - - def test_database_is_derived_from_authority_configuration(self): - database = dict(host='db', user='audit', name='seafile', password='private') - self.assertEqual(worker.database_environment({'database': database}), dict( - CLOUDFILE_DB_HOST='db', CLOUDFILE_DB_USER='audit', CLOUDFILE_DB_NAME='seafile', - CLOUDFILE_DB_PASSWORD='private', CLOUDFILE_DB_PORT='3306')) - database['port'] = 3307 - self.assertEqual(worker.database_environment({'database': database})['CLOUDFILE_DB_PORT'], '3307') diff --git a/tests/test_cloudfile_settings.py b/tests/test_cloudfile_settings.py index bed98040..16ea65b4 100644 --- a/tests/test_cloudfile_settings.py +++ b/tests/test_cloudfile_settings.py @@ -13,45 +13,26 @@ class CloudFileSettingsTest(unittest.TestCase): - def test_backchannel_flag_requires_enabled_oidc_and_defaults_off(self): - namespace = {} - exec(CLOUDFILE.render_settings({}), namespace) - self.assertIs(namespace['CLOUDFILE_OIDC_BACKCHANNEL_ENABLED'], False) - with self.assertRaisesRegex(ValueError, 'backchannel'): - CLOUDFILE.render_settings({'CLOUDFILE_OIDC_BACKCHANNEL_ENABLED': 'true'}) + def test_retired_policy_configuration_is_rejected_without_echoing_values(self): + for key in CLOUDFILE.RETIRED_POLICY_SETTINGS: + with self.subTest(key=key): + with self.assertRaises(ValueError) as error: + CLOUDFILE.render_settings({key: 'secret-example'}) + self.assertIn(key, str(error.exception)) + self.assertNotIn('secret-example', str(error.exception)) + rendered = CLOUDFILE.render_settings({}) + self.assertNotIn('cloudfile_extensions.authorization.gunicorn', rendered) + self.assertNotIn('CLOUDFILE_POLICY_CONFIG =', rendered) + - def test_policy_worker_hooks_preserve_compose_and_remove_only_owned_block(self): - environment = {"CLOUDFILE_POLICY_WORKER_HOOKS": "true"} - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "gunicorn.conf.py" - original = "bind = '127.0.0.1:8000'\ndef post_worker_init(worker):\n worker.append('original-init')\ndef worker_exit(server, worker):\n worker.append('original-exit')\n" - path.write_text(original, encoding="utf-8") - self.assertTrue(CLOUDFILE.write_policy_worker_hooks(path, environment)) - rendered = path.read_text(encoding="utf-8") - self.assertFalse(CLOUDFILE.write_policy_worker_hooks(path, environment)) - self.assertEqual(path.read_text(encoding="utf-8"), rendered) - fixture = ModuleType("cloudfile_extensions.authorization.gunicorn") - fixture.post_worker_init = lambda worker: worker.append("cf-init") - fixture.worker_exit = lambda server, worker: worker.append("cf-exit") - with patch.dict("sys.modules", {fixture.__name__: fixture}): - namespace = {} - exec(compile(rendered, str(path), "exec"), namespace) - events = [] - namespace["post_worker_init"](events) - namespace["worker_exit"](None, events) - self.assertEqual(events, ["original-init", "cf-init", "original-exit", "cf-exit"]) - self.assertTrue(CLOUDFILE.write_policy_worker_hooks(path, {})) - self.assertEqual(path.read_text(encoding="utf-8"), original) - self.assertFalse(CLOUDFILE.write_policy_worker_hooks(path, {})) def test_v2_removes_only_legacy_worker_hooks_without_starting_policy_host(self): # A stale policy config/hook must not load a historical SchemaRunner. with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "gunicorn.conf.py" original = "def post_worker_init(worker):\n worker.append('native')\n" - path.write_text(original) + path.write_text(original + "# BEGIN CLOUDFILE POLICY WORKER HOOKS\nraise AssertionError('retired')\n# END CLOUDFILE POLICY WORKER HOOKS\n") env = {"CLOUDFILE_POLICY_WORKER_HOOKS": "true"} - CLOUDFILE.write_policy_worker_hooks(path, env) CLOUDFILE.write_policy_worker_hooks(path, env, v2=True) self.assertEqual(path.read_text(), original) namespace = {}; exec(path.read_text(), namespace) @@ -59,29 +40,7 @@ def test_v2_removes_only_legacy_worker_hooks_without_starting_policy_host(self): self.assertEqual(events, ['native']) self.assertEqual(env, {"CLOUDFILE_POLICY_WORKER_HOOKS": "true"}) - def test_v2_refuses_retired_routes_before_mutating_hooks(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "gunicorn.conf.py" - path.write_text("# operator config\n") - for key in ('CLOUDFILE_EDITING_ENABLED', 'CLOUDFILE_AUTHORIZATION_ENABLED', - 'CLOUDFILE_OIDC_ENABLED', 'CLOUDFILE_RESOURCE_SEARCH_ENABLED'): - with self.assertRaisesRegex(ValueError, key): - CLOUDFILE.write_policy_worker_hooks(path, {key: 'true'}, v2=True) - self.assertEqual(path.read_text(), "# operator config\n") - - def test_policy_exit_cleanup_runs_after_existing_hook_failure(self): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / "gunicorn.conf.py" - path.write_text("def worker_exit(server, worker):\n raise RuntimeError('fixture')\n", encoding="utf-8") - CLOUDFILE.write_policy_worker_hooks(path, {"CLOUDFILE_POLICY_WORKER_HOOKS": "true"}) - fixture = ModuleType("cloudfile_extensions.authorization.gunicorn") - fixture.worker_exit = Mock() - with patch.dict("sys.modules", {fixture.__name__: fixture}): - namespace = {} - exec(compile(path.read_text(encoding="utf-8"), str(path), "exec"), namespace) - with self.assertRaises(RuntimeError): - namespace["worker_exit"]("server", "worker") - fixture.worker_exit.assert_called_once_with("server", "worker") + def test_policy_hook_malformed_markers_preserve_original_file(self): for original in ("# BEGIN CLOUDFILE POLICY WORKER HOOKS\n", @@ -129,14 +88,6 @@ def test_existing_block_is_replaced(self): self.assertIn("before = True", rendered) self.assertIn("after = True", rendered) - def test_retired_administrator_policy_defaults_off_without_loading_its_schema(self): - values = {} - exec(CLOUDFILE.render_settings({}), values) - self.assertIs(values['CLOUDFILE_ADMIN_POLICY_ENABLED'], False) - self.assertIs(values['CLOUDFILE_OIDC_ENABLED'], False) - self.assertIsNone(values['CLOUDFILE_POLICY_CONFIG']) - with self.assertRaisesRegex(ValueError, 'administrator policy'): - CLOUDFILE.render_settings({'CLOUDFILE_ADMIN_POLICY_ENABLED':'true', 'CLOUDFILE_AUTHORIZATION_ENABLED':'true'}) def test_native_block_follows_legacy_defaults_and_preserves_both_apps(self): with tempfile.TemporaryDirectory() as directory: @@ -261,135 +212,13 @@ def test_webdav_requires_explicit_deployment_flag(self): self.assertEqual(namespace["CLOUDFILE_WEBDAV_SERVICE_ENABLED"], expected) self.assertEqual(namespace["CLOUDFILE_CAPABILITIES"]["protocol.webdav"]["enabled"], expected) - def test_local_edit_requires_complete_native_runtime_and_renders_adapters(self): - for value in (None, "false"): - environment = {} if value is None else {"CLOUDFILE_LOCAL_EDIT_ENABLED": value} - namespace = {}; exec(CLOUDFILE.render_settings(environment), namespace) - self.assertFalse(namespace["CLOUDFILE_LOCAL_EDIT_ENABLED"]) - self.assertIsNone(namespace["CLOUDFILE_LOCAL_EDIT_INSTANCE"]) - self.assertFalse(namespace["CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED"]) - with self.assertRaisesRegex(ValueError, "local edit requires"): - CLOUDFILE.render_settings({"CLOUDFILE_LOCAL_EDIT_ENABLED": "true"}) - environment = OIDCSettingsTest.environment() - environment.update(CLOUDFILE_LOCAL_EDIT_ENABLED="true", CLOUDFILE_LOCAL_EDIT_INSTANCE="https://files.example", - CLOUDFILE_RESOURCE_SECRET="s" * 32, CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED="true") - namespace = {}; exec(CLOUDFILE.render_settings(environment), namespace) - self.assertFalse(namespace["CLOUDFILE_EDITING_ENABLED"]) - self.assertTrue(namespace["CLOUDFILE_LOCAL_EDIT_ENABLED"]) - self.assertTrue(namespace["CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED"]) - self.assertEqual(namespace["CLOUDFILE_RESOURCE_SECRET"], b"s" * 32) - self.assertTrue(callable(namespace["CLOUDFILE_RESOURCE_LIFECYCLE_READER"])) - self.assertTrue(callable(namespace["CLOUDFILE_LOCAL_EDIT_VERSION_READER"])) - for key in ("CLOUDFILE_OIDC_ENABLED", "CLOUDFILE_POLICY_CONFIG_JSON", "CLOUDFILE_POLICY_WORKER_HOOKS", "CLOUDFILE_RESOURCE_SECRET"): - with self.subTest(key=key), self.assertRaises(ValueError): - CLOUDFILE.render_settings({k:v for k,v in environment.items() if k != key}) - for origin in ("https://other.example", "https://files.example/seafile", "https://user:secret@files.example", "https://files.example:invalid", "https://files.example:", "https://files.example/#fragment"): - with self.subTest(origin=origin), self.assertRaises(ValueError): - CLOUDFILE.render_settings({**environment, "CLOUDFILE_LOCAL_EDIT_INSTANCE": origin}) - for gate in ("CLOUDFILE_AGENT2_OPTIMISTIC_UPLOAD_ENABLED", "CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED"): - with self.assertRaises(ValueError): CLOUDFILE.render_settings({gate:"true"}) - with self.assertRaises(ValueError): CLOUDFILE.render_settings({**environment,"CLOUDFILE_AGENT2_EXCLUSIVE_ENABLED":"true"}) - import json - oidc = json.loads(environment["CLOUDFILE_OIDC_CONFIG_JSON"]) - for field in ("issuer", "authorization_url", "token_url", "userinfo_url", "jwks_url", "redirect_uri"): - oidc[field] = oidc[field].replace("https://", "http://") - rendered = CLOUDFILE.render_settings({**environment,"CLOUDFILE_LOCAL_EDIT_INSTANCE":"http://files.example", "CLOUDFILE_OIDC_CONFIG_JSON":json.dumps(oidc)}) - self.assertIn("CLOUDFILE_LOCAL_EDIT_INSTANCE = 'http://files.example'",rendered) - - def test_editing_requires_stable_resource_secret_and_native_oidc(self): - namespace = {} - exec(CLOUDFILE.render_settings({}), namespace) - self.assertIs(namespace["CLOUDFILE_EDITING_ENABLED"], False) - self.assertIsNone(namespace["CLOUDFILE_RESOURCE_LIFECYCLE_READER"]) - with self.assertRaisesRegex(ValueError, "editing requires"): - CLOUDFILE.render_settings({"CLOUDFILE_EDITING_ENABLED": "true"}) - environment = OIDCSettingsTest.environment() - environment["CLOUDFILE_EDITING_ENABLED"] = "true" - with self.assertRaisesRegex(ValueError, "resource secret"): - CLOUDFILE.render_settings(environment) - environment["CLOUDFILE_RESOURCE_SECRET"] = "s" * 32 - with self.assertRaisesRegex(ValueError, "file-lock barrier"): - CLOUDFILE.render_settings(environment) - environment["CF_ENABLE_FILE_LOCK"] = "true" - rendered = CLOUDFILE.render_settings(environment) - self.assertIn("CLOUDFILE_EDITING_ENABLED = True", rendered) - self.assertIn("return NativeResourceReader(seafile_api)(cursor, reference)", rendered) - def test_authorization_routes_require_explicit_deployment_flag(self): - policy = '{"database":{},"redis":{}}' - for value, expected in ((None, False), ("true", True), ("false", False)): - environment = {} if value is None else { - "CLOUDFILE_AUTHORIZATION_ENABLED": value, - "CLOUDFILE_POLICY_CONFIG_JSON": policy, - "CLOUDFILE_POLICY_WORKER_HOOKS": "true", - } - namespace = {} - exec(CLOUDFILE.render_settings(environment), namespace) - self.assertEqual(namespace["CLOUDFILE_AUTHORIZATION_ENABLED"], expected) - self.assertEqual(callable(namespace["CLOUDFILE_RESOURCE_LIFECYCLE_READER"]), expected) - self.assertFalse(namespace["CLOUDFILE_EDITING_ENABLED"]) - def test_transfer_routes_require_explicit_deployment_flag(self): - policy = '{"database":{},"redis":{}}' - for value, expected in ((None, False), ("true", True), ("false", False)): - environment = {} if value is None else { - "CLOUDFILE_TRANSFER_ENABLED": value, - "ENABLE_GO_FILESERVER": "true", - "CLOUDFILE_POLICY_CONFIG_JSON": policy, - "CLOUDFILE_POLICY_WORKER_HOOKS": "true", - } - namespace = {} - exec(CLOUDFILE.render_settings(environment), namespace) - self.assertEqual(namespace["CLOUDFILE_TRANSFER_ENABLED"], expected) - - def test_login_and_directory_preserve_resource_key_without_editing(self): - # A lifecycle reader without its key cannot initialize PolicyHost; - # these independently enabled features must not depend on editing. - for feature in ("CLOUDFILE_OIDC_ENABLED", "CLOUDFILE_AUTHORIZATION_ENABLED", - "CLOUDFILE_TRANSFER_ENABLED"): - environment = OIDCSettingsTest.environment() - environment.update({feature: "true", "CLOUDFILE_RESOURCE_SECRET": "r" * 32, - "ENABLE_GO_FILESERVER": "true"}) - if feature != "CLOUDFILE_OIDC_ENABLED": - environment["CLOUDFILE_OIDC_ENABLED"] = "false" - namespace = {} - exec(CLOUDFILE.render_settings(environment), namespace) - with self.subTest(feature=feature): - self.assertFalse(namespace["CLOUDFILE_EDITING_ENABLED"]) - self.assertEqual(namespace["CLOUDFILE_RESOURCE_SECRET"], b"r" * 32) - self.assertTrue(callable(namespace["CLOUDFILE_RESOURCE_LIFECYCLE_READER"])) - def test_enabled_transfer_rejects_missing_policy_worker(self): - with self.assertRaisesRegex(ValueError, "authorization or transfer"): - CLOUDFILE.render_settings({"CLOUDFILE_TRANSFER_ENABLED": "true"}) - def test_enabled_transfer_rejects_native_c_fileserver(self): - environment = { - "CLOUDFILE_TRANSFER_ENABLED": "true", - "CLOUDFILE_POLICY_CONFIG_JSON": '{"database":{},"redis":{}}', - "CLOUDFILE_POLICY_WORKER_HOOKS": "true", - } - for value in (None, "false"): - with self.subTest(value=value): - if value is not None: - environment["ENABLE_GO_FILESERVER"] = value - with self.assertRaisesRegex(ValueError, "ENABLE_GO_FILESERVER=true"): - CLOUDFILE.render_settings(environment) - - def test_enabled_authorization_rejects_incomplete_or_ambiguous_policy_config(self): - with self.assertRaisesRegex(ValueError, "policy config and worker hooks"): - CLOUDFILE.render_settings({"CLOUDFILE_AUTHORIZATION_ENABLED": "true"}) - with self.assertRaisesRegex(ValueError, "policy config and worker hooks"): - CLOUDFILE.render_settings({ - "CLOUDFILE_AUTHORIZATION_ENABLED": "true", - "CLOUDFILE_POLICY_CONFIG_JSON": "{}", - }) - with self.assertRaisesRegex(ValueError, "duplicate"): - CLOUDFILE.render_settings({ - "CLOUDFILE_POLICY_CONFIG_JSON": '{"database":{},"database":{}}', - }) - with self.assertRaisesRegex(ValueError, "strict JSON"): - CLOUDFILE.render_settings({"CLOUDFILE_POLICY_CONFIG_JSON": '{"port":NaN}'}) + + + def test_deployment_cannot_claim_reserved_domain(self): for domain in CLOUDFILE.RESERVED_DOMAINS: @@ -398,55 +227,8 @@ def test_deployment_cannot_claim_reserved_domain(self): class OIDCSettingsTest(unittest.TestCase): - def test_audit_export_defaults_off_and_requires_query_and_private_root(self): - namespace = {} - exec(CLOUDFILE.render_settings({}), namespace) - self.assertIs(namespace['CLOUDFILE_AUDIT_EXPORT_ENABLED'], False) - self.assertIsNone(namespace['CLOUDFILE_AUDIT_RESULT_ROOT']) - environment = self.environment() - environment.update(CLOUDFILE_AUTHORIZATION_ENABLED='true', - CLOUDFILE_AUDIT_QUERY_ENABLED='true', CLOUDFILE_AUDIT_EXPORT_ENABLED='true', - CLOUDFILE_AUDIT_CURSOR_SECRET='x' * 32, - CLOUDFILE_AUDIT_RESULT_ROOT='/private/audit') - exec(CLOUDFILE.render_settings(environment), namespace) - self.assertIs(namespace['CLOUDFILE_AUDIT_EXPORT_ENABLED'], True) - self.assertEqual(namespace['CLOUDFILE_AUDIT_RESULT_ROOT'], '/private/audit') - for root in ('', 'relative', '/invalid\x00'): - with self.assertRaises(ValueError): - CLOUDFILE.render_settings({**environment, 'CLOUDFILE_AUDIT_RESULT_ROOT': root}) - with self.assertRaises(ValueError): - CLOUDFILE.render_settings({**environment, 'CLOUDFILE_AUDIT_QUERY_ENABLED': 'false'}) - - @staticmethod - def environment(): - import json - return {"CLOUDFILE_OIDC_ENABLED": "true", "CLOUDFILE_POLICY_WORKER_HOOKS": "true", - "CLOUDFILE_POLICY_CONFIG_JSON": '{"provider":"fixture"}', - "CLOUDFILE_OIDC_CONFIG_JSON": json.dumps(dict( - issuer="https://idp.example/application/o/cloudfile/", client_id="cloudfile", - client_secret="fixture-client-secret", - redirect_uri="https://files.example/api/v2.1/cloudfile/extensions/identity/v1/callback/", - authorization_url="https://idp.example/authorize/", token_url="https://idp.example/token/", - userinfo_url="https://idp.example/userinfo/", jwks_url="https://idp.example/jwks/"))} - - def test_audit_query_requires_explicit_prerequisites_and_fixed_secret(self): - environment = self.environment() - environment.update(CLOUDFILE_AUTHORIZATION_ENABLED="true", - CLOUDFILE_AUDIT_QUERY_ENABLED="true", - CLOUDFILE_AUDIT_CURSOR_SECRET="fixed-audit-secret-" + "x" * 32) - namespace = {} - exec(CLOUDFILE.render_settings(environment), namespace) - self.assertTrue(namespace["CLOUDFILE_AUDIT_QUERY_ENABLED"]) - self.assertEqual(namespace["CLOUDFILE_AUDIT_CURSOR_SECRET"], - environment["CLOUDFILE_AUDIT_CURSOR_SECRET"].encode("utf-8")) - for key in ("CLOUDFILE_AUTHORIZATION_ENABLED", "CLOUDFILE_AUDIT_CURSOR_SECRET"): - invalid = dict(environment) - del invalid[key] - with self.subTest(key=key), self.assertRaises(ValueError): - CLOUDFILE.render_settings(invalid) - invalid = dict(environment, CLOUDFILE_AUDIT_CURSOR_SECRET="short") - with self.assertRaises(ValueError): - CLOUDFILE.render_settings(invalid) + + def test_managed_group_settings_follow_runtime_environment(self): namespace = {} @@ -457,76 +239,11 @@ def test_managed_group_settings_follow_runtime_environment(self): self.assertEqual(namespace["CF_SSO_GROUP_OWNER"], "owner@example.com") self.assertEqual(namespace["CF_SSO_MAX_REMOVAL_RATIO"], "0.25") - def test_explicit_oidc_renders_primitives_without_claiming_capability(self): - namespace = {} - exec(CLOUDFILE.render_settings(self.environment()), namespace) - self.assertTrue(namespace["CLOUDFILE_OIDC_ENABLED"]) - self.assertFalse(namespace["CLOUDFILE_OIDC_JIT_ENABLED"]) - self.assertEqual(namespace["CLOUDFILE_OIDC_CONFIG"]["client_id"], "cloudfile") - self.assertFalse(namespace["CLOUDFILE_CAPABILITIES"]["auth.oidc"]["enabled"]) - self.assertIs(namespace["ENABLE_OAUTH"], False) - self.assertEqual(namespace["LOGIN_URL"], "/api/v2.1/cloudfile/extensions/identity/v1/begin/") - self.assertFalse(namespace["CLOUDFILE_LOCAL_EDIT_ENABLED"]) - - def test_missing_runtime_or_worker_hooks_rejected(self): - for key in ("CLOUDFILE_POLICY_CONFIG_JSON", "CLOUDFILE_OIDC_CONFIG_JSON", - "CLOUDFILE_POLICY_WORKER_HOOKS"): - environment = self.environment() - del environment[key] - with self.subTest(key=key), self.assertRaises(ValueError): - CLOUDFILE.render_settings(environment) - - def test_legacy_oauth_coexists_with_native_oidc(self): - # Directory sharing and read tickets require the native OIDC host while - # existing deployments keep the Authentik OAuth preset, so the bootstrap - # must render both instead of rejecting the combination. - environment = self.environment() - environment.update(CLOUDFILE_AUTHENTIK_ENABLED="true", - CLOUDFILE_AUTHENTIK_URL="https://idp.example", - CLOUDFILE_AUTHENTIK_CLIENT_ID="fixture", CLOUDFILE_AUTHENTIK_CLIENT_SECRET="fixture", - CLOUDFILE_AUTHENTIK_REDIRECT_URL="https://files.example/oauth/callback/") - namespace = {} - exec(CLOUDFILE.render_settings(environment), namespace) - self.assertTrue(namespace["CLOUDFILE_OIDC_ENABLED"]) - self.assertTrue(namespace["ENABLE_OAUTH"]) - self.assertEqual(namespace["OAUTH_PROVIDER"], "authentik-oauth") - self.assertIsNotNone(namespace["CLOUDFILE_POLICY_CONFIG"]) - def test_jit_without_oidc_host_is_rejected(self): - with self.assertRaises(ValueError): - CLOUDFILE.render_settings({"CLOUDFILE_OIDC_JIT_ENABLED": "true"}) - - def test_explicit_http_and_https_oidc_issuers_remain_supported(self): - # dev intentionally supports internal HTTP issuers; redaction tests must - # reject invalid schemes without silently reversing that contract. - import json - for scheme in ("http", "https"): - environment = self.environment() - value = json.loads(environment["CLOUDFILE_OIDC_CONFIG_JSON"]) - value["issuer"] = scheme + "://idp.example" - environment["CLOUDFILE_OIDC_CONFIG_JSON"] = json.dumps(value) - namespace = {} - exec(CLOUDFILE.render_settings(environment), namespace) - self.assertEqual(namespace["CLOUDFILE_OIDC_CONFIG"]["issuer"], value["issuer"]) - - def test_oidc_json_and_endpoint_errors_are_redacted(self): - import json - for raw in ('{"client_secret":"fixture-client-secret","client_secret":"duplicate"}', - '{"secret":NaN}', '[]'): - environment = self.environment() - environment["CLOUDFILE_OIDC_CONFIG_JSON"] = raw - with self.subTest(raw=raw), self.assertRaises(ValueError) as error: - CLOUDFILE.render_settings(environment) - self.assertNotIn("fixture-client-secret", str(error.exception)) - for uri in ("ftp://idp.example", "https://user:fixture-client-secret@idp.example", - "https://idp.example/#fragment", "https://idp.example:invalid", "https://idp.example/\n"): - environment = self.environment() - value = json.loads(environment["CLOUDFILE_OIDC_CONFIG_JSON"]) - value["issuer"] = uri - environment["CLOUDFILE_OIDC_CONFIG_JSON"] = json.dumps(value) - with self.subTest(uri=uri), self.assertRaises(ValueError) as error: - CLOUDFILE.render_settings(environment) - self.assertNotIn("fixture-client-secret", str(error.exception)) + + + + def test_identity_domain_cannot_be_replaced_by_project_urlconf(self): with self.assertRaises(ValueError): diff --git a/tests/test_jit_worker.py b/tests/test_jit_worker.py deleted file mode 100644 index bf0ed951..00000000 --- a/tests/test_jit_worker.py +++ /dev/null @@ -1,37 +0,0 @@ -import importlib.util -from pathlib import Path -from types import SimpleNamespace -import unittest -from unittest.mock import patch - -path = Path(__file__).resolve().parents[1] / 'scripts/scripts_14.0/cloudfile-jit-worker.py' -spec = importlib.util.spec_from_file_location('jit_worker', path) -worker = importlib.util.module_from_spec(spec) -spec.loader.exec_module(worker) - - -class JITWorkerConfigurationTests(unittest.TestCase): - def test_disabled_and_string_flags_do_not_start_provisioning(self): - for oidc, jit in [(False, True), (True, False), ('true', True), (True, 'true')]: - with self.subTest(oidc=oidc, jit=jit), self.assertRaises(ValueError): - worker.require_enabled(SimpleNamespace(CLOUDFILE_OIDC_ENABLED=oidc, CLOUDFILE_OIDC_JIT_ENABLED=jit)) - - def test_invalid_poll_and_lease_limits_fail_before_loading_runtime(self): - for arguments in [['--poll-seconds', '0'], ['--poll-seconds', '31'], - ['--lease-seconds', '0'], ['--lease-seconds', '301']]: - with patch.object(worker, 'bootstrap') as bootstrap, patch('sys.stderr'): - self.assertEqual(worker.main(arguments), 1) - bootstrap.assert_not_called() - - def test_logout_worker_uses_its_own_flag_without_requiring_jit(self): - settings = SimpleNamespace(CLOUDFILE_OIDC_ENABLED=True, CLOUDFILE_OIDC_BACKCHANNEL_ENABLED=True, - CLOUDFILE_OIDC_JIT_ENABLED=False) - worker.require_enabled(settings, logout=True) - for value in (False, 'true'): - settings.CLOUDFILE_OIDC_BACKCHANNEL_ENABLED = value - with self.assertRaises(ValueError): - worker.require_enabled(settings, logout=True) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_package_provenance.py b/tests/test_package_provenance.py index 761dba1e..578d916c 100644 --- a/tests/test_package_provenance.py +++ b/tests/test_package_provenance.py @@ -36,7 +36,8 @@ def setUp(self): path = self.package / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"installed fixture bytes") - (self.package / "seafile/lib/core-link.so").symlink_to("libcloudfile_acl.so.1") + (self.package / "seafile/lib").mkdir(exist_ok=True) + (self.package / "seafile/lib/core-link.so").symlink_to("../bin/seaf-server") self.captured = dict(schema=1, manifest=self.manifest, source_commits={name: value["ref"] for name, value in self.manifest["sources"].items()}) @@ -80,7 +81,7 @@ def test_execute_modes_added_files_and_symlink_targets_are_measured(self): with self.assertRaisesRegex(ValueError, "content differs"): verify_package(self.package, self.manifest_path) link.unlink() - link.symlink_to("libcloudfile_acl.so.1") + link.symlink_to("../bin/seaf-server") (self.package / "extra.py").write_text("unrecorded") with self.assertRaisesRegex(ValueError, "content differs"): verify_package(self.package, self.manifest_path) diff --git a/tests/test_v2_authorization_contract.py b/tests/test_v2_authorization_contract.py index e0dd4a30..09af7995 100644 --- a/tests/test_v2_authorization_contract.py +++ b/tests/test_v2_authorization_contract.py @@ -43,9 +43,14 @@ def test_retired_sql_runtime_fixtures_cannot_return(self): for name in retired: self.assertFalse((ROOT / name).exists(), name) forbidden = re.compile( - r'\\b(?:INSERT\\s+INTO|SELECT\\s+[^\\n]*?\\s+FROM|UPDATE|DELETE\\s+FROM)\\s+cf_(?:managed_library|managed_library_share|library_share_revision)\\b', + r'\b(?:INSERT\s+INTO|SELECT\s+[^\n]*?\s+FROM|UPDATE|DELETE\s+FROM)\s+cf_(?:managed_library|managed_library_share|library_share_revision)\b', re.I, ) + for sql in ('INSERT INTO cf_managed_library VALUES (1)', + 'SELECT * FROM cf_managed_library_share', + 'DELETE FROM cf_library_share_revision', + 'UPDATE cf_managed_library SET x=1'): + self.assertIsNotNone(forbidden.search(sql), sql) for file in (ROOT / 'tests').rglob('*.py'): if file == Path(__file__): continue @@ -58,5 +63,14 @@ def test_obsolete_runtime_is_never_reported_as_v2(self): self.assertIn('Retired identity-runtime fixture is not a V2 acceptance test', smoke) self.assertIn('Retired CloudFile extension regression cannot run on V2', smoke) + def test_retired_policy_artifacts_are_not_packaged_or_tested(self): + provenance = (ROOT / 'build/seafile_14.0/package_provenance.py').read_text() + self.assertNotIn('libcloudfile_acl', provenance) + for name in ('tests/probe_identity_runtime.py', 'tests/test_jit_worker.py', + 'tests/test_audit_worker.py', 'tests/e2e/native_device_editing_e2e.py', + 'tests/e2e/native_cdc_editing_e2e.py', 'tests/e2e/native_optimistic_cdc_e2e.py', + 'tests/e2e/native_local_edit_baseline_e2e.py'): + self.assertFalse((ROOT / name).exists(), name) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_verify.py b/tests/test_verify.py deleted file mode 100644 index a8a3a94a..00000000 --- a/tests/test_verify.py +++ /dev/null @@ -1,104 +0,0 @@ -import importlib.util -import json -from pathlib import Path -import tempfile -import unittest -from unittest.mock import patch - -spec = importlib.util.spec_from_file_location('cloudfile_verify', Path(__file__).with_name('verify.py')) -verify = importlib.util.module_from_spec(spec) -spec.loader.exec_module(verify) - - -class SelectionTests(unittest.TestCase): - def test_identity_change_keeps_runtime_gate_visible(self): - with patch.object(verify, 'test_modules', return_value=['identity-test']): - selected = verify.plan(['cloudfile-hub/cloudfile_extensions/identity/runtime.py']) - self.assertEqual(selected['modules'], ['identity-test']) - self.assertEqual(selected['required_gates'], ['identity-runtime']) - - def test_shared_schema_and_unknown_native_never_silently_pass(self): - for path in ['cloudfile-hub/cloudfile_extensions/schema/runner.py', - 'cloudfile-hub/cloudfile_extensions/common/errors.py', - 'cloudfile-server/fileserver/http.go', 'other/unmapped.py']: - self.assertIn('full-regression', verify.plan([path])['required_gates']) - - def test_probe_edit_selects_docker_and_identity_but_no_full_rebuild(self): - selected = verify.plan(['cloudfile-docker/tests/probe_identity_runtime.py']) - self.assertEqual(selected['groups'], ['docker']) - self.assertEqual(selected['required_gates'], ['audit-runtime', 'identity-runtime', 'search-runtime']) - - def test_worker_or_jit_fixture_edit_requires_actual_provisioning_gate(self): - for path in ('scripts/scripts_14.0/cloudfile-jit-worker.py', 'tests/identity_provisioning_runtime.py'): - selected = verify.plan(['cloudfile-docker/' + path]) - expected = ['identity-backchannel', 'identity-provisioning'] if path.startswith('scripts/') else ['identity-provisioning'] - self.assertEqual(selected['required_gates'], expected) - - def test_logout_fixture_requires_actual_logout_gate(self): - selected = verify.plan(['cloudfile-docker/tests/identity_logout_runtime.py']) - self.assertEqual(selected['required_gates'], ['identity-logout']) - - def test_backchannel_worker_and_fixture_require_actual_notification_gate(self): - for path in ('tests/identity_backchannel_runtime.py', 'scripts/scripts_14.0/cloudfile-logout-worker.py'): - self.assertEqual(verify.plan(['cloudfile-docker/' + path])['required_gates'], ['identity-backchannel']) - - def test_audit_worker_and_fixture_require_actual_export_gate(self): - for path in ('tests/audit_export_runtime.py', 'scripts/scripts_14.0/cloudfile-audit-worker.py'): - self.assertEqual(verify.plan(['cloudfile-docker/' + path])['required_gates'], ['audit-runtime']) - - def test_reverse_imports_include_transitive_consumers_and_relative_imports(self): - with tempfile.TemporaryDirectory() as directory: - hub = Path(directory) - contents = { - 'identity/runtime.py': 'from .bindings import resolve\n', - 'identity/bindings.py': 'def resolve(): pass\n', - 'delegation/host.py': 'from ..identity.runtime import runtime\n', - 'tests/test_consumer.py': 'from cloudfile_extensions.delegation.host import host\n', - 'tests/test_unrelated.py': 'import json\n', - } - for name, content in contents.items(): - path = hub / 'cloudfile_extensions' / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - with patch.object(verify, 'HUB', hub): - self.assertEqual(verify.test_modules('identity'), ['cloudfile_extensions.tests.test_consumer']) - - -class OwnershipTests(unittest.TestCase): - def test_stale_native_image_is_rejected_before_starting_services(self): - metadata = [{'Id': 'fixture-image', 'Config': {'Labels': { - 'com.cloudfile.source.seafile-server': 'old-native'}}}] - with patch.object(verify, 'docker', return_value=json.dumps(metadata)), \ - patch.object(verify, 'git', side_effect=['', 'new-native']), \ - patch.object(verify, 'core') as services: - with self.assertRaisesRegex(RuntimeError, 'Native source changed'): - verify.run_components(['fixture-test'], 'fixture-image', False) - services.assert_not_called() - - def test_unowned_services_cannot_be_reused_or_removed(self): - response = type('Result', (), {'returncode': 0, 'stdout': json.dumps([{'Config': {'Labels': {}}}])})() - with patch.object(verify, 'command', return_value=response), self.assertRaisesRegex(RuntimeError, 'unowned'): - verify.owned('container', 'existing-user-db') - - def test_partial_start_failure_only_cleans_newly_created_resources(self): - calls = [] - def docker(*args, **kwargs): - calls.append(args) - if args[0] == 'run': - raise RuntimeError('fixture start failed') - return '' - with patch.object(verify, 'owned', return_value=None), patch.object(verify, 'docker', side_effect=docker): - with self.assertRaises(RuntimeError): - verify.core_up(('new-network', 'new-db', 'new-redis')) - self.assertIn(('network', 'rm', 'new-network'), calls) - self.assertFalse(any(call[0] == 'rm' for call in calls)) - - -if __name__ == '__main__': - unittest.main() - - -class SearchGateTests(unittest.TestCase): - def test_search_worker_and_real_fixture_require_search_runtime_gate(self): - for path in ('scripts/scripts_14.0/cloudfile-search-worker.py', 'tests/search_runtime.py'): - self.assertEqual(verify.plan(['cloudfile-docker/' + path])['required_gates'], ['search-runtime']) diff --git a/tests/verify.py b/tests/verify.py deleted file mode 100644 index 17123086..00000000 --- a/tests/verify.py +++ /dev/null @@ -1,404 +0,0 @@ -#!/usr/bin/env python3 -"""Scoped development verification and explicit clean runtime release gate.""" -import argparse -import ast -from contextlib import contextmanager -import hashlib -import fcntl -import json -from pathlib import Path -import secrets -import re -import subprocess -import sys -import time - -ROOT = Path(__file__).resolve().parents[1] -WORKSPACE = ROOT.parent -HUB = WORKSPACE / 'cloudfile-hub' -DEFAULT_IMAGE = 'cloudfile/cloudfile:14.0.8-v0.2-rc-work' -OWNER = hashlib.sha256(str(WORKSPACE).encode()).hexdigest()[:16] -LABEL = 'com.cloudfile.dev-verifier' -GROUPS = { - 'identity': ('identity', 'sessions', 'directory'), - 'authorization': ('authorization', 'directory', 'acl'), - 'transfer': ('delegation', 'services', 'authorization', 'acl'), - 'migration': ('migration',), - 'audit': ('audit', 'events'), -} - - -def command(args, **kwargs): - return subprocess.run(args, text=True, capture_output=True, **kwargs) - - -def git(repo, *args): - result = command(['git', '-C', str(WORKSPACE / repo), *args]) - if result.returncode: - raise RuntimeError('Git operation failed for ' + repo) - return result.stdout.strip() - - -def extensions_hash(): - digest = hashlib.sha256() - for path in sorted((HUB / 'cloudfile_extensions').rglob('*.py')): - digest.update(str(path.relative_to(HUB)).encode() + b'\0' + path.read_bytes() + b'\0') - return digest.hexdigest() - - -def baseline_checks(contract_python): - reports = {} - for name in ('verify_baseline.py', 'check_design_contracts.py'): - interpreter = contract_python if name == 'check_design_contracts.py' else sys.executable - result = command([interpreter, str(WORKSPACE / 'eap-cloudfile/tools' / name)]) - reports[name] = dict(passed=result.returncode == 0, output=result.stdout[-2000:]) - return reports - - -def changed_files(base=None): - files = set() - for repo in ('cloudfile-hub', 'cloudfile-server', 'cloudfile-docker', 'eap-cloudfile'): - # NUL separation handles spaces; diff detects removed paths as well. - for args in (['diff', '--name-only', '-z', 'HEAD'], - ['ls-files', '--others', '--exclude-standard', '-z']): - files.update(repo + '/' + p for p in git(repo, *args).split('\0') if p) - if base: - files.update(repo + '/' + p for p in git(repo, 'diff', '--name-only', '-z', base + '...HEAD').split('\0') if p) - return sorted(files) - - -def test_modules(group, seeds=None): - """Reverse import closure includes downstream tests and shared test fixtures.""" - sources = {} - for path in (HUB / 'cloudfile_extensions').rglob('*.py'): - module = '.'.join(path.relative_to(HUB).with_suffix('').parts) - if module.endswith('.__init__'): - module = module.removesuffix('.__init__') - sources[module] = path - edges = {} - for module, path in sources.items(): - imports = set() - for node in ast.walk(ast.parse(path.read_text())): - if isinstance(node, ast.Import): - imports.update(alias.name for alias in node.names) - elif isinstance(node, ast.ImportFrom): - package = module if path.name == '__init__.py' else module.rpartition('.')[0] - parent = package.split('.')[:len(package.split('.')) - node.level + 1] if node.level else [] - prefix = '.'.join(parent + ([node.module] if node.module else [])) - imports.add(prefix) - imports.update(prefix + '.' + alias.name for alias in node.names) - edges[module] = imports - affected = set(seeds) if seeds is not None else {module for module in sources if any( - module == 'cloudfile_extensions.' + domain or module.startswith('cloudfile_extensions.' + domain + '.') - for domain in GROUPS[group])} - while True: - expanded = affected | {module for module, imports in edges.items() if imports & affected} - if expanded == affected: - break - affected = expanded - return sorted(module for module in affected if module.startswith('cloudfile_extensions.tests.test_') - and not module.endswith('.test_download_actor')) - - -def plan(files): - groups, gates, reasons, seeds = set(), set(), [], set() - for item in files: - repo, _, path = item.partition('/') - if path.endswith('.md') or (repo == 'eap-cloudfile' and path.endswith('.txt')): - if repo == 'eap-cloudfile': - gates.add('baseline-contracts') - continue - if repo == 'cloudfile-hub' and path.startswith('cloudfile_extensions/tests/test_') and path.endswith('.py'): - module = path[:-3].replace('/', '.') - if module.endswith('test_download_actor'): - gates.add('native-runtime') - elif (HUB / path).exists(): - seeds.add(module) - if module.endswith(('test_schema', 'test_common')): - gates.add('full-regression') - else: - gates.add('full-regression') - continue - if repo == 'cloudfile-hub' and path.startswith('cloudfile_extensions/'): - domain = path.split('/')[1] - if domain in ('common', 'schema', 'jobs') or path in ('cloudfile_extensions/apps.py', 'cloudfile_extensions/root_urls.py'): - gates.add('full-regression') - else: - matches = {name for name, domains in GROUPS.items() if domain in domains} - if matches: - groups.update(matches) - if path.endswith('.py'): - seeds.add(path[:-3].replace('/', '.').removesuffix('.__init__')) - else: - gates.add('full-regression') - if domain == 'search': - gates.add('search-runtime') - if domain == 'events': - gates.add('audit-runtime') - if domain == 'identity': - gates.add('identity-runtime') - if any(name in path for name in ('manual_update', 'read_ticket', 'hosted_routes', 'configuration', '/urls.py')): - gates.add('web-runtime') - if path in ('cloudfile_extensions/identity/configuration.py', 'cloudfile_extensions/identity/hosted_routes.py', - 'cloudfile_extensions/identity/urls.py') or '/logout_' in path: - gates.add('identity-backchannel') - elif repo == 'cloudfile-docker': - groups.add('docker') - if path == 'scripts/scripts_14.0/cloudfile-jit-worker.py': - gates.update(('identity-provisioning', 'identity-backchannel')) - elif path in ('scripts/scripts_14.0/cloudfile-search-worker.py', 'tests/search_runtime.py'): - gates.add('search-runtime') - elif path in ('scripts/scripts_14.0/cloudfile-audit-worker.py', 'tests/audit_export_runtime.py'): - gates.add('audit-runtime') - elif path == 'tests/delegated_transfer_runtime.py': - gates.update(('transfer-runtime', 'web-runtime')) - elif path == 'tests/identity_provisioning_runtime.py': - gates.add('identity-provisioning') - elif path == 'tests/identity_logout_runtime.py': - gates.add('identity-logout') - elif path in ('tests/identity_backchannel_runtime.py', 'scripts/scripts_14.0/cloudfile-logout-worker.py'): - gates.add('identity-backchannel') - elif path.startswith(('image/', 'build/', 'scripts/')): - gates.add('native-runtime') - elif path == 'tests/probe_identity_runtime.py': - gates.update(('identity-runtime', 'audit-runtime', 'search-runtime')) - elif path == 'tests/smoke_ce14_runtime.py': - gates.add('native-runtime') - elif repo == 'eap-cloudfile': - gates.add('baseline-contracts') - else: - gates.update(('full-regression', 'native-build-and-runtime')) - reasons.append('Unmapped or native change: ' + item) - modules = set() - if seeds: - modules.update(test_modules('identity', seeds=seeds)) - # Dynamic imports are not an AST dependency. Unknown source edits - # require a broad gate rather than passing an empty selection. - if not modules: - gates.add('full-regression') - return dict(changed_files=files, groups=sorted(groups), modules=sorted(modules), - required_gates=sorted(gates), reasons=reasons) - - -def docker(*args, input=None, timeout=180): - result = command(['docker', *args], input=input, timeout=timeout) - if result.returncode: - raise RuntimeError('Docker operation failed: ' + args[0]) - return result.stdout.strip() - - -def owned(kind, name): - result = command(['docker', kind, 'inspect', name]) - if result.returncode: - # Do not interpret daemon errors as a missing resource. - docker('info', '--format', '{{.ServerVersion}}') - return None - data = json.loads(result.stdout)[0] - labels = (data.get('Config', {}) if kind == 'container' else data).get('Labels') or {} - if labels.get(LABEL) != OWNER: - raise RuntimeError('Refusing to use or remove unowned resource: ' + name) - return data - - -def core_names(warm): - prefix = 'cf-dev-' + OWNER if warm else 'cf-unit-' + secrets.token_hex(6) - return prefix + '-net', prefix + '-db', prefix + '-redis' - - -def core_up(names): - network, db, cache = names - created = [] - try: - info = owned('network', network) - if info is None: - docker('network', 'create', '--internal', '--label', LABEL + '=' + OWNER, network) - created.append(('network', network)) - elif not info.get('Internal'): - raise RuntimeError('Development network must be internal') - for name, alias, image, mount, env in ( - (db, 'test-db', 'mysql:8', '/var/lib/mysql:rw,size=1g', ['-e', 'MYSQL_ALLOW_EMPTY_PASSWORD=yes']), - (cache, 'test-redis', 'redis:7-alpine', '/data:rw,size=64m', [])): - info = owned('container', name) - if info is None: - try: - docker('run', '-d', '--name', name, '--network', network, '--network-alias', alias, - '--label', LABEL + '=' + OWNER, '--tmpfs', mount, *env, image) - except Exception: - if owned('container', name) is not None: - docker('rm', '-f', name) - raise - created.append(('container', name)) - elif (not info['State']['Running'] or info['HostConfig'].get('PortBindings') - or set(info['NetworkSettings']['Networks']) != {network} - or info['Image'] != json.loads(docker('image', 'inspect', image))[0]['Id']): - raise RuntimeError('Warm services changed or stopped; explicitly run warm down/up') - return created - except Exception: - for kind, name in reversed(created): - docker('rm' if kind == 'container' else 'network', *(['-f', name] if kind == 'container' else ['rm', name])) - raise - - -def core_down(names): - for kind, name in [('container', names[2]), ('container', names[1]), ('network', names[0])]: - if owned(kind, name) is not None: - docker('rm', '-f', name) if kind == 'container' else docker('network', 'rm', name) - - -@contextmanager -def core(warm): - names = core_names(warm) - created = core_up(names) - try: - yield names[0], warm and not created - finally: - if not warm: - core_down(names) - - -def run_components(modules, image, warm): - started = time.monotonic() - metadata = json.loads(docker('image', 'inspect', image))[0] - image_id = metadata['Id'] - if (git('cloudfile-server', 'status', '--porcelain') or - (metadata['Config'].get('Labels') or {}).get('com.cloudfile.source.seafile-server') != git('cloudfile-server', 'rev-parse', 'HEAD')): - raise RuntimeError('Native source changed; rebuild matching native artifact before component integration') - version = (metadata['Config'].get('Labels') or {}).get('com.cloudfile.seafile.version', '') - if not re.fullmatch(r'\d+\.\d+\.\d+', version): - raise RuntimeError('Component image must have a CE version label') - with core(warm) as (network, reused): - runner = 'cf-unit-run-' + secrets.token_hex(6) - args = ['run', '--rm', '-i', '--name', runner, '--label', LABEL + '=' + OWNER, - '--network', network, '--entrypoint', 'python3', - '-v', str(HUB) + ':/workspace/cloudfile-hub:ro', - '-v', str(WORKSPACE / 'eap-cloudfile/contracts') + ':/workspace/eap-cloudfile/contracts:ro', - '-v', str(ROOT / 'tests/run_component_tests.py') + ':/tmp/run_component_tests.py:ro', - '-w', '/workspace/cloudfile-hub'] - # latest is created by the real CE entrypoint; this lightweight runner - # intentionally starts neither initialization nor Seahub/native services. - installed = command(['docker', 'run', '--rm', '--entrypoint', 'sh', image_id, - '-c', 'printf "%s\\n" /opt/seafile/seafile-server-*']) - packages = installed.stdout.strip().splitlines() - if (installed.returncode or len(packages) != 1 or - not re.fullmatch(r'/opt/seafile/seafile-server-' + re.escape(version) + r'(?:-[a-zA-Z0-9.-]+)?', packages[0])): - raise RuntimeError('Component image has no unique CE package directory') - package = packages[0] - for key, value in dict(PYTHONDONTWRITEBYTECODE='1', - PYTHONPATH=':'.join(['/workspace/cloudfile-hub', package + '/seahub/thirdpart', - package + '/seafile/lib/python3/site-packages', package + '/pro/python']), - SEAHUB_DIR='/workspace/cloudfile-hub', SEAFES_DIR=package + '/pro/python', - CF_TEST_DB_HOST='test-db', CF_TEST_DB_PORT='3306', CF_TEST_REDIS_HOST='test-redis', - CF_TEST_REDIS_PORT='6379', CF_TEST_ACL_LIBRARY=package + '/seafile/lib/libcloudfile_acl.so.1').items(): - args += ['-e', key + '=' + value] - try: - result = command(['docker', *args, image_id, '/tmp/run_component_tests.py'], - input=json.dumps(modules), timeout=600) - finally: - if owned('container', runner) is not None: - docker('rm', '-f', runner) - try: - report = json.loads(result.stdout) - except ValueError: - raise RuntimeError('Component runner failed before safe report; exit=' + str(result.returncode)) from None - report.update(image_id=image_id, mode='development-source-overlay', warm_reused=reused, - total_seconds=round(time.monotonic() - started, 3)) - report['passed'] = result.returncode == 0 - return report - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('scope', choices=['changed', *GROUPS, 'docker', 'full', 'runtime', 'identity-runtime', 'identity-provisioning', 'identity-logout', 'identity-backchannel', 'transfer-runtime', 'web-runtime', 'audit-runtime', 'search-runtime', 'warm']) - parser.add_argument('--base', help='Explicit common Git ref for committed diff in all four repositories') - parser.add_argument('--changed-file', action='append', help='Repository/path; replaces Git change discovery') - parser.add_argument('--plan', action='store_true', help='Show selection without running services/tests') - parser.add_argument('--warm', action='store_true', help='Keep/reuse owned isolated MySQL/Redis for component tests') - parser.add_argument('--warm-action', choices=['up', 'status', 'down'], default='status') - parser.add_argument('--image', default=DEFAULT_IMAGE) - parser.add_argument('--contract-python', default=sys.executable, - help='Python with eap-cloudfile/tools/design-requirements.txt installed; used only for contract gate') - parser.add_argument('--annotations-probe', action='store_true', help='Runtime only: targeted native annotations source overlay') - parser.add_argument('--annotations-http-probe', action='store_true', help='Identity-runtime only: targeted authenticated annotations source overlay') - args = parser.parse_args() - if args.annotations_http_probe and args.scope != 'identity-runtime': - parser.error('--annotations-http-probe requires identity-runtime') - if args.annotations_probe and args.scope != 'runtime': - parser.error('--annotations-probe requires runtime scope') - if args.scope == 'warm': - names = core_names(True) - if args.warm_action == 'up': - core_up(names) - elif args.warm_action == 'down': - core_down(names) - print(json.dumps(dict(action=args.warm_action, resources={name: owned(kind, name) is not None - for kind, name in [('network', names[0]), ('container', names[1]), ('container', names[2])]}))) - return - selection = plan(args.changed_file if args.changed_file is not None else changed_files(args.base)) if args.scope == 'changed' else dict( - groups=[args.scope], modules=test_modules(args.scope) if args.scope in GROUPS else [], required_gates=[]) - if args.plan: - print(json.dumps(selection, indent=2)) - return - if args.scope in ('full', 'runtime', 'identity-runtime', 'identity-provisioning', 'identity-logout', 'identity-backchannel', 'transfer-runtime', 'web-runtime', 'audit-runtime', 'search-runtime'): - # Full gate tests the packaged artifact, never the development overlay. - if args.scope == 'full': - if not all(item['passed'] for item in baseline_checks(args.contract_python).values()): - raise RuntimeError('Locked source baseline failed; full gate refused') - labels = json.loads(docker('image', 'inspect', args.image))[0]['Config']['Labels'] - for repo, label in [('cloudfile-hub', 'seahub'), ('cloudfile-server', 'seafile-server')]: - if git(repo, 'status', '--porcelain') or labels.get('com.cloudfile.source.' + label) != git(repo, 'rev-parse', 'HEAD'): - raise RuntimeError('Full gate requires clean source matching packaged image: ' + repo) - from smoke_ce14_runtime import run - report = run(args.image, annotations_runtime=args.annotations_probe, extensions_regression=args.scope == 'full', - identity_runtime=args.scope in ('full', 'identity-runtime', 'identity-provisioning', 'identity-logout', 'identity-backchannel', 'transfer-runtime', 'web-runtime', 'audit-runtime', 'search-runtime'), - identity_scenario='annotations' if args.annotations_http_probe else 'provisioning' if args.scope == 'identity-provisioning' else - 'logout' if args.scope == 'identity-logout' else - 'backchannel' if args.scope == 'identity-backchannel' else - 'transfer' if args.scope == 'transfer-runtime' else - 'audit' if args.scope == 'audit-runtime' else - 'search' if args.scope == 'search-runtime' else - 'web' if args.scope == 'web-runtime' else 'prebound') - if args.scope == 'full': - report['provisioning_runtime'] = run(args.image, identity_runtime=True, - identity_scenario='provisioning') - report['logout_runtime'] = run(args.image, identity_runtime=True, - identity_scenario='logout') - report['backchannel_runtime'] = run(args.image, identity_runtime=True, - identity_scenario='backchannel') - print(json.dumps(report, indent=2)) - return - report = dict(selection=selection, scope='development-selected-tests', release_acceptance=False) - passed = True - before = extensions_hash() - if selection['modules']: - report['components'] = run_components(selection['modules'], args.image, args.warm) - passed &= report['components']['passed'] - if 'docker' in selection['groups']: - result = command(['docker', 'run', '--rm', '--entrypoint', 'python3', '-v', str(ROOT) + ':/workspace:ro', - '-w', '/workspace', '-e', 'PYTHONDONTWRITEBYTECODE=1', args.image, '-m', 'unittest', 'discover', '-s', 'tests'], timeout=120) - report['docker_tests'] = dict(passed=result.returncode == 0, output=result.stderr[-1500:]) - passed &= result.returncode == 0 - if 'baseline-contracts' in selection['required_gates']: - report['baseline'] = baseline_checks(args.contract_python) - passed &= all(item['passed'] for item in report['baseline'].values()) - report['source'] = {repo: dict(head=git(repo, 'rev-parse', 'HEAD'), dirty=bool(git(repo, 'status', '--porcelain'))) - for repo in ('cloudfile-hub', 'cloudfile-server', 'cloudfile-docker', 'eap-cloudfile')} - report['extensions_source_sha256'] = before - report['source_unchanged_during_tests'] = before == extensions_hash() - passed &= report['source_unchanged_during_tests'] - report['result'] = 'passed-selected-tests' if passed else 'failed' - print(json.dumps(report, indent=2)) - # Required integration gates remain visible and do not turn into a false green. - outstanding = set(selection['required_gates']) - {'baseline-contracts'} - raise SystemExit(1 if not passed else 2 if outstanding else 0) - - -if __name__ == '__main__': - # SQL schemas are random but several Redis fixtures flush the fixture DB. - # Serialize all warm operations; never collide with another verifier run. - with open('/tmp/cf-dev-' + OWNER + '.lock', 'a') as lock: - if '--warm' in sys.argv or 'warm' in sys.argv[1:2]: - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise SystemExit('Warm verification is already running for this workspace') - main() diff --git a/tools/check-hub-contracts.py b/tools/check-hub-contracts.py index 753f0911..7f2c3954 100644 --- a/tools/check-hub-contracts.py +++ b/tools/check-hub-contracts.py @@ -1,34 +1,22 @@ #!/usr/bin/env python3 -"""Run full-fork contracts or pinned clean-V2 HTTP units in isolated processes.""" -import importlib.util -import json +"""Run existing current-fork HTTP contracts with isolated native adapters.""" import os from pathlib import Path -import subprocess import sys -import tempfile here = Path(__file__).resolve().parent workspace = here.parents[1] hub = workspace / 'cloudfile-hub' -if '--v2' in sys.argv: - # V2 native seams are immutable reviewed overlays, not the full fork tree. - # Reuse production assembly and its receipt; never copy candidate files into dev. - spec = importlib.util.spec_from_file_location('prepare_v2_source', here / 'prepare-v2-source.py') - prepare = importlib.util.module_from_spec(spec) - spec.loader.exec_module(prepare) - sources = json.loads((here.parent / 'build/v2/source-set.json').read_text()) - refs = {name: config['overlay_commit'] for name, config in sources['components'].items()} - with tempfile.TemporaryDirectory(prefix='cf-v2-http-') as temporary: - output = Path(temporary) / 'source' - receipt = prepare.assemble(workspace, output, sources, refs) - print('V2 HTTP native source pins: ' + json.dumps(refs), flush=True) - assert receipt['components']['cloudfile-hub']['overlay_commit'] == refs['cloudfile-hub'] - env = dict(os.environ, CF_CHECK_NATIVE_ROOT=str(output / 'seahub')) - # Current regression tests run against the pinned production modules. - raise SystemExit(subprocess.call([sys.executable, __file__, - str(hub / 'cloudfile_ext/v2/tests'), '--import-mode=importlib'], env=env)) +# V2 runs the current fork's existing contracts. The retired overlay/source-set +# assembly no longer exists and must not select a second permission runtime. +arguments = sys.argv[1:] +if '--v2' in arguments: + arguments.remove('--v2') + if not arguments: + arguments = ['cloudfile_ext/v2/tests'] +if os.environ.get('CF_CHECK_NATIVE_ROOT'): + hub = Path(os.environ['CF_CHECK_NATIVE_ROOT']).resolve() os.chdir(hub) sys.path.insert(0, str(hub)) @@ -40,4 +28,4 @@ import pytest raise SystemExit(pytest.main(['-q', '--tb=short', '-c', os.devnull, '--rootdir', str(hub), - *(sys.argv[1:] or ['cloudfile_extensions/tests'])])) + *(arguments or ['cloudfile_ext/v2/tests'])])) diff --git a/tools/run-checks.sh b/tools/run-checks.sh index 854b35c9..71d03570 100644 --- a/tools/run-checks.sh +++ b/tools/run-checks.sh @@ -109,7 +109,7 @@ if [[ -d $hub ]]; then # The native HTTP unit tests use explicit RPC/ORM doubles and real DRF; # upstream runtime integration remains in the dedicated Seahub CI job. run "Hub 扩展测试 (Python)" python3 "$docker_repo/tools/check-hub-contracts.py" cloudfile_ext/ --ignore=cloudfile_ext/v2/tests - run "Hub 干净 V2 HTTP 契约 (固定 source-set)" python3 "$docker_repo/tools/check-hub-contracts.py" --v2 + run "Hub 当前 V2 HTTP 契约" python3 "$docker_repo/tools/check-hub-contracts.py" --v2 # Current contracts use isolated SQL fixtures and minimal Django settings, # not Seahub's native runtime or its upstream integration test settings. run "Hub 当前契约与 Agent 路由" bash -c \