@kernel-sig/console 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +50 -0
- package/LICENSE +127 -0
- package/Makefile +66 -0
- package/README.md +279 -0
- package/backend/.dockerignore +12 -0
- package/backend/Dockerfile +43 -0
- package/backend/alembic/env.py +75 -0
- package/backend/alembic/script.py.mako +25 -0
- package/backend/alembic/versions/0001_initial.py +88 -0
- package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
- package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
- package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
- package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
- package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
- package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
- package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
- package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
- package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
- package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
- package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
- package/backend/alembic.ini +40 -0
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/deps.py +74 -0
- package/backend/app/api/v1/__init__.py +0 -0
- package/backend/app/api/v1/ai.py +386 -0
- package/backend/app/api/v1/analytics.py +48 -0
- package/backend/app/api/v1/attention.py +142 -0
- package/backend/app/api/v1/auth.py +106 -0
- package/backend/app/api/v1/classification.py +192 -0
- package/backend/app/api/v1/health.py +29 -0
- package/backend/app/api/v1/issues.py +143 -0
- package/backend/app/api/v1/pulls.py +388 -0
- package/backend/app/api/v1/repositories.py +273 -0
- package/backend/app/api/v1/router.py +32 -0
- package/backend/app/api/v1/sig.py +565 -0
- package/backend/app/api/v1/users.py +87 -0
- package/backend/app/api/v1/webhooks.py +135 -0
- package/backend/app/core/__init__.py +0 -0
- package/backend/app/core/config.py +72 -0
- package/backend/app/core/crypto.py +74 -0
- package/backend/app/core/db.py +79 -0
- package/backend/app/core/exceptions.py +60 -0
- package/backend/app/core/logging.py +69 -0
- package/backend/app/core/permissions.py +87 -0
- package/backend/app/core/queue.py +57 -0
- package/backend/app/core/security.py +69 -0
- package/backend/app/domain/__init__.py +0 -0
- package/backend/app/domain/attention.py +618 -0
- package/backend/app/domain/classification.py +689 -0
- package/backend/app/domain/meeting.py +408 -0
- package/backend/app/domain/release.py +163 -0
- package/backend/app/domain/review.py +416 -0
- package/backend/app/domain/sig.py +178 -0
- package/backend/app/domain/sig_info.py +201 -0
- package/backend/app/integrations/__init__.py +0 -0
- package/backend/app/integrations/atomgit/__init__.py +0 -0
- package/backend/app/integrations/atomgit/client.py +505 -0
- package/backend/app/integrations/atomgit/models.py +311 -0
- package/backend/app/integrations/llm/__init__.py +0 -0
- package/backend/app/integrations/llm/prompts.py +222 -0
- package/backend/app/integrations/llm/provider.py +326 -0
- package/backend/app/main.py +242 -0
- package/backend/app/middleware/__init__.py +0 -0
- package/backend/app/middleware/audit.py +129 -0
- package/backend/app/middleware/request_context.py +42 -0
- package/backend/app/models/__init__.py +112 -0
- package/backend/app/models/ai.py +214 -0
- package/backend/app/models/attention.py +131 -0
- package/backend/app/models/attention_settings.py +61 -0
- package/backend/app/models/audit.py +53 -0
- package/backend/app/models/base.py +35 -0
- package/backend/app/models/classification.py +144 -0
- package/backend/app/models/credential.py +56 -0
- package/backend/app/models/issue.py +132 -0
- package/backend/app/models/meeting.py +189 -0
- package/backend/app/models/pull_request.py +288 -0
- package/backend/app/models/repository.py +196 -0
- package/backend/app/models/sig.py +194 -0
- package/backend/app/models/user.py +44 -0
- package/backend/app/schemas/__init__.py +0 -0
- package/backend/app/schemas/ai.py +120 -0
- package/backend/app/schemas/attention.py +43 -0
- package/backend/app/schemas/auth.py +26 -0
- package/backend/app/schemas/classification.py +58 -0
- package/backend/app/schemas/common.py +43 -0
- package/backend/app/schemas/pull_request.py +214 -0
- package/backend/app/schemas/repository.py +94 -0
- package/backend/app/schemas/sig.py +246 -0
- package/backend/app/schemas/user.py +79 -0
- package/backend/app/services/__init__.py +0 -0
- package/backend/app/services/ai_service.py +569 -0
- package/backend/app/services/analytics_service.py +390 -0
- package/backend/app/services/attention_queue.py +451 -0
- package/backend/app/services/attention_service.py +432 -0
- package/backend/app/services/auth_service.py +74 -0
- package/backend/app/services/classification_service.py +658 -0
- package/backend/app/services/credential_service.py +105 -0
- package/backend/app/services/pull_query.py +273 -0
- package/backend/app/services/release_service.py +446 -0
- package/backend/app/services/repository_service.py +132 -0
- package/backend/app/services/sig_service.py +385 -0
- package/backend/app/services/sync_service.py +752 -0
- package/backend/app/services/user_service.py +68 -0
- package/backend/app/worker.py +389 -0
- package/backend/entrypoint.sh +10 -0
- package/backend/pyproject.toml +68 -0
- package/backend/tests/test_analysis_api.py +154 -0
- package/backend/tests/test_atomgit_client.py +360 -0
- package/backend/tests/test_atomgit_models.py +257 -0
- package/backend/tests/test_attention.py +291 -0
- package/backend/tests/test_audit_middleware.py +135 -0
- package/backend/tests/test_classification.py +498 -0
- package/backend/tests/test_config.py +41 -0
- package/backend/tests/test_crypto.py +68 -0
- package/backend/tests/test_exceptions.py +62 -0
- package/backend/tests/test_health.py +63 -0
- package/backend/tests/test_llm_provider.py +320 -0
- package/backend/tests/test_meeting_domain.py +169 -0
- package/backend/tests/test_permissions.py +69 -0
- package/backend/tests/test_pull_query_wiring.py +66 -0
- package/backend/tests/test_pull_schemas.py +82 -0
- package/backend/tests/test_release_domain.py +82 -0
- package/backend/tests/test_review_parser.py +301 -0
- package/backend/tests/test_schemas_user.py +97 -0
- package/backend/tests/test_security.py +92 -0
- package/cli/index.js +338 -0
- package/compose.yaml +105 -0
- package/frontend/.dockerignore +4 -0
- package/frontend/Dockerfile +27 -0
- package/frontend/index.html +14 -0
- package/frontend/nginx.conf +47 -0
- package/frontend/package-lock.json +5020 -0
- package/frontend/package.json +35 -0
- package/frontend/src/api/ai.ts +124 -0
- package/frontend/src/api/analytics.ts +66 -0
- package/frontend/src/api/attention.ts +181 -0
- package/frontend/src/api/auth.ts +74 -0
- package/frontend/src/api/classification.ts +170 -0
- package/frontend/src/api/issues.ts +90 -0
- package/frontend/src/api/pulls.ts +233 -0
- package/frontend/src/api/repositories.ts +95 -0
- package/frontend/src/api/sig.ts +232 -0
- package/frontend/src/app/antd-theme.ts +94 -0
- package/frontend/src/app/providers.tsx +59 -0
- package/frontend/src/app/router.tsx +411 -0
- package/frontend/src/app/search.ts +30 -0
- package/frontend/src/components/ClassificationBadge.tsx +58 -0
- package/frontend/src/components/GateBadge.tsx +13 -0
- package/frontend/src/components/SeverityBadge.tsx +20 -0
- package/frontend/src/components/layout/AppShell.tsx +16 -0
- package/frontend/src/components/layout/AuthLayout.tsx +32 -0
- package/frontend/src/components/layout/Sidebar.tsx +223 -0
- package/frontend/src/components/layout/TopBar.tsx +47 -0
- package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
- package/frontend/src/components/pulls/FacetRail.tsx +199 -0
- package/frontend/src/components/pulls/LabelChips.tsx +87 -0
- package/frontend/src/components/ui/alert.tsx +30 -0
- package/frontend/src/components/ui/badge.tsx +53 -0
- package/frontend/src/components/ui/button.tsx +51 -0
- package/frontend/src/components/ui/card.tsx +64 -0
- package/frontend/src/components/ui/chart-theme.ts +65 -0
- package/frontend/src/components/ui/data-table.tsx +39 -0
- package/frontend/src/components/ui/echart.tsx +70 -0
- package/frontend/src/components/ui/empty-state.tsx +22 -0
- package/frontend/src/components/ui/input.tsx +39 -0
- package/frontend/src/components/ui/lazy-chart.tsx +21 -0
- package/frontend/src/components/ui/skeleton.tsx +19 -0
- package/frontend/src/hooks/use-current-repository.ts +44 -0
- package/frontend/src/hooks/use-current-user.ts +38 -0
- package/frontend/src/lib/api-client.ts +93 -0
- package/frontend/src/lib/css-color.ts +60 -0
- package/frontend/src/lib/utils.ts +45 -0
- package/frontend/src/main.tsx +22 -0
- package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
- package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
- package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
- package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
- package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
- package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
- package/frontend/src/pages/login/LoginPage.tsx +95 -0
- package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
- package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
- package/frontend/src/pages/members/MembersPage.tsx +534 -0
- package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
- package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
- package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
- package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
- package/frontend/src/pages/setup/SetupPage.tsx +144 -0
- package/frontend/src/styles/globals.css +147 -0
- package/frontend/tsconfig.json +22 -0
- package/frontend/vite.config.ts +57 -0
- package/package.json +48 -0
- package/scripts/e2e-auth-flow.py +131 -0
- package/scripts/e2e-pr-detail.py +128 -0
- package/scripts/e2e-verify.py +773 -0
- package/scripts/verify-ai-pipeline.py +616 -0
- package/scripts/verify-analysis-pipeline.py +303 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""AtomGit 数据模型对**真实响应**的解析验证。
|
|
2
|
+
|
|
3
|
+
夹具来自 atomgit.com 的实际 API 响应(见 tests/fixtures/atomgit/)。
|
|
4
|
+
不使用手写假数据 —— 手写数据会掩盖字段名与类型上的错误,
|
|
5
|
+
正如官方文档把认证头写错一样。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from app.integrations.atomgit.models import (
|
|
14
|
+
AtomGitComment,
|
|
15
|
+
AtomGitCommit,
|
|
16
|
+
AtomGitFile,
|
|
17
|
+
AtomGitIssue,
|
|
18
|
+
AtomGitLabel,
|
|
19
|
+
AtomGitPullRequest,
|
|
20
|
+
AtomGitRepository,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
FIXTURES = Path(__file__).parent / "fixtures" / "atomgit"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load(name: str):
|
|
27
|
+
return json.loads((FIXTURES / name).read_text(encoding="utf-8"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- PR ---------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_parses_real_pull_list():
|
|
34
|
+
pulls = [AtomGitPullRequest.model_validate(x) for x in load("pulls_page1.json")]
|
|
35
|
+
assert len(pulls) == 5
|
|
36
|
+
first = pulls[0]
|
|
37
|
+
assert isinstance(first.number, int)
|
|
38
|
+
assert first.title
|
|
39
|
+
assert first.state == "open"
|
|
40
|
+
assert first.target_branch
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_pull_exposes_label_names():
|
|
44
|
+
pulls = [AtomGitPullRequest.model_validate(x) for x in load("pulls_page1.json")]
|
|
45
|
+
assert isinstance(pulls[0].label_names, list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_pull_detail_parses():
|
|
49
|
+
pull = AtomGitPullRequest.model_validate(load("pull_detail.json"))
|
|
50
|
+
assert pull.number == 27650
|
|
51
|
+
assert pull.mergeable is not None
|
|
52
|
+
assert isinstance(pull.label_names, list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_blank_datetime_becomes_none():
|
|
56
|
+
"""AtomGit 用空字符串表示"尚未合并",不能当成非法日期。"""
|
|
57
|
+
pull = AtomGitPullRequest.model_validate(load("pull_detail.json"))
|
|
58
|
+
if pull.merged_at is not None:
|
|
59
|
+
# 该 PR 未合并,字段应为空字符串 → None
|
|
60
|
+
pytest.fail("未合并的 PR 不应有 merged_at")
|
|
61
|
+
assert pull.merged_at is None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_merged_pull_has_merged_at():
|
|
65
|
+
pulls = [AtomGitPullRequest.model_validate(x) for x in load("pulls_merged.json")]
|
|
66
|
+
merged = [p for p in pulls if p.merged_at is not None]
|
|
67
|
+
assert merged, "已合并的 PR 应解析出 merged_at"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_merged_pull_review_labels_present():
|
|
71
|
+
"""评审状态由标签承载:这是评审子系统的数据基础。"""
|
|
72
|
+
pulls = [AtomGitPullRequest.model_validate(x) for x in load("pulls_merged.json")]
|
|
73
|
+
names = {n for p in pulls for n in p.label_names}
|
|
74
|
+
assert "approved" in names or "lgtm" in names
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_pull_permalink_prefers_web_url():
|
|
78
|
+
"""html_url 实测指向 /merge_requests/,规范链接应用 web_url。"""
|
|
79
|
+
pull = AtomGitPullRequest.model_validate(load("pull_detail.json"))
|
|
80
|
+
assert pull.permalink
|
|
81
|
+
assert "/pull/" in pull.permalink or "/merge_requests/" in pull.permalink
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- 文件 -------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_parses_real_pull_files():
|
|
88
|
+
files = [AtomGitFile.model_validate(x) for x in load("pull_files.json")]
|
|
89
|
+
assert files
|
|
90
|
+
assert all(f.filename for f in files)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_text_file_is_not_binary():
|
|
94
|
+
files = [AtomGitFile.model_validate(x) for x in load("pull_files.json")]
|
|
95
|
+
assert not any(f.is_binary for f in files)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize("marker", ["Binary files differ", "GIT binary patch"])
|
|
99
|
+
def test_binary_file_detected_from_patch_marker(marker):
|
|
100
|
+
"""内核仓库禁止提交二进制文件,判定依据是 Git 的二进制标记。"""
|
|
101
|
+
file = AtomGitFile(filename="drivers/x/fw.bin", patch=f"{marker}\n...")
|
|
102
|
+
assert file.is_binary
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_file_without_patch_is_not_binary():
|
|
106
|
+
assert not AtomGitFile(filename="a.c", patch=None).is_binary
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# --- 提交 -------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_parses_real_pull_commits():
|
|
113
|
+
commits = [AtomGitCommit.model_validate(x) for x in load("pull_commits.json")]
|
|
114
|
+
assert commits
|
|
115
|
+
assert len(commits[0].sha) >= 8
|
|
116
|
+
assert commits[0].subject
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- 评论 -------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_parses_real_pull_comments():
|
|
123
|
+
comments = [AtomGitComment.model_validate(x) for x in load("pull_comments.json")]
|
|
124
|
+
assert comments
|
|
125
|
+
assert all(c.id is not None for c in comments)
|
|
126
|
+
# 实测字段:comment_type 与 discussion_id
|
|
127
|
+
assert any(c.comment_type for c in comments)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- Issue ------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_issue_number_string_is_coerced_to_int():
|
|
134
|
+
"""Issue 的 number 是字符串,需与 PR 共用整数编号空间。"""
|
|
135
|
+
issues = [AtomGitIssue.model_validate(x) for x in load("issues_page1.json")]
|
|
136
|
+
assert all(isinstance(i.number, int) for i in issues)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_issue_structured_fields_parsed():
|
|
140
|
+
"""AtomGit 原生提供类型/状态/优先级,比用标签猜测更可靠。"""
|
|
141
|
+
issue = AtomGitIssue.model_validate(load("issue_detail.json"))
|
|
142
|
+
assert issue.issue_type
|
|
143
|
+
assert issue.issue_state
|
|
144
|
+
assert issue.title
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_issue_labels_parsed():
|
|
148
|
+
issues = [AtomGitIssue.model_validate(x) for x in load("issues_page1.json")]
|
|
149
|
+
assert any(i.label_names for i in issues)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --- 标签与仓库 -------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_parses_real_labels():
|
|
156
|
+
labels = [AtomGitLabel.model_validate(x) for x in load("labels.json")]
|
|
157
|
+
assert labels
|
|
158
|
+
assert all(label.name for label in labels)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_parses_real_repository():
|
|
162
|
+
repo = AtomGitRepository.model_validate(load("repo.json"))
|
|
163
|
+
assert repo.full_name == "openeuler/kernel"
|
|
164
|
+
assert repo.default_branch
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# --- 健壮性 -----------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_unknown_fields_are_ignored():
|
|
171
|
+
"""上游新增字段不应导致解析失败。"""
|
|
172
|
+
payload = load("pull_detail.json")
|
|
173
|
+
pull = AtomGitPullRequest.model_validate({**payload, "brand_new_field": {"x": 1}})
|
|
174
|
+
assert pull.number == 27650
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_missing_optional_fields_are_tolerated():
|
|
178
|
+
minimal = {"number": 1, "title": "t", "state": "open"}
|
|
179
|
+
pull = AtomGitPullRequest.model_validate(minimal)
|
|
180
|
+
assert pull.labels == []
|
|
181
|
+
assert pull.author_login is None
|
|
182
|
+
assert pull.merged_at is None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- 实测结构与直觉不符之处,逐条锁定 -------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_file_patch_is_an_object_not_a_string():
|
|
189
|
+
"""实测 patch 是含 diff/old_path/too_large 的对象,不是 diff 字符串。"""
|
|
190
|
+
files = [AtomGitFile.model_validate(x) for x in load("pull_files.json")]
|
|
191
|
+
file = files[0]
|
|
192
|
+
assert file.patch is not None
|
|
193
|
+
assert file.patch.diff is not None
|
|
194
|
+
assert "@@" in file.patch.diff
|
|
195
|
+
assert file.patch.old_path == file.filename
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_file_diff_text_shortcut():
|
|
199
|
+
files = [AtomGitFile.model_validate(x) for x in load("pull_files.json")]
|
|
200
|
+
assert files[0].diff_text is not None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_file_patch_as_plain_string_still_parses():
|
|
204
|
+
"""其他端点或未来版本可能直接返回字符串,需兼容。"""
|
|
205
|
+
file = AtomGitFile.model_validate({"filename": "a.c", "patch": "@@ -1 +1 @@\n-x\n+y"})
|
|
206
|
+
assert file.diff_text and "@@" in file.diff_text
|
|
207
|
+
assert not file.is_binary
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_commit_message_is_nested_under_commit():
|
|
211
|
+
"""commit 的 message 嵌套在 commit.{author,committer,message}。"""
|
|
212
|
+
commits = [AtomGitCommit.model_validate(x) for x in load("pull_commits.json")]
|
|
213
|
+
commit = commits[0]
|
|
214
|
+
assert commit.message
|
|
215
|
+
assert commit.subject
|
|
216
|
+
assert commit.subject.startswith("KVM:")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def test_commit_subject_is_first_line_only():
|
|
220
|
+
commit = AtomGitCommit.model_validate(
|
|
221
|
+
{"sha": "a" * 40, "commit": {"message": "sub: do thing\n\nbody line\nmore"}}
|
|
222
|
+
)
|
|
223
|
+
assert commit.subject == "sub: do thing"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_mergeable_state_is_an_object():
|
|
227
|
+
"""实测 mergeable_state 是合并检查明细对象,不是状态字符串。"""
|
|
228
|
+
pull = AtomGitPullRequest.model_validate(load("pull_detail.json"))
|
|
229
|
+
assert pull.mergeable_state is not None
|
|
230
|
+
assert pull.mergeable_state.merge_request_id is not None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_merge_conflict_detected_from_detail():
|
|
234
|
+
payload = {
|
|
235
|
+
"number": 1,
|
|
236
|
+
"title": "t",
|
|
237
|
+
"state": "open",
|
|
238
|
+
"mergeable": False,
|
|
239
|
+
"mergeable_state": {"conflict_passed": False},
|
|
240
|
+
}
|
|
241
|
+
assert AtomGitPullRequest.model_validate(payload).has_merge_conflict
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def test_no_conflict_when_detail_says_passed():
|
|
245
|
+
payload = {
|
|
246
|
+
"number": 1,
|
|
247
|
+
"title": "t",
|
|
248
|
+
"state": "open",
|
|
249
|
+
"mergeable": True,
|
|
250
|
+
"mergeable_state": {"conflict_passed": True},
|
|
251
|
+
}
|
|
252
|
+
assert not AtomGitPullRequest.model_validate(payload).has_merge_conflict
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_merge_conflict_falls_back_to_mergeable_flag():
|
|
256
|
+
payload = {"number": 1, "title": "t", "state": "open", "mergeable": False}
|
|
257
|
+
assert AtomGitPullRequest.model_validate(payload).has_merge_conflict
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""关注项规则。
|
|
2
|
+
|
|
3
|
+
规则的价值在于"能不能让维护者据此行动",因此测试重点覆盖:
|
|
4
|
+
- 不该报的不报(已合并、未到期、已完成评审的都不算待办)
|
|
5
|
+
- 该报的报且给得出理由
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import UTC, datetime, timedelta
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from app.domain.attention import (
|
|
13
|
+
RULE_DESCRIPTIONS,
|
|
14
|
+
PullSnapshot,
|
|
15
|
+
Rule,
|
|
16
|
+
Severity,
|
|
17
|
+
Thresholds,
|
|
18
|
+
evaluate,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
NOW = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ago(days: float = 0, hours: float = 0) -> datetime:
|
|
25
|
+
return NOW - timedelta(days=days, hours=hours)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def make_pull(**overrides) -> PullSnapshot:
|
|
29
|
+
base: dict = {
|
|
30
|
+
"number": 1000,
|
|
31
|
+
"title": "net: fix leak",
|
|
32
|
+
"state": "open",
|
|
33
|
+
"gate_stage": "ready_to_merge",
|
|
34
|
+
"created_at": ago(1),
|
|
35
|
+
"updated_at": ago(0.5),
|
|
36
|
+
}
|
|
37
|
+
base.update(overrides)
|
|
38
|
+
return PullSnapshot(**base)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def rules_for(pull: PullSnapshot, **kw) -> set[Rule]:
|
|
42
|
+
return {hit.rule for hit in evaluate(pull, now=NOW, **kw)}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- 不该报的情况 -----------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.parametrize("state", ["merged", "closed"])
|
|
49
|
+
def test_finished_pulls_produce_no_attention(state):
|
|
50
|
+
"""对已合并/已关闭的 PR 提"待处理"是纯噪音。"""
|
|
51
|
+
pull = make_pull(state=state, gate_stage="review_pending", updated_at=ago(90))
|
|
52
|
+
assert evaluate(pull, now=NOW) == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_healthy_pull_produces_nothing():
|
|
56
|
+
"""门禁就绪、刚更新过的 PR 不应产生任何告警。"""
|
|
57
|
+
assert evaluate(make_pull(), now=NOW) == []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_recent_pull_within_sla_is_not_flagged():
|
|
61
|
+
pull = make_pull(gate_stage="review_pending", created_at=ago(2))
|
|
62
|
+
assert Rule.REVIEW_SLA_BREACH not in rules_for(pull)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_pull_with_lgtm_is_not_review_sla_breach():
|
|
66
|
+
"""已拿到 LGTM 的不算评审欠账,否则会把完成的评审算成超期。"""
|
|
67
|
+
pull = make_pull(gate_stage="ready_to_merge", created_at=ago(60))
|
|
68
|
+
assert Rule.REVIEW_SLA_BREACH not in rules_for(pull)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_draft_is_not_stale_flagged_until_threshold():
|
|
72
|
+
pull = make_pull(is_draft=True, updated_at=ago(5))
|
|
73
|
+
assert Rule.STALE not in rules_for(pull)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- CI ---------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_ci_failure_is_reported():
|
|
80
|
+
pull = make_pull(gate_stage="ci_failed", updated_at=ago(0.2))
|
|
81
|
+
hits = evaluate(pull, now=NOW)
|
|
82
|
+
assert Rule.CI_FAILED in {h.rule for h in hits}
|
|
83
|
+
hit = next(h for h in hits if h.rule is Rule.CI_FAILED)
|
|
84
|
+
assert hit.severity is Severity.WARNING
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_long_running_ci_failure_escalates():
|
|
88
|
+
pull = make_pull(gate_stage="ci_failed", updated_at=ago(10))
|
|
89
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.CI_FAILED)
|
|
90
|
+
assert hit.severity is Severity.CRITICAL
|
|
91
|
+
assert "10 天" in hit.title
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- 合并冲突 ---------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_merge_conflict_reported():
|
|
98
|
+
pull = make_pull(merge_conflict=True, updated_at=ago(0.5))
|
|
99
|
+
assert Rule.MERGE_CONFLICT in rules_for(pull)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_old_conflict_escalates():
|
|
103
|
+
pull = make_pull(merge_conflict=True, updated_at=ago(5))
|
|
104
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.MERGE_CONFLICT)
|
|
105
|
+
assert hit.severity is Severity.CRITICAL
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_conflict_on_closed_pull_not_reported():
|
|
109
|
+
pull = make_pull(merge_conflict=True, state="closed")
|
|
110
|
+
assert Rule.MERGE_CONFLICT not in rules_for(pull)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --- CVE --------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_cve_aging_reported_after_threshold():
|
|
117
|
+
pull = make_pull(cve_ids=["CVE-2026-1234"], created_at=ago(2))
|
|
118
|
+
hits = evaluate(pull, now=NOW)
|
|
119
|
+
hit = next(h for h in hits if h.rule is Rule.CVE_AGING)
|
|
120
|
+
assert hit.severity is Severity.CRITICAL
|
|
121
|
+
assert "CVE-2026-1234" in hit.detail
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_fresh_cve_not_flagged():
|
|
125
|
+
pull = make_pull(cve_ids=["CVE-2026-1234"], created_at=ago(0.2))
|
|
126
|
+
assert Rule.CVE_AGING not in rules_for(pull)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_non_cve_pull_never_gets_cve_aging():
|
|
130
|
+
pull = make_pull(created_at=ago(100))
|
|
131
|
+
assert Rule.CVE_AGING not in rules_for(pull)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_many_cves_are_summarised_not_dumped():
|
|
135
|
+
"""一次涉及几十个 CVE 的补丁(实测存在)不能把详情铺满。
|
|
136
|
+
|
|
137
|
+
待办的价值在于「要做什么」,一串编号占满整屏反而淹没了这句话。
|
|
138
|
+
完整列表仍然完整地留在 evidence 里 —— 摘要不等于丢信息。
|
|
139
|
+
"""
|
|
140
|
+
cves = [f"CVE-2026-{1000 + i}" for i in range(34)]
|
|
141
|
+
pull = make_pull(cve_ids=cves, created_at=ago(2))
|
|
142
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.CVE_AGING)
|
|
143
|
+
|
|
144
|
+
assert "等 34 项" in hit.detail
|
|
145
|
+
assert len(hit.detail) < 200, f"详情过长:{len(hit.detail)} 字符"
|
|
146
|
+
assert hit.evidence["cve_ids"] == cves, "evidence 中保留完整列表"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_few_cves_are_listed_in_full():
|
|
150
|
+
"""少数几个 CVE 直接列出,不必加"等 N 项"的噪音。"""
|
|
151
|
+
pull = make_pull(cve_ids=["CVE-2026-1", "CVE-2026-2"], created_at=ago(2))
|
|
152
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.CVE_AGING)
|
|
153
|
+
assert "CVE-2026-1" in hit.detail and "CVE-2026-2" in hit.detail
|
|
154
|
+
assert "等" not in hit.detail
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- CLA / Issue / NACK -----------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_cla_denied_is_critical():
|
|
161
|
+
pull = make_pull(gate_stage="cla_denied")
|
|
162
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.CLA_DENIED)
|
|
163
|
+
assert hit.severity is Severity.CRITICAL
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_cla_pending_only_flagged_after_a_day():
|
|
167
|
+
fresh = make_pull(gate_stage="cla_pending", created_at=ago(0.2))
|
|
168
|
+
assert Rule.CLA_PENDING_LONG not in rules_for(fresh)
|
|
169
|
+
|
|
170
|
+
old = make_pull(gate_stage="cla_pending", created_at=ago(3))
|
|
171
|
+
assert Rule.CLA_PENDING_LONG in rules_for(old)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_needs_issue_reported():
|
|
175
|
+
pull = make_pull(gate_stage="needs_issue")
|
|
176
|
+
assert Rule.NEEDS_ISSUE in rules_for(pull)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_rejected_reported():
|
|
180
|
+
pull = make_pull(gate_stage="rejected", label_names=["NACK"])
|
|
181
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.REJECTED)
|
|
182
|
+
assert hit.severity is Severity.CRITICAL
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- 二进制文件 -------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_binary_file_is_blocker():
|
|
189
|
+
"""内核仓库禁止二进制文件,属阻塞性问题而非提示。"""
|
|
190
|
+
pull = make_pull(binary_files=["drivers/fw.bin", "drivers/other.bin"])
|
|
191
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.BINARY_FILE)
|
|
192
|
+
assert hit.severity is Severity.BLOCKER
|
|
193
|
+
assert "2 个" in hit.title
|
|
194
|
+
assert hit.evidence["files"] == ["drivers/fw.bin", "drivers/other.bin"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_no_binary_files_no_hit():
|
|
198
|
+
assert Rule.BINARY_FILE not in rules_for(make_pull())
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- Signed-off-by ----------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_missing_signed_off_reported():
|
|
205
|
+
pull = make_pull(total_commits=3, commits_without_signed_off=1)
|
|
206
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.MISSING_SIGNED_OFF)
|
|
207
|
+
assert hit.evidence == {"missing": 1, "total": 3}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_all_signed_off_no_hit():
|
|
211
|
+
pull = make_pull(total_commits=3, commits_without_signed_off=0)
|
|
212
|
+
assert Rule.MISSING_SIGNED_OFF not in rules_for(pull)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_no_commits_means_no_signed_off_judgement():
|
|
216
|
+
"""提交尚未同步时不能断定"缺少签名" —— 那是数据缺失,不是问题。"""
|
|
217
|
+
pull = make_pull(total_commits=0, commits_without_signed_off=0)
|
|
218
|
+
assert Rule.MISSING_SIGNED_OFF not in rules_for(pull)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# --- Stale ------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_stale_after_threshold():
|
|
225
|
+
pull = make_pull(updated_at=ago(45))
|
|
226
|
+
hit = next(h for h in evaluate(pull, now=NOW) if h.rule is Rule.STALE)
|
|
227
|
+
assert "45 天" in hit.title
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def test_custom_thresholds_respected():
|
|
231
|
+
pull = make_pull(updated_at=ago(10))
|
|
232
|
+
assert Rule.STALE not in rules_for(pull)
|
|
233
|
+
assert Rule.STALE in rules_for(pull, thresholds=Thresholds(stale_days=7))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# --- 输出契约 ---------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_hits_sorted_by_severity():
|
|
240
|
+
"""阻塞性问题必须排在提示类之前 —— 界面按此顺序渲染。"""
|
|
241
|
+
pull = make_pull(
|
|
242
|
+
gate_stage="needs_issue",
|
|
243
|
+
binary_files=["a.bin"],
|
|
244
|
+
updated_at=ago(60),
|
|
245
|
+
)
|
|
246
|
+
hits = evaluate(pull, now=NOW)
|
|
247
|
+
severities = [h.severity for h in hits]
|
|
248
|
+
order = [
|
|
249
|
+
Severity.BLOCKER,
|
|
250
|
+
Severity.CRITICAL,
|
|
251
|
+
Severity.WARNING,
|
|
252
|
+
Severity.INFO,
|
|
253
|
+
]
|
|
254
|
+
assert severities == sorted(severities, key=lambda s: order.index(s))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_every_hit_carries_actionable_evidence():
|
|
258
|
+
"""只给"有问题"的结论无法据以行动,每条命中都必须带证据。"""
|
|
259
|
+
pull = make_pull(
|
|
260
|
+
gate_stage="ci_failed",
|
|
261
|
+
binary_files=["a.bin"],
|
|
262
|
+
merge_conflict=True,
|
|
263
|
+
created_at=ago(30),
|
|
264
|
+
updated_at=ago(10),
|
|
265
|
+
total_commits=2,
|
|
266
|
+
commits_without_signed_off=2,
|
|
267
|
+
)
|
|
268
|
+
hits = evaluate(pull, now=NOW)
|
|
269
|
+
assert len(hits) >= 4
|
|
270
|
+
for hit in hits:
|
|
271
|
+
assert hit.title
|
|
272
|
+
assert hit.detail
|
|
273
|
+
assert hit.evidence, f"{hit.rule} 缺少证据"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def test_every_rule_has_a_description():
|
|
277
|
+
"""界面要解释"这条规则是什么意思",缺描述会让使用者无从判断。"""
|
|
278
|
+
for rule in Rule:
|
|
279
|
+
assert rule in RULE_DESCRIPTIONS, f"{rule.value} 缺少说明文案"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_naive_datetime_is_tolerated():
|
|
283
|
+
"""数据库可能返回不带时区的时间,规则不应因此崩溃。"""
|
|
284
|
+
naive = datetime(2026, 8, 1)
|
|
285
|
+
pull = make_pull(created_at=naive, updated_at=naive)
|
|
286
|
+
assert evaluate(pull, now=NOW) is not None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_missing_timestamps_do_not_crash():
|
|
290
|
+
pull = make_pull(created_at=None, updated_at=None)
|
|
291
|
+
assert evaluate(pull, now=NOW) == []
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""审计中间件的行为约束。
|
|
2
|
+
|
|
3
|
+
关键不变量:中间件在请求处理完成、数据库会话关闭**之后**才写日志,
|
|
4
|
+
因此绝不能触碰 ORM 实例(会触发 DetachedInstanceError)。
|
|
5
|
+
归属信息必须以标量形式从 request.state 读取。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
|
|
13
|
+
from app.middleware.audit import AuditMiddleware, _redact, _username_from
|
|
14
|
+
from app.models.audit import AuditResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ExplodingUser:
|
|
18
|
+
"""访问任何属性都抛错,用于证明中间件不会触碰 ORM 实例。"""
|
|
19
|
+
|
|
20
|
+
def __getattr__(self, name: str):
|
|
21
|
+
raise AssertionError(f"审计中间件不应读取 user.{name}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FakeSession:
|
|
25
|
+
def __init__(self, sink: list) -> None:
|
|
26
|
+
self._sink = sink
|
|
27
|
+
|
|
28
|
+
def add(self, obj: object) -> None:
|
|
29
|
+
self._sink.append(obj)
|
|
30
|
+
|
|
31
|
+
async def commit(self) -> None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
async def __aenter__(self) -> "_FakeSession":
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
async def __aexit__(self, *exc_info: object) -> bool:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def captured(monkeypatch):
|
|
43
|
+
sink: list = []
|
|
44
|
+
|
|
45
|
+
class _Factory:
|
|
46
|
+
def __call__(self) -> _FakeSession:
|
|
47
|
+
return _FakeSession(sink)
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr("app.middleware.audit.get_session_factory", lambda: _Factory())
|
|
50
|
+
return sink
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _request() -> Request:
|
|
54
|
+
return Request(
|
|
55
|
+
{
|
|
56
|
+
"type": "http",
|
|
57
|
+
"method": "POST",
|
|
58
|
+
"path": "/api/v1/users",
|
|
59
|
+
"headers": [],
|
|
60
|
+
"client": ("10.0.0.1", 12345),
|
|
61
|
+
"state": {},
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def test_write_log_reads_primitives_not_orm(captured):
|
|
67
|
+
middleware = AuditMiddleware(app=lambda *a, **kw: None)
|
|
68
|
+
request = _request()
|
|
69
|
+
actor_id = uuid.uuid4()
|
|
70
|
+
request.state.actor_user_id = actor_id
|
|
71
|
+
request.state.actor_username = "alice"
|
|
72
|
+
request.state.request_id = "rid-1"
|
|
73
|
+
# 若中间件去读 user 的属性,这里会抛 AssertionError
|
|
74
|
+
request.state.user = ExplodingUser()
|
|
75
|
+
|
|
76
|
+
await middleware._write_log(request, {"action": "create"}, AuditResult.SUCCESS)
|
|
77
|
+
|
|
78
|
+
assert len(captured) == 1
|
|
79
|
+
entry = captured[0]
|
|
80
|
+
assert entry.actor_username == "alice"
|
|
81
|
+
assert entry.actor_user_id == actor_id
|
|
82
|
+
assert entry.actor_ip == "10.0.0.1"
|
|
83
|
+
assert entry.request_id == "rid-1"
|
|
84
|
+
assert entry.result is AuditResult.SUCCESS
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_write_log_falls_back_to_claimed_actor(captured):
|
|
88
|
+
"""未认证请求(如登录尝试)用载荷中的账号名作为归属。"""
|
|
89
|
+
middleware = AuditMiddleware(app=lambda *a, **kw: None)
|
|
90
|
+
request = _request()
|
|
91
|
+
request.state.request_id = "rid-2"
|
|
92
|
+
|
|
93
|
+
await middleware._write_log(
|
|
94
|
+
request, {"username": "attacker"}, AuditResult.FAILURE, claimed_actor="attacker"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
assert captured[0].actor_username == "attacker"
|
|
98
|
+
assert captured[0].actor_user_id is None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_write_log_swallows_session_failure(monkeypatch):
|
|
102
|
+
"""审计写入失败绝不能影响主请求。"""
|
|
103
|
+
middleware = AuditMiddleware(app=lambda *a, **kw: None)
|
|
104
|
+
|
|
105
|
+
class _Boom:
|
|
106
|
+
def __call__(self):
|
|
107
|
+
raise RuntimeError("数据库不可用")
|
|
108
|
+
|
|
109
|
+
monkeypatch.setattr("app.middleware.audit.get_session_factory", lambda: _Boom())
|
|
110
|
+
request = _request()
|
|
111
|
+
request.state.actor_username = "alice"
|
|
112
|
+
|
|
113
|
+
await middleware._write_log(request, None, AuditResult.SUCCESS)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_redact_masks_credential_fields():
|
|
117
|
+
payload = {
|
|
118
|
+
"username": "alice",
|
|
119
|
+
"password": "hunter2",
|
|
120
|
+
"nested": {"api_key": "k-123", "safe": "visible"},
|
|
121
|
+
"items": [{"token": "t-1"}],
|
|
122
|
+
}
|
|
123
|
+
result = _redact(payload)
|
|
124
|
+
assert result["username"] == "alice"
|
|
125
|
+
assert result["password"] == "***"
|
|
126
|
+
assert result["nested"]["api_key"] == "***"
|
|
127
|
+
assert result["nested"]["safe"] == "visible"
|
|
128
|
+
assert result["items"][0]["token"] == "***"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_username_from_extracts_only_username():
|
|
132
|
+
assert _username_from({"username": "bob", "password": "x"}) == "bob"
|
|
133
|
+
assert _username_from({"password": "x"}) is None
|
|
134
|
+
assert _username_from("not-a-dict") is None
|
|
135
|
+
assert _username_from({"username": ""}) is None
|