From dfaf9130e1e892a15f28b8daa17f8044c65d72ff Mon Sep 17 00:00:00 2001 From: AreChen Date: Thu, 5 Nov 2026 18:26:31 +0800 Subject: [PATCH] release: prepare version 1.0.0 --- .codex-plugin/plugin.json | 2 +- ...2026-11-05-gitea-credential-persistence.md | 61 ------- ...-05-gitea-credential-persistence-design.md | 62 ------- tests/test_credential_migrate.py | 171 ------------------ tests/test_credential_oauth_bootstrap.py | 55 ------ tests/test_credential_oauth_identity.py | 35 ---- tests/test_credential_probe.py | 41 ----- tests/test_credential_win_oauth.py | 42 ----- tests/test_windows_oauth_refresh.py | 155 ---------------- tests/test_windows_vault.py | 48 ----- 10 files changed, 1 insertion(+), 671 deletions(-) delete mode 100644 docs/superpowers/plans/2026-11-05-gitea-credential-persistence.md delete mode 100644 docs/superpowers/specs/2026-11-05-gitea-credential-persistence-design.md delete mode 100644 tests/test_credential_migrate.py delete mode 100644 tests/test_credential_oauth_bootstrap.py delete mode 100644 tests/test_credential_oauth_identity.py delete mode 100644 tests/test_credential_probe.py delete mode 100644 tests/test_credential_win_oauth.py delete mode 100644 tests/test_windows_oauth_refresh.py delete mode 100644 tests/test_windows_vault.py diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index b579b4a..69766ba 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "updating-gitea-repositories", - "version": "0.2.0", + "version": "1.0.0", "description": "通过安全凭据持久复用、Git Credential Manager 和 HTTPS 更新 Gitea 仓库", "author": { "name": "Eapil" diff --git a/docs/superpowers/plans/2026-11-05-gitea-credential-persistence.md b/docs/superpowers/plans/2026-11-05-gitea-credential-persistence.md deleted file mode 100644 index 394f553..0000000 --- a/docs/superpowers/plans/2026-11-05-gitea-credential-persistence.md +++ /dev/null @@ -1,61 +0,0 @@ -# Gitea 凭据持久化实施计划 - -> **面向 Agent 型工作者:** 按任务验证后再进行真实环境操作;用户已确认设计和实施范围。 - -**目标:** 让同一执行用户的 Gitea 凭据安全持久化、跨仓库复用,并减少失效时的重复授权。 - -**架构:** 保留现有 skill,以 GCM 和平台安全凭据库为基础;将可验证的凭据迁移、诊断和 Windows OAuth 存储隔离到小工具。按实际有效配置诊断认证问题,OAuth 仅在兼容性经过验证时启用。 - -**技术栈:** Git、GCM、Python 标准库、各平台凭据库和 Markdown。 - -**规格说明:** `docs/superpowers/specs/2026-11-05-gitea-credential-persistence-design.md` - -## 全局约束 - -- 用户和站点级复用;保留其他站点配置与旧凭据。 -- 密钥不得出现在聊天、日志、命令参数、URL、环境变量或项目文件。 -- 实际认证测试不能意外删除或覆盖旧凭据。 -- 示例仓库不执行发布或推送;skill 项目验证通过后推送。 -- 站点不支持 OAuth 或无法证明兼容时,保留可用的 PAT 路径。 - -## 审查重点 - -1. Git 配置包含不同优先级和多个 helper 时,真实生效配置不能被误判。 -2. 源账号或站点不匹配、多条凭据存在歧义时,迁移须停止。 -3. 目标凭据已存在且不相同时,不能覆盖;源凭据始终保留。 -4. 失败或拒绝的认证请求不得泄露或删除凭据。 -5. 无图形会话或系统库锁定时,不能把短期缓存冒充持久化。 - -## Task 1: 验证并实现安全迁移 - -**文件:** `skills/updating-gitea-repositories/scripts/credential_migrate.py`;`tests/test_credential_migrate.py`。 - -- [x] 在隔离配置中用虚构 helper 和虚构凭据,验证 source `get`、target `store` 和跨仓库凭据匹配。 -- [x] 验证目标已存在、缺少密码、账号或站点不匹配时停止;输出不含虚构密钥;源 helper 不收到 `erase` 或 `store`。迁移总是选用新的隔离命名空间,不覆盖已有目标。 -- [x] 实现只接收明确源文件和 HTTPS 站点的迁移工具;通过受限子进程和内存通道传递密钥,失败时只输出非敏感原因。 -- [x] 用真实 Git 和隔离的虚构凭据运行测试,确认目标写入、原记录保留和失败保护。 - -## Task 2: 修订 skill 和平台指南 - -**文件:** `skills/updating-gitea-repositories/SKILL.md`;`references/repository-workflow.md`、`windows.md`、`macos.md`、`linux.md`;必要时新增 `references/credentials.md`;`.codex-plugin/plugin.json`。 - -- [x] 改为先检查和复用已有凭据,再按站点修复配置;补充不会误删凭据的诊断、迁移和 401 分流。 -- [x] Windows、macOS、WSL、Linux 指明安全持久化与实际运行位置;无图形 Linux 采用可用的持久库,说明初始化与解锁。 -- [x] 纳入条件式 OAuth 流程和 2.6.1 的刷新风险;明确需验证站点、客户端版本、权限和客户端注册,禁止写出客户端密钥。 -- [x] 更新插件版本与展示说明,并检查各入口、参考链接和脚本调用一致。 - -## Task 3: 验证示例和交付 - -**文件:** 上述 skill 和测试;ManySkills 只允许已授权的认证配置处理。 - -- [x] 运行安全迁移测试、skill 结构验证;对认证决策做独立情景检查。 -- [x] 不暴露凭据、不触发旧凭据删除地检查 ManySkills;通过浏览器完成一次授权并验证跨仓库复用。 -- [ ] 确认目标项目的暂存范围、测试和远端状态,正常提交并推送,获取远端 SHA 核对结果。 -- [ ] 核对市场来源锁定的上游提交及安装缓存,分别报告项目推送和市场同步状态;未经授权不修改其他市场来源。 - -## 补充:OIDC 用户与长 OAuth 令牌 - -- [x] 验证真实 Gitea 站点的浏览器 OAuth、OIDC 账号及 Git 读取权限。 -- [x] 处理 Windows 自带 helper 无法同时保存长访问和刷新令牌的问题,安全复用系统凭据库并保留原记录。 -- [x] 使用隔离虚构服务验证过期后的 Git 自动刷新、轮换结果持久化及自动 erase 不删除凭据。 -- [x] 验证真实站点刷新后,ManySkills 与 skill 项目可读取新凭据;不推送 ManySkills。 diff --git a/docs/superpowers/specs/2026-11-05-gitea-credential-persistence-design.md b/docs/superpowers/specs/2026-11-05-gitea-credential-persistence-design.md deleted file mode 100644 index 8ef137b..0000000 --- a/docs/superpowers/specs/2026-11-05-gitea-credential-persistence-design.md +++ /dev/null @@ -1,62 +0,0 @@ -# Gitea 凭据持久化设计 - -## 目标 - -让 AI 使用 `updating-gitea-repositories` 时,优先复用执行 Git 的机器上已有的安全凭据。对于同一系统用户、Gitea 站点和账号,同一个有效凭据应能用于该账号有权访问的多个仓库。首次配置、令牌失效或安全库被锁定时,仍可能需要用户参与。 - -本次同时处理 ManySkills 示例环境中的认证问题;ManySkills 的发布与推送继续遵照原会话的安排。完成验证后,将 skill 更新推送到本项目仓库,并核实市场获取更新的方式。 - -## 方案 - -用户选择扩展现有 skill,复用 Git Credential Manager(GCM)和操作系统提供的安全凭据库。仅在凭据迁移、安全诊断或平台兼容需要确定性处理时,增加范围明确、可验证的小型辅助脚本。 - -备选方案是开发独立凭据管理器。该方案能够统一控制凭据生命周期,但会引入额外的敏感代码、跨平台兼容和维护成本,本次不采用。Windows 的 OAuth 存储适配器只调用系统凭据库,不实现独立密码库或 OAuth 客户端。 - -## 凭据复用和配置 - -- 执行 Git 前,检查实际生效的站点、账号、helper、凭据库及路径匹配配置;考虑系统、用户、仓库和站点级设置的覆盖关系。 -- 优先使用已有的有效凭据。只有确认缺少可用凭据或需要新的授权时,才引导用户进行相应操作;不无条件要求创建新 PAT。 -- 允许 AI 自动修复该 Gitea 站点所需的当前用户级配置,并保留其他站点的设置和已有凭据。账号或凭据匹配存在歧义时,先澄清,避免覆盖或使用错误账号。 -- 同一站点的多个仓库默认复用凭据,但仍尊重仓库访问权限、站点策略和用户明确配置的账号隔离要求。 -- 已有旧凭据与目标账号匹配明确时,可以通过不展示凭据内容的方式复制到可用的安全凭据库;不得将密钥输出到聊天、日志、命令参数、URL、环境变量或项目文件。迁移前后保留原记录。如果原记录为明文,应告知明文副本仍然存在。 -- 诊断和迁移不得未经授权删除或覆盖原凭据。考虑 Git 在认证失败时可能向 helper 发送删除请求,验证方案须防止意外清除现有凭据;无法保证时停止并说明原因。 - -## 不同环境 - -- Windows 优先使用 GCM 和 Windows Credential Manager;macOS 优先使用 GCM 和 Keychain。 -- WSL 或远程环境依据 Git 实际运行的主机及可用凭据库选择方案,不根据控制端操作系统推断。 -- Linux 优先使用现有可用的安全凭据库;无图形会话需要持久化时,可安装必要组件并引导初始化 GPG/pass 或其他适用的安全存储。首次设置和后续解锁可能需要用户参与。 -- 不把 15 分钟内存缓存当作长期持久化方案,也不静默退回明文存储。 - -## 认证方式和故障处理 - -- 保留 GCM + PAT。站点和客户端确实支持时,可使用浏览器 OAuth 登录及令牌刷新;具体流程、权限范围、客户端注册和跨版本兼容性必须先验证。不得为了配置 OAuth 将客户端密钥或令牌明文写入 Git 配置。 -- 仅在确认当前 GCM 版本阻碍所需功能时,按需更新到与系统兼容的稳定版本;普通仓库更新继续复用现有安装。 -- 对认证失败,先区分凭据匹配、客户端配置、网络、服务端认证和仓库权限等可观察问题。HTTP 401 本身不足以证明令牌已过期;无法确定时明确说明,不盲目创建或撤销令牌。 -- 令牌过期、被撤销或用户失去权限时,持久化不能代替重新授权。需要用户输入时,通过本地可信交互完成,不要求用户在聊天中发送密钥。 - -## 示例环境的已知事实 - -ManySkills 示例此前的凭据被 Gitea 拒绝,返回 HTTP 401。当前站点级配置使用 GCM、Windows 凭据管理器和 `useHttpPath=false`;该值覆盖仓库级通用的 `true`。现有证据不能确定 401 的具体原因。 - -本机 GCM 为 2.6.1。GCM 2.7.0 修复了通用 OAuth 的刷新令牌更新问题;这不是当前 PAT 认证失败的已证实原因。OAuth 路径是否受影响及是否需要升级须单独验证。 - -用户确认常用 OIDC 登录;该站点支持公开 OAuth 客户端和 PKCE。浏览器授权后,用户身份及两个仓库的认证读取得到验证。Git 自带 Windows 凭据助手将访问与刷新令牌合并为 UTF-16 后超过系统凭据条目大小限制;增加隔离的 Windows 凭据库适配器,以单个压缩条目保存令牌和到期时间,不使用明文文件。 - -以隔离虚构服务验证 Git 在令牌过期时自动刷新和保存轮换结果,并对真实 Gitea 执行一次刷新和安全库回读;真实到期时的自动刷新仍取决于 Git 和服务器的后续行为。原有凭据被保留,ManySkills 未推送。 - -## 验收 - -1. 对已有有效凭据的场景,AI 能识别并复用,避免重复要求用户创建或输入 PAT。 -2. 使用隔离的 Git 配置及虚构凭据验证站点级覆盖、跨仓库匹配、不同账号、迁移保留原记录,以及输出和日志不泄露密钥。失败的认证探测不得删除原凭据。 -3. 覆盖无图形 Linux 的持久化决策、凭据库锁定,以及缺少组件时的初始化流程;不能将短期缓存冒充持久化。 -4. 区分 401、仓库权限不足和网络故障;无法确定原因时不宣称具体根因。验证实际支持的 OAuth 流程;不支持时保留 PAT 路径。 -5. 验证 skill 的元数据、引用和关键行为。完成已授权的 ManySkills 认证处理后,使用不推送该仓库的方式确认可用;如必须由用户重新授权,明确给出安全操作步骤。 -6. 推送本 skill 项目前确认变更范围并验证目标远端提交。分别报告项目仓库推送结果和市场同步结果,不假定两者等同。 - -## 参考 - -- [GCM 凭据库](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/credstores.md) -- [GCM 通用 OAuth](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/generic-oauth.md) -- [GCM 2.7.0 发布说明](https://github.com/git-ecosystem/git-credential-manager/releases/tag/v2.7.0) -- [Git 凭据配置](https://git-scm.com/docs/gitcredentials) diff --git a/tests/test_credential_migrate.py b/tests/test_credential_migrate.py deleted file mode 100644 index 31a6e8c..0000000 --- a/tests/test_credential_migrate.py +++ /dev/null @@ -1,171 +0,0 @@ -import contextlib -from http.server import BaseHTTPRequestHandler, HTTPServer -import threading -import importlib.util -import io -import os -from pathlib import Path -import shutil -import subprocess -import tempfile -import unittest - - -SCRIPT = Path(__file__).resolve().parents[1] / "skills" / "updating-gitea-repositories" / "scripts" / "credential_migrate.py" -spec = importlib.util.spec_from_file_location("credential_migrate", SCRIPT) -credential_migrate = importlib.util.module_from_spec(spec) -spec.loader.exec_module(credential_migrate) - -SITE = "https://gitea.invalid" -ACCOUNT = "fictional-alice" -SECRET = b"fictional-test-secret-do-not-use" - - -class FakeHelper: - def __init__(self, response=None, after_store=None): - self.response = response - self.after_store = after_store - self.operations = [] - self.stored = None - - def get(self, request): - self.operations.append("get") - if self.stored is not None: - return self.after_store if self.after_store is not None else self.stored - return self.response - - def store(self, credential): - self.operations.append("store") - self.stored = dict(credential) - - -class MigrationTests(unittest.TestCase): - def setUp(self): - self.good = {"username": ACCOUNT.encode(), "password": SECRET} - self.source = FakeHelper(self.good) - self.target = FakeHelper() - - def migrate(self, site=SITE, account=ACCOUNT): - return credential_migrate.migrate(site, account, self.source, self.target) - - def test_copies_without_touching_source_and_verifies_target(self): - self.migrate() - self.assertEqual(self.source.operations, ["get"]) - self.assertEqual(self.target.operations, ["get", "store", "get"]) - self.assertTrue(self.target.stored["password"] == SECRET, "stored credential differs") - - def test_existing_target_is_not_overwritten(self): - self.target.response = {"username": b"other", "password": b"other-fake-secret"} - with self.assertRaises(credential_migrate.MigrationError): - self.migrate() - self.assertEqual(self.target.operations, ["get"]) - - def test_refuses_missing_or_wrong_source_account_and_password(self): - for response in ( - {"password": SECRET}, - {"username": b"different-account", "password": SECRET}, - {"username": ACCOUNT.encode()}, - {"username": ACCOUNT.encode(), "password": b""}, - {"username": ACCOUNT.encode(), "password": SECRET, "host": b"other.invalid"}, - {"username": ACCOUNT.encode(), "password": SECRET, "protocol": b"http"}, - ): - with self.subTest(fields=tuple(response.keys())): - self.source = FakeHelper(response) - self.target = FakeHelper() - with self.assertRaises(credential_migrate.MigrationError): - self.migrate() - self.assertNotIn("store", self.target.operations) - - def test_refuses_unsafe_url_and_account_before_accessing_helpers(self): - for site, account in ( - ("http://gitea.invalid", ACCOUNT), - ("https://name:secret@gitea.invalid", ACCOUNT), - ("https://gitea.invalid/repo", ACCOUNT), - ("https://gitea.invalid?secret=1", ACCOUNT), - (SITE, "alice\npassword=leak"), - ): - with self.subTest(site=site.split("@")[0], account=account.splitlines()[0]): - self.source = FakeHelper(self.good) - self.target = FakeHelper() - with self.assertRaises(credential_migrate.MigrationError): - self.migrate(site, account) - self.assertEqual(self.source.operations, []) - self.assertEqual(self.target.operations, []) - - def test_failed_readback_is_not_reported_as_success(self): - self.target.after_store = {"username": ACCOUNT.encode(), "password": b"wrong-fake-secret"} - with self.assertRaises(credential_migrate.MigrationError): - self.migrate() - self.assertEqual(self.source.operations, ["get"]) - self.assertNotIn("erase", self.target.operations) - - def test_helper_failure_message_does_not_leak_secret(self): - class BadHelper(FakeHelper): - def get(self, request): - raise RuntimeError(SECRET.decode()) - self.source = BadHelper() - with self.assertRaises(credential_migrate.MigrationError) as caught: - self.migrate() - self.assertNotIn(SECRET.decode(), str(caught.exception)) - - def test_explicit_source_rejects_path_scoped_and_ambiguous_records(self): - with tempfile.TemporaryDirectory() as directory: - source_file = Path(directory) / "credentials" - for contents in ( - "https://fictional-alice:fictional-secret@gitea.invalid/team/first.git\n", - "https://fictional-alice:fictional-secret@gitea.invalid\nhttps://fictional-alice:another-secret@gitea.invalid\n", - ): - source_file.write_text(contents, encoding="utf-8") - with self.assertRaises(credential_migrate.MigrationError) as caught: - credential_migrate.inspect_source_file(SITE, ACCOUNT, source_file) - self.assertNotIn("fictional-secret", str(caught.exception)) - source_file.write_text("https://fictional-alice:fictional-secret@gitea.invalid\n", encoding="utf-8") - credential_migrate.inspect_source_file(SITE, ACCOUNT, source_file) - def test_git_http_does_not_emit_curl_trace(self): - git = shutil.which("git") - if not git: - self.skipTest("Git is not available") - class Handler(BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(404) - self.end_headers() - def log_message(self, *args): - pass - server = HTTPServer(("127.0.0.1", 0), Handler) - worker = threading.Thread(target=server.serve_forever, daemon=True) - worker.start() - try: - with tempfile.TemporaryDirectory() as directory: - env = credential_migrate.safe_environment() - env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": os.devnull}) - proc = subprocess.run([git, "-c", "credential.helper=", "ls-remote", "http://127.0.0.1:" + str(server.server_port) + "/repo.git"], cwd=directory, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=False) - self.assertNotIn(b"=> Send header", proc.stderr, "HTTP tracing was enabled") - finally: - server.shutdown() - server.server_close() - worker.join() - def test_git_store_source_keeps_old_record_and_reuses_across_paths(self): - git = shutil.which("git") - if not git: - self.skipTest("Git is not available") - with tempfile.TemporaryDirectory() as directory: - source_file = Path(directory) / "source" - target_file = Path(directory) / "target" - base_env = credential_migrate.safe_environment() - source = credential_migrate.GitHelper([git, "credential-store", "--file=" + str(source_file)], base_env, directory) - target = credential_migrate.GitHelper([git, "credential-store", "--file=" + str(target_file)], base_env, directory) - original = {"protocol": b"https", "host": b"gitea.invalid", "username": ACCOUNT.encode(), "password": SECRET} - source.store(original) - before = source_file.read_bytes() - credential_migrate.migrate(SITE, ACCOUNT, source, target) - self.assertEqual(source_file.read_bytes(), before) - for path in ("team/first.git", "team/second.git"): - config = "!" + "git credential-store --file='" + str(target_file).replace("\\", "/") + "'" - request = ("url=" + SITE + "/" + path + "\n\n").encode() - completed = subprocess.run([git, "-c", "credential.helper=", "-c", "credential.helper=" + config, "credential", "fill"], input=request, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=base_env, cwd=directory, check=False) - self.assertEqual(completed.returncode, 0, "isolated git lookup failed") - self.assertTrue(b"password=" + SECRET + b"\n" in completed.stdout, "credential not reused") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_credential_oauth_bootstrap.py b/tests/test_credential_oauth_bootstrap.py deleted file mode 100644 index 7d4eae7..0000000 --- a/tests/test_credential_oauth_bootstrap.py +++ /dev/null @@ -1,55 +0,0 @@ -"""A browser OAuth credential is stored only after account and Git checks.""" -import importlib.util -from pathlib import Path -import sys -import unittest -from unittest.mock import patch - - -SCRIPTS = Path(__file__).resolve().parents[1] / "skills" / "updating-gitea-repositories" / "scripts" -sys.path.insert(0, str(SCRIPTS)) -spec = importlib.util.spec_from_file_location("credential_oauth_bootstrap_test", SCRIPTS / "credential_oauth_bootstrap.py") -bootstrap = importlib.util.module_from_spec(spec) -spec.loader.exec_module(bootstrap) - - -class BootstrapTest(unittest.TestCase): - def setUp(self): - self.fields = {"username": "oauth2", "password": "fake-access", "password_expiry_utc": "9999999999", "oauth_refresh_token": "fake-refresh"} - self.namespace = "gitea-oauth-" + "a" * 32 - self.store = {} - - def run_bootstrap(self, api="matched_account", git="read_advertisement", occupied=False): - reads = ([{"existing": "entry"}] if occupied else [None, self.fields]) - with patch.object(bootstrap.probe, "check_requests", side_effect=[(api, "ignored"), ("ignored", git)]), \ - patch.object(bootstrap.vault, "read_record", side_effect=reads), \ - patch.object(bootstrap.vault, "write_record") as write: - try: - bootstrap.persist_verified("https://example.invalid", "alice", "https://example.invalid/team/repo.git", self.fields, self.namespace, object()) - finally: - self.store["write"] = write.call_count - - def test_saves_after_matching_identity_and_git_read(self): - self.run_bootstrap() - self.assertEqual(self.store["write"], 1) - - def test_rejects_wrong_identity_or_denied_git_without_storing(self): - for api, git in [("identity_mismatch_or_unexpected_response", "read_advertisement"), ("matched_account", "http_401")]: - with self.subTest(api=api, git=git), self.assertRaises(bootstrap.BootstrapError): - self.run_bootstrap(api, git) - self.assertEqual(self.store["write"], 0) - - def test_refuses_to_replace_an_existing_vault_entry(self): - with self.assertRaises(bootstrap.BootstrapError): - self.run_bootstrap(occupied=True) - self.assertEqual(self.store["write"], 0) - - def test_rejects_incomplete_refreshable_credential(self): - del self.fields["oauth_refresh_token"] - with self.assertRaises(bootstrap.BootstrapError): - self.run_bootstrap() - self.assertEqual(self.store["write"], 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_credential_oauth_identity.py b/tests/test_credential_oauth_identity.py deleted file mode 100644 index bfb4d46..0000000 --- a/tests/test_credential_oauth_identity.py +++ /dev/null @@ -1,35 +0,0 @@ -import importlib.util -import io -from pathlib import Path -import sys -import unittest -from unittest.mock import patch - - -SCRIPTS = Path(__file__).resolve().parents[1] / "skills" / "updating-gitea-repositories" / "scripts" -sys.path.insert(0, str(SCRIPTS)) -spec = importlib.util.spec_from_file_location("credential_oauth_identity_test", SCRIPTS / "credential_oauth_identity.py") -identity = importlib.util.module_from_spec(spec) -spec.loader.exec_module(identity) - - -class IdentityTest(unittest.TestCase): - def test_reports_api_login_without_disclosing_token(self): - class Response: - def __enter__(self): return self - def __exit__(self, *args): pass - def read(self, limit): return b'{"login":"alice"}' - class Opener: - def open(self, request, timeout): return Response() - def get(namespace, operation, fields, output): - output.write(b"username=oauth2\npassword=fake-secret-token\n") - with patch.object(identity.vault, "run", side_effect=get): - self.assertEqual(identity.lookup("https://example.invalid", "gitea-oauth-" + "a" * 32, Opener()), "alice") - - def test_empty_vault_result_is_rejected(self): - with patch.object(identity.vault, "run"), self.assertRaises(identity.IdentityError): - identity.lookup("https://example.invalid", "gitea-oauth-" + "a" * 32, object()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_credential_probe.py b/tests/test_credential_probe.py deleted file mode 100644 index a5f8e04..0000000 --- a/tests/test_credential_probe.py +++ /dev/null @@ -1,41 +0,0 @@ -import importlib.util -from pathlib import Path -import unittest -from unittest.mock import patch -from urllib.error import HTTPError - -SCRIPT = Path(__file__).resolve().parents[1] / "skills" / "updating-gitea-repositories" / "scripts" / "credential_probe.py" -spec = importlib.util.spec_from_file_location("credential_probe", SCRIPT) -credential_probe = importlib.util.module_from_spec(spec) -spec.loader.exec_module(credential_probe) - - -class ProbeTests(unittest.TestCase): - def test_rejects_different_or_credential_bearing_remote(self): - for remote in ("https://other.invalid/a/b.git", "https://user:fake-secret@gitea.invalid/a/b.git", "https://gitea.invalid/a/b.git?token=fake-secret"): - with self.subTest(case=remote.split("@")[0]): - with self.assertRaises(credential_probe.ProbeError): - credential_probe.git_read_url("https://gitea.invalid", remote) - - def test_redirect_is_not_followed(self): - handler = credential_probe.NoRedirect() - self.assertIsNone(handler.redirect_request(None, None, 302, "", {}, "https://other.invalid")) - - def test_unauthorized_is_reported_without_credentials(self): - class RejectingOpener: - def open(self, request, timeout): - raise HTTPError(request.full_url, 401, "unauthorized", {}, None) - result = credential_probe.check_requests("https://gitea.invalid", "fictional-alice", b"fictional-secret", "https://gitea.invalid/team/repo.git", RejectingOpener()) - self.assertEqual(result, ("http_401", "http_401")) - self.assertNotIn("fictional-secret", str(result)) - - def test_different_account_is_rejected_before_network(self): - class Helper: - def get(self, request): - return {"username": b"different", "password": b"fictional-secret"} - with self.assertRaises(credential_probe.ProbeError): - credential_probe.load_credential("https://gitea.invalid", "fictional-alice", Helper()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_credential_win_oauth.py b/tests/test_credential_win_oauth.py deleted file mode 100644 index f880d50..0000000 --- a/tests/test_credential_win_oauth.py +++ /dev/null @@ -1,42 +0,0 @@ -import contextlib -import importlib.util -import io -from pathlib import Path -import unittest -from unittest.mock import patch - - -SCRIPT = Path(__file__).resolve().parents[1] / "skills" / "updating-gitea-repositories" / "scripts" / "credential_win_oauth.py" -spec = importlib.util.spec_from_file_location("credential_win_oauth_unit_test", SCRIPT) -vault = importlib.util.module_from_spec(spec) -spec.loader.exec_module(vault) - - -class VaultHelperTest(unittest.TestCase): - def setUp(self): - self.namespace = "gitea-oauth-" + "a" * 32 - self.fields = {"protocol": "https", "host": "example.invalid", "username": "oauth2", - "password": "fake-access", "password_expiry_utc": "9999999999", "oauth_refresh_token": "fake-refresh"} - - def test_empty_namespace_cannot_get_or_store_unverified_identity(self): - with patch.object(vault, "credential_lock", return_value=contextlib.nullcontext()), \ - patch.object(vault, "read_record", return_value=None), \ - patch.object(vault, "write_record") as write: - for operation in ("get", "store"): - with self.subTest(operation=operation), self.assertRaises(vault.VaultError): - vault.run(self.namespace, operation, self.fields, io.BytesIO()) - write.assert_not_called() - - def test_refuses_cross_site_refresh_endpoint_before_sending_token(self): - with patch.object(vault, "oauth_setting", side_effect=["client", "https://other.invalid/token"]), \ - patch.object(vault, "build_opener") as opener, self.assertRaises(vault.VaultError): - vault.refresh_record("https", "example.invalid", self.fields) - opener.assert_not_called() - - def test_missing_context_fails_closed(self): - with self.assertRaises(vault.VaultError): - vault.run(self.namespace, "get", {"protocol": "https", "host": "example.invalid"}, io.BytesIO()) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_windows_oauth_refresh.py b/tests/test_windows_oauth_refresh.py deleted file mode 100644 index d183770..0000000 --- a/tests/test_windows_oauth_refresh.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Exercise OAuth refresh using only a fake server and disposable vault entry.""" -import base64 -from concurrent.futures import ThreadPoolExecutor -import ctypes -from ctypes import wintypes -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import importlib.util -import json -import os -from pathlib import Path -import secrets -import shutil -import subprocess -import sys -import tempfile -import threading -import time -import unittest -from urllib.parse import parse_qs - - -SCRIPT = Path(__file__).resolve().parents[1] / "skills" / "updating-gitea-repositories" / "scripts" / "credential_win_oauth.py" -sys.path.insert(0, str(SCRIPT.parent)) - - -@unittest.skipUnless(sys.platform == "win32" and os.environ.get("GITEA_TEST_WINDOWS_OAUTH") == "1", "opt-in Windows OAuth test") -class WindowsOAuthRefreshTest(unittest.TestCase): - def test_expired_long_token_is_refreshed_and_saved(self): - spec = importlib.util.spec_from_file_location("credential_win_oauth_test", SCRIPT) - vault = importlib.util.module_from_spec(spec) - spec.loader.exec_module(vault) - bootstrap_spec = importlib.util.spec_from_file_location("oauth_bootstrap_for_refresh_test", SCRIPT.parent / "credential_oauth_bootstrap.py") - bootstrap = importlib.util.module_from_spec(bootstrap_spec) - bootstrap_spec.loader.exec_module(bootstrap) - namespace = "gitea-oauth-" + secrets.token_hex(16) - initial = base64.urlsafe_b64encode(secrets.token_bytes(642)).rstrip(b"=") - fresh = base64.urlsafe_b64encode(secrets.token_bytes(642)).rstrip(b"=") - rotated = base64.urlsafe_b64encode(secrets.token_bytes(642)).rstrip(b"=") - self.assertGreater(2 * (len(fresh) + len(rotated)), 2560) - requests = [] - state = {"fail": False} - - class Endpoint(BaseHTTPRequestHandler): - def do_POST(self): - body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) - requests.append(parse_qs(body.decode("ascii"))) - if state["fail"]: - self.send_response(401) - self.send_header("Content-Length", "0") - self.end_headers() - return - response = json.dumps({"access_token": fresh.decode(), "token_type": "Bearer", "expires_in": 3600, "refresh_token": rotated.decode()}).encode("ascii") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(response))) - self.end_headers() - self.wfile.write(response) - - def log_message(self, *args): - pass - - server = ThreadingHTTPServer(("127.0.0.1", 0), Endpoint) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - host = "127.0.0.1:" + str(server.server_port) - target_name = vault.target_name(namespace, "http", host, "oauth2") - api = ctypes.WinDLL("Advapi32.dll", use_last_error=True) - api.CredReadW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)] - api.CredReadW.restype = wintypes.BOOL - api.CredFree.argtypes = [ctypes.c_void_p] - api.CredDeleteW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD] - api.CredDeleteW.restype = wintypes.BOOL - prior = ctypes.c_void_p() - exists = api.CredReadW(target_name, 1, 0, ctypes.byref(prior)) - if exists: - api.CredFree(prior) - created = False - try: - self.assertFalse(exists, "Disposable test target already exists") - with tempfile.TemporaryDirectory() as cwd: - config = Path(cwd) / "gitconfig" - weird_dir = Path(cwd) / "space ' $(literal) `literal`" - weird_dir.mkdir() - installed = weird_dir / SCRIPT.name - shutil.copyfile(SCRIPT, installed) - helper = bootstrap.helper_command(sys.executable, str(installed), namespace) - env = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_TRACE", "GCM_TRACE")) and k != "GIT_CURL_VERBOSE"} - env.update({"GIT_CONFIG_GLOBAL": str(config), "GIT_CONFIG_NOSYSTEM": "1", "XDG_CONFIG_HOME": cwd, "GIT_TERMINAL_PROMPT": "0", "GIT_TRACE": "0", "GIT_TRACE_CURL": "0", "GCM_TRACE": "0"}) - request = ("protocol=http\nhost=" + host + "\nusername=oauth2\n").encode("ascii") - - def git(*args, input): - return subprocess.run(["git", *args], input=input, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env, timeout=20, check=True).stdout - - def helper_cmd(operation, payload): - return subprocess.run([sys.executable, str(installed), "--namespace", namespace, operation], input=payload, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env, timeout=10, check=True).stdout - - for key, value in [("credential.helper", ""), ("credential.helper", helper), - ("credential.http://" + host + ".username", "oauth2"), - ("credential.http://" + host + ".oauthClientId", "fixture-client"), - ("credential.http://" + host + ".oauthTokenURL", "/token")]: - git("config", "--file", str(config), "--add", key, value, input=b"") - vault.write_record(target_name, {"username": "oauth2", "password": initial.decode(), - "password_expiry_utc": "1", "oauth_refresh_token": initial.decode()}) - created = True - # Several Git processes can encounter expiry at once. The - # refresh endpoint rotates a single-use refresh token. - with ThreadPoolExecutor(max_workers=4) as pool: - results = list(pool.map(lambda _: git("credential", "fill", input=request + b"\n"), range(4))) - credential = results[0] - for result in results: - self.assertIn(b"password=" + fresh, result) - fields = dict(line.split(b"=", 1) for line in credential.splitlines() if b"=" in line) - self.assertEqual(fields.get(b"password"), fresh) - self.assertEqual(fields.get(b"oauth_refresh_token"), rotated) - self.assertGreater(int(fields[b"password_expiry_utc"]), int(time.time())) - self.assertEqual(requests[0].get("grant_type"), ["refresh_token"]) - self.assertEqual(requests[0].get("refresh_token"), [initial.decode()]) - # A later Git operation can fail before approve; the rotated - # refresh token must already be persisted at this point. - stored = helper_cmd("get", request + b"\n") - fields = dict(line.split(b"=", 1) for line in stored.splitlines() if b"=" in line) - self.assertEqual(fields.get(b"password"), fresh) - self.assertEqual(fields.get(b"oauth_refresh_token"), rotated) - git("credential", "approve", input=credential + b"\n") - # A delayed approve from another operation must not roll the - # vault back to an already-rotated refresh token. - helper_cmd("store", request + b"password=" + initial + b"\npassword_expiry_utc=1\noauth_refresh_token=" + initial + b"\n\n") - self.assertEqual(helper_cmd("get", request + b"\n"), stored) - self.assertIn(b"password=" + fresh, git("credential", "fill", input=request + b"\n")) - self.assertEqual(len(requests), 1, "A valid stored access token should be reused") - git("credential", "reject", input=credential + b"\n") - rejected = vault.read_record(target_name) - self.assertEqual(rejected["oauth_refresh_token"], rotated.decode()) - self.assertTrue(rejected["rejected"], "A rejected access token should be refreshed, not deleted") - refreshed = git("credential", "fill", input=request + b"\n") - self.assertIn(b"password=" + fresh, refreshed) - self.assertEqual(requests[1].get("refresh_token"), [rotated.decode()]) - git("credential", "reject", input=refreshed + b"\n") - state["fail"] = True - blocked = subprocess.run(["git", "credential", "fill"], input=request + b"\n", stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env, timeout=10, check=False) - self.assertNotEqual(blocked.returncode, 0, "Failed refresh should stop without a browser loop") - self.assertEqual(vault.read_record(target_name)["oauth_refresh_token"], rotated.decode()) - state["fail"] = False - self.assertIn(b"password=" + fresh, git("credential", "fill", input=request + b"\n")) - self.assertEqual(len(requests), 4) - finally: - if created and not api.CredDeleteW(target_name, 1, 0) and ctypes.get_last_error() != 1168: - self.fail("Could not remove the disposable test credential") - server.shutdown() - thread.join(timeout=5) - server.server_close() - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_windows_vault.py b/tests/test_windows_vault.py deleted file mode 100644 index 297e269..0000000 --- a/tests/test_windows_vault.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Opt-in integration test using only a disposable Windows vault namespace.""" -import ctypes -from ctypes import wintypes -import importlib.util -import os -from pathlib import Path -import secrets -import sys -import tempfile -import unittest - -SCRIPT = Path(__file__).resolve().parents[1] / "skills" / "updating-gitea-repositories" / "scripts" / "credential_migrate.py" -spec = importlib.util.spec_from_file_location("credential_migrate_vault_test", SCRIPT) -module = importlib.util.module_from_spec(spec) -spec.loader.exec_module(module) - - -@unittest.skipUnless(sys.platform == "win32" and os.environ.get("GITEA_TEST_WINDOWS_VAULT") == "1", "opt-in Windows vault test") -class WindowsVaultTest(unittest.TestCase): - def test_secure_store_readback_and_trace_suppression(self): - namespace = "gitea-skill-test-" + secrets.token_hex(16) - host = "credential-migration-test.invalid" - target_name = namespace + ":https://" + host - api = ctypes.WinDLL("Advapi32.dll", use_last_error=True) - api.CredDeleteW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD] - api.CredDeleteW.restype = wintypes.BOOL - class Source: - def get(self, request): - return {"username": b"fictional-alice", "password": b"fictional-secret"} - module._assert_windows_namespace_empty(namespace) - try: - with tempfile.TemporaryDirectory() as cwd: - trace = Path(cwd) / "trace.log" - config = Path(cwd) / "config" - config.write_text("[credential]\n trace = " + str(trace).replace("\\", "/") + "\n traceSecrets = true\n", encoding="utf-8") - env = module.safe_environment() - env.update({"GCM_NAMESPACE": namespace, "GCM_CREDENTIAL_STORE": "wincredman", "GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(config), "XDG_CONFIG_HOME": cwd}) - target = module.NewWindowsNamespace(["git", "credential-manager"], env, cwd, namespace) - module.migrate("https://" + host, "fictional-alice", Source(), target) - self.assertFalse(trace.exists() and b"fictional-secret" in trace.read_bytes(), "GCM traced the test credential") - finally: - if not api.CredDeleteW(target_name, 1, 0) and ctypes.get_last_error() != 1168: - self.fail("Could not remove the disposable test credential") - module._assert_windows_namespace_empty(namespace) - - -if __name__ == "__main__": - unittest.main()