pygitweb 0.1.0__py3-none-any.whl

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.
Files changed (95) hide show
  1. pygitweb/__init__.py +22 -0
  2. pygitweb/__meta__.py +9 -0
  3. pygitweb/actions.py +1303 -0
  4. pygitweb/addproject_permissions_test.py +114 -0
  5. pygitweb/api/__init__.py +0 -0
  6. pygitweb/api/action.py +20 -0
  7. pygitweb/api/plugins/__init__.py +0 -0
  8. pygitweb/api/plugins/project_zip.py +71 -0
  9. pygitweb/api/project.py +67 -0
  10. pygitweb/api/subpage.py +22 -0
  11. pygitweb/auth.py +387 -0
  12. pygitweb/auth.schema.json +106 -0
  13. pygitweb/auth_config.py +243 -0
  14. pygitweb/auth_oauth.py +343 -0
  15. pygitweb/auth_test.py +390 -0
  16. pygitweb/boards_permissions_test.py +113 -0
  17. pygitweb/change_queue.py +110 -0
  18. pygitweb/change_queue_routes_test.py +146 -0
  19. pygitweb/change_queue_test.py +91 -0
  20. pygitweb/comments_permissions_test.py +140 -0
  21. pygitweb/config.py +185 -0
  22. pygitweb/conftest.py +43 -0
  23. pygitweb/dependencies.py +87 -0
  24. pygitweb/formatting.py +182 -0
  25. pygitweb/formatting_test.py +68 -0
  26. pygitweb/git_helpers.py +454 -0
  27. pygitweb/git_helpers_test.py +223 -0
  28. pygitweb/gravatar.py +16 -0
  29. pygitweb/gravatar_test.py +10 -0
  30. pygitweb/hooks_install.py +278 -0
  31. pygitweb/hooks_install_test.py +363 -0
  32. pygitweb/hooks_permissions_test.py +116 -0
  33. pygitweb/main.py +887 -0
  34. pygitweb/merge_requests.py +230 -0
  35. pygitweb/merge_requests_test.py +85 -0
  36. pygitweb/mr_permissions_test.py +148 -0
  37. pygitweb/password_hash.py +74 -0
  38. pygitweb/password_hash_test.py +20 -0
  39. pygitweb/permissions.py +209 -0
  40. pygitweb/permissions_test.py +86 -0
  41. pygitweb/plugin_loader.py +83 -0
  42. pygitweb/projects.py +213 -0
  43. pygitweb/projects_test.py +186 -0
  44. pygitweb/sessions.py +73 -0
  45. pygitweb/settings.py +412 -0
  46. pygitweb/settings.schema.json +73 -0
  47. pygitweb/settings_config.py +122 -0
  48. pygitweb/settings_config_test.py +101 -0
  49. pygitweb/settings_permissions_test.py +76 -0
  50. pygitweb/static/blob-view.js +133 -0
  51. pygitweb/static/board.js +839 -0
  52. pygitweb/static/hook-install.js +53 -0
  53. pygitweb/static/icons/cloud-down.svg +1 -0
  54. pygitweb/static/icons/cloud-exclamation.svg +1 -0
  55. pygitweb/static/icons/cloud-filled.svg +1 -0
  56. pygitweb/static/icons/cloud-storm.svg +1 -0
  57. pygitweb/static/icons/cloud-up.svg +1 -0
  58. pygitweb/static/icons/cloud.svg +1 -0
  59. pygitweb/static/logo.svg +44 -0
  60. pygitweb/static/main.css +1210 -0
  61. pygitweb/static/navbar-auth.js +82 -0
  62. pygitweb/static/readme-render.js +174 -0
  63. pygitweb/static/search-page.js +139 -0
  64. pygitweb/static/summary-ref-switcher.js +130 -0
  65. pygitweb/static/themes/solarized.css +34 -0
  66. pygitweb/tasks.py +595 -0
  67. pygitweb/tasks_permissions_test.py +137 -0
  68. pygitweb/tasks_routes_test.py +430 -0
  69. pygitweb/templates/addproject.html +174 -0
  70. pygitweb/templates/blobdiff.html +57 -0
  71. pygitweb/templates/board.html +31 -0
  72. pygitweb/templates/board_card.html +44 -0
  73. pygitweb/templates/commitdiff.html +57 -0
  74. pygitweb/templates/hooks_summary_cell.html +37 -0
  75. pygitweb/templates/login.html +37 -0
  76. pygitweb/templates/merge_request_summary_cell.html +18 -0
  77. pygitweb/templates/merge_request_tag.html +90 -0
  78. pygitweb/templates/pagination.html +44 -0
  79. pygitweb/templates/preamble.html +89 -0
  80. pygitweb/templates/project_settings.html +13 -0
  81. pygitweb/templates/pygit2_settings.html +12 -0
  82. pygitweb/templates/pygitweb_settings.html +12 -0
  83. pygitweb/templates/search.html +77 -0
  84. pygitweb/templates/settings_form_field.html +27 -0
  85. pygitweb/templates/table.html +15 -0
  86. pygitweb/templates/user_profile.html +10 -0
  87. pygitweb/templates_env.py +21 -0
  88. pygitweb/timeline_cache.py +243 -0
  89. pygitweb/validation.py +75 -0
  90. pygitweb/validation_test.py +167 -0
  91. pygitweb-0.1.0.dist-info/METADATA +165 -0
  92. pygitweb-0.1.0.dist-info/RECORD +95 -0
  93. pygitweb-0.1.0.dist-info/WHEEL +5 -0
  94. pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
  95. pygitweb-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,363 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ import pygit2
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+
11
+ from pygitweb.config import settings
12
+ from pygitweb.hooks_install import (
13
+ HOOK_BUNDLES,
14
+ HookStatus,
15
+ bundle_status,
16
+ get_bundle,
17
+ get_installed_version,
18
+ get_sample,
19
+ install,
20
+ install_bundle,
21
+ is_installed,
22
+ list_bundles,
23
+ list_installable_hooks,
24
+ list_installed_hooks,
25
+ list_samples,
26
+ read_hook_version,
27
+ remove,
28
+ remove_bundle,
29
+ status,
30
+ )
31
+ from pygitweb.main import app
32
+
33
+
34
+ def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
35
+ (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
36
+ index = repo.index
37
+ index.add("README.md")
38
+ index.write()
39
+ tree = index.write_tree()
40
+ sig = pygit2.Signature("tester", "tester@example.com")
41
+ repo.create_commit("HEAD", sig, sig, "initial", tree, [])
42
+
43
+
44
+ @pytest.fixture
45
+ def project_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
46
+ root: Path = tmp_path / "root"
47
+ root.mkdir()
48
+ repo_dir: Path = root / "demo"
49
+ repo_dir.mkdir()
50
+ _create_initial_commit(pygit2.init_repository(str(repo_dir), bare=False), repo_dir)
51
+ with (
52
+ patch.object(settings, "PROJECTROOT", str(root)),
53
+ patch.object(settings, "PROJECTS_LIST", str(root)),
54
+ patch.object(settings, "PROJECT_MAXDEPTH", 3),
55
+ patch.object(settings, "STRICT_EXPORT", False),
56
+ patch.object(settings, "EXPORT_OK", ""),
57
+ patch.object(settings, "LIST_ALL", True),
58
+ patch.object(settings, "AUTH", False),
59
+ patch.object(settings, "MAXLOAD", None),
60
+ ):
61
+ yield {"root": str(root), "project": "demo", "hooks_dir": str(repo_dir / ".git" / "hooks")}
62
+
63
+
64
+ class TestRegistry:
65
+ def test_list_samples_includes_notify_pair(self) -> None:
66
+ names: set[str] = {s["name"] for s in list_samples()}
67
+ assert {"post-commit.notify", "post-receive.notify"} <= names
68
+
69
+ def test_get_sample_target_is_filename_prefix(self) -> None:
70
+ sample = get_sample("post-receive.notify")
71
+ assert sample is not None
72
+ assert sample["target"] == "post-receive"
73
+ assert Path(sample["source"]).is_file()
74
+
75
+ def test_get_sample_unknown_returns_none(self) -> None:
76
+ assert get_sample("does-not-exist.foo") is None
77
+
78
+ def test_update_bundle_includes_both_notify_samples(self) -> None:
79
+ bundle = get_bundle("update")
80
+ assert bundle is not None
81
+ assert bundle["label"] == "Update Hook"
82
+ assert {m["name"] for m in bundle["members"]} == {"post-commit.notify", "post-receive.notify"}
83
+
84
+ def test_list_bundles_returns_update(self) -> None:
85
+ assert any(b["name"] == "update" for b in list_bundles())
86
+ assert "update" in HOOK_BUNDLES
87
+
88
+
89
+ class TestStatusInstallRemove:
90
+ def test_status_not_installed_when_no_file(self, project_env: dict[str, str]) -> None:
91
+ assert status(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
92
+ assert is_installed(project_env["project"], "post-receive.notify") is False
93
+
94
+ def test_install_then_status_installed(self, project_env: dict[str, str]) -> None:
95
+ assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
96
+ assert is_installed(project_env["project"], "post-receive.notify") is True
97
+ target: Path = Path(project_env["hooks_dir"]) / "post-receive"
98
+ assert target.is_file()
99
+
100
+ def test_install_is_idempotent(self, project_env: dict[str, str]) -> None:
101
+ install(project_env["project"], "post-receive.notify")
102
+ assert install(project_env["project"], "post-receive.notify") == HookStatus.INSTALLED
103
+
104
+ def test_install_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
105
+ hooks_dir: Path = Path(project_env["hooks_dir"])
106
+ hooks_dir.mkdir(parents=True, exist_ok=True)
107
+ (hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
108
+ assert install(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
109
+ assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"
110
+
111
+ def test_remove_uninstalls_only_when_content_matches(self, project_env: dict[str, str]) -> None:
112
+ install(project_env["project"], "post-receive.notify")
113
+ assert remove(project_env["project"], "post-receive.notify") == HookStatus.NOT_INSTALLED
114
+ assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()
115
+
116
+ def test_remove_preserves_custom_hook(self, project_env: dict[str, str]) -> None:
117
+ hooks_dir: Path = Path(project_env["hooks_dir"])
118
+ hooks_dir.mkdir(parents=True, exist_ok=True)
119
+ custom: str = "#!/bin/sh\necho custom\n"
120
+ (hooks_dir / "post-receive").write_text(custom, encoding="utf-8")
121
+ assert remove(project_env["project"], "post-receive.notify") == HookStatus.DIFFERENT
122
+ assert (hooks_dir / "post-receive").read_text(encoding="utf-8") == custom
123
+
124
+ def test_unknown_sample_raises(self, project_env: dict[str, str]) -> None:
125
+ with pytest.raises(KeyError):
126
+ status(project_env["project"], "no-such.sample")
127
+ with pytest.raises(KeyError):
128
+ install(project_env["project"], "no-such.sample")
129
+ with pytest.raises(KeyError):
130
+ remove(project_env["project"], "no-such.sample")
131
+
132
+
133
+ class TestBundle:
134
+ def test_bundle_status_starts_not_installed(self, project_env: dict[str, str]) -> None:
135
+ assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED
136
+
137
+ def test_install_bundle_installs_all_members(self, project_env: dict[str, str]) -> None:
138
+ assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
139
+ assert is_installed(project_env["project"], "post-commit.notify")
140
+ assert is_installed(project_env["project"], "post-receive.notify")
141
+
142
+ def test_partial_install_reports_not_installed(self, project_env: dict[str, str]) -> None:
143
+ install(project_env["project"], "post-commit.notify")
144
+ assert bundle_status(project_env["project"], "update") == HookStatus.NOT_INSTALLED
145
+
146
+ def test_install_bundle_completes_partial(self, project_env: dict[str, str]) -> None:
147
+ install(project_env["project"], "post-commit.notify")
148
+ assert install_bundle(project_env["project"], "update") == HookStatus.INSTALLED
149
+ assert is_installed(project_env["project"], "post-receive.notify")
150
+
151
+ def test_bundle_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
152
+ hooks_dir: Path = Path(project_env["hooks_dir"])
153
+ hooks_dir.mkdir(parents=True, exist_ok=True)
154
+ (hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
155
+ assert bundle_status(project_env["project"], "update") == HookStatus.DIFFERENT
156
+ assert install_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
157
+ assert remove_bundle(project_env["project"], "update") == HookStatus.DIFFERENT
158
+ assert (hooks_dir / "post-commit").read_text(encoding="utf-8") == "#!/bin/sh\necho custom\n"
159
+
160
+ def test_remove_bundle_uninstalls_only_managed_members(self, project_env: dict[str, str]) -> None:
161
+ install_bundle(project_env["project"], "update")
162
+ assert remove_bundle(project_env["project"], "update") == HookStatus.NOT_INSTALLED
163
+ assert not (Path(project_env["hooks_dir"]) / "post-commit").exists()
164
+ assert not (Path(project_env["hooks_dir"]) / "post-receive").exists()
165
+
166
+ def test_unknown_bundle_raises(self, project_env: dict[str, str]) -> None:
167
+ with pytest.raises(KeyError):
168
+ bundle_status(project_env["project"], "no-such-bundle")
169
+ with pytest.raises(KeyError):
170
+ install_bundle(project_env["project"], "no-such-bundle")
171
+ with pytest.raises(KeyError):
172
+ remove_bundle(project_env["project"], "no-such-bundle")
173
+
174
+
175
+ def _client() -> TestClient:
176
+ return TestClient(app)
177
+
178
+
179
+ class TestHookRoute:
180
+ def test_sample_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
181
+ project: str = project_env["project"]
182
+ with _client() as client:
183
+ check_resp = client.post(
184
+ f"/project/{project}/hook",
185
+ params={"name": "post-receive.notify", "op": "check"},
186
+ )
187
+ assert check_resp.status_code == 200
188
+ body = check_resp.json()
189
+ assert body["installed"] is False
190
+ assert body["kind"] == "sample"
191
+
192
+ add_resp = client.post(
193
+ f"/project/{project}/hook",
194
+ params={"name": "post-receive.notify", "op": "add"},
195
+ )
196
+ assert add_resp.status_code == 200
197
+ body = add_resp.json()
198
+ assert body["installed"] is True
199
+ assert body["target"] == "post-receive"
200
+ assert body["label"] == "Post-receive Notify"
201
+
202
+ remove_resp = client.post(
203
+ f"/project/{project}/hook",
204
+ params={"name": "post-receive.notify", "op": "remove"},
205
+ )
206
+ assert remove_resp.status_code == 200
207
+ assert remove_resp.json()["installed"] is False
208
+
209
+ def test_bundle_check_then_add_then_remove_round_trip(self, project_env: dict[str, str]) -> None:
210
+ project: str = project_env["project"]
211
+ hooks_dir: Path = Path(project_env["hooks_dir"])
212
+ with _client() as client:
213
+ check_resp = client.post(
214
+ f"/project/{project}/hook",
215
+ params={"name": "update", "op": "check"},
216
+ )
217
+ assert check_resp.status_code == 200
218
+ body = check_resp.json()
219
+ assert body["installed"] is False
220
+ assert body["kind"] == "bundle"
221
+ assert body["label"] == "Update Hook"
222
+ assert set(body["members"]) == {"post-commit.notify", "post-receive.notify"}
223
+
224
+ add_resp = client.post(
225
+ f"/project/{project}/hook",
226
+ params={"name": "update", "op": "add"},
227
+ )
228
+ assert add_resp.status_code == 200
229
+ assert add_resp.json()["installed"] is True
230
+ assert (hooks_dir / "post-commit").is_file()
231
+ assert (hooks_dir / "post-receive").is_file()
232
+
233
+ remove_resp = client.post(
234
+ f"/project/{project}/hook",
235
+ params={"name": "update", "op": "remove"},
236
+ )
237
+ assert remove_resp.status_code == 200
238
+ assert remove_resp.json()["installed"] is False
239
+ assert not (hooks_dir / "post-commit").exists()
240
+ assert not (hooks_dir / "post-receive").exists()
241
+
242
+ def test_bundle_add_refuses_when_member_has_custom_hook(self, project_env: dict[str, str]) -> None:
243
+ hooks_dir: Path = Path(project_env["hooks_dir"])
244
+ hooks_dir.mkdir(parents=True, exist_ok=True)
245
+ (hooks_dir / "post-commit").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
246
+ with _client() as client:
247
+ resp = client.post(
248
+ f"/project/{project_env['project']}/hook",
249
+ params={"name": "update", "op": "add"},
250
+ )
251
+ assert resp.status_code == 409
252
+
253
+ def test_invalid_op_returns_422(self, project_env: dict[str, str]) -> None:
254
+ with _client() as client:
255
+ resp = client.post(
256
+ f"/project/{project_env['project']}/hook",
257
+ params={"name": "post-receive.notify", "op": "noop"},
258
+ )
259
+ assert resp.status_code == 422
260
+
261
+ def test_unknown_name_returns_404(self, project_env: dict[str, str]) -> None:
262
+ with _client() as client:
263
+ resp = client.post(
264
+ f"/project/{project_env['project']}/hook",
265
+ params={"name": "no-such.thing", "op": "check"},
266
+ )
267
+ assert resp.status_code == 404
268
+
269
+ def test_add_refuses_to_overwrite_custom_hook(self, project_env: dict[str, str]) -> None:
270
+ hooks_dir: Path = Path(project_env["hooks_dir"])
271
+ hooks_dir.mkdir(parents=True, exist_ok=True)
272
+ (hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
273
+ with _client() as client:
274
+ resp = client.post(
275
+ f"/project/{project_env['project']}/hook",
276
+ params={"name": "post-receive.notify", "op": "add"},
277
+ )
278
+ assert resp.status_code == 409
279
+
280
+ def test_hooks_list_endpoint_returns_status_for_samples_and_bundles(self, project_env: dict[str, str]) -> None:
281
+ with _client() as client:
282
+ resp = client.get(f"/project/{project_env['project']}/hooks")
283
+ assert resp.status_code == 200
284
+ body = resp.json()
285
+ assert body["project"] == project_env["project"]
286
+ sample_names: set[str] = {entry["name"] for entry in body["hooks"]}
287
+ assert {"post-commit.notify", "post-receive.notify"} <= sample_names
288
+ bundle_names: set[str] = {entry["name"] for entry in body["bundles"]}
289
+ assert "update" in bundle_names
290
+
291
+ def test_summary_renders_install_dropdown_when_no_hooks_installed(self, project_env: dict[str, str]) -> None:
292
+ with _client() as client:
293
+ resp = client.get(f"/project/{project_env['project']}")
294
+ assert resp.status_code == 200
295
+ assert 'id="hook-install-select"' in resp.text
296
+ assert "hook-install-btn" in resp.text
297
+ assert "Post-receive Notify" in resp.text
298
+ assert "No hooks installed." in resp.text
299
+
300
+ def test_summary_renders_uninstall_buttons_for_installed_hooks(self, project_env: dict[str, str]) -> None:
301
+ install(project_env["project"], "post-receive.notify")
302
+ install(project_env["project"], "pre-commit.ruff")
303
+ with _client() as client:
304
+ resp = client.get(f"/project/{project_env['project']}")
305
+ assert resp.status_code == 200
306
+ assert resp.text.count("Uninstall") == 2
307
+ assert 'data-sample="post-receive.notify"' in resp.text
308
+ assert 'data-sample="pre-commit.ruff"' in resp.text
309
+ assert ">v1<" in resp.text or "v1" in resp.text
310
+
311
+ def test_summary_hides_installed_hooks_from_install_dropdown(self, project_env: dict[str, str]) -> None:
312
+ install(project_env["project"], "post-commit.notify")
313
+ with _client() as client:
314
+ resp = client.get(f"/project/{project_env['project']}")
315
+ assert resp.status_code == 200
316
+ assert 'data-sample="post-commit.notify"' in resp.text
317
+ assert 'value="post-commit.notify"' not in resp.text
318
+
319
+ def test_summary_shows_custom_hook_conflict(self, project_env: dict[str, str]) -> None:
320
+ hooks_dir: Path = Path(project_env["hooks_dir"])
321
+ hooks_dir.mkdir(parents=True, exist_ok=True)
322
+ (hooks_dir / "post-receive").write_text("#!/bin/sh\necho custom\n", encoding="utf-8")
323
+ with _client() as client:
324
+ resp = client.get(f"/project/{project_env['project']}")
325
+ assert resp.status_code == 200
326
+ assert "custom hook installed; refusing to manage" in resp.text
327
+
328
+
329
+ class TestHookVersion:
330
+ def test_read_hook_version_from_sample(self) -> None:
331
+ sample = get_sample("post-receive.notify")
332
+ assert sample is not None
333
+ assert read_hook_version(Path(sample["source"])) == "1"
334
+
335
+ def test_get_installed_version_reads_installed_file(self, project_env: dict[str, str]) -> None:
336
+ install(project_env["project"], "post-receive.notify")
337
+ assert get_installed_version(project_env["project"], "post-receive.notify") == "1"
338
+
339
+ def test_list_installed_hooks_includes_only_present_hooks(self, project_env: dict[str, str]) -> None:
340
+ install(project_env["project"], "post-commit.notify")
341
+ installed = list_installed_hooks(project_env["project"])
342
+ assert {entry["name"] for entry in installed} == {"post-commit.notify"}
343
+ assert installed[0]["version"] == "1"
344
+
345
+ def test_list_installable_hooks_excludes_installed(self, project_env: dict[str, str]) -> None:
346
+ install(project_env["project"], "post-commit.notify")
347
+ installable = list_installable_hooks(project_env["project"])
348
+ assert all(entry["name"] != "post-commit.notify" for entry in installable)
349
+
350
+
351
+ class TestSummaryRefSwitcherStaticScript:
352
+ def test_static_script_subscribes_via_updates_endpoint(self) -> None:
353
+ with _client() as client:
354
+ resp = client.get("/static/summary-ref-switcher.js")
355
+ assert resp.status_code == 200
356
+ body: str = resp.text
357
+ assert "a=heads&updates=true" in body
358
+ assert "subscribeToUpdates" in body
359
+ assert "AbortController" in body
360
+ assert "loadOptions" in body
361
+ assert "loadState" in body
362
+ assert "summary-tree-container" in body
363
+ assert "tree_html" in body
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ import pygit2
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+
11
+ from pygitweb.auth_config import AuthConfig, LocalUser
12
+ from pygitweb.config import settings
13
+ from pygitweb.conftest import client_as_user
14
+ from pygitweb.main import app
15
+ from pygitweb.permissions import Permission, PermissionsMap, permission_key
16
+
17
+ _HOOKS_AUTH = AuthConfig(
18
+ auth_mode="local",
19
+ local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
20
+ oauth_permissions=PermissionsMap.model_validate({
21
+ "*": [],
22
+ permission_key(Permission.HOOKS, "demo"): ["admin"],
23
+ }),
24
+ )
25
+
26
+
27
+ def _create_initial_commit(repo: pygit2.Repository, repo_dir: Path) -> None:
28
+ (repo_dir / "README.md").write_text("seed\n", encoding="utf-8")
29
+ index = repo.index
30
+ index.add("README.md")
31
+ index.write()
32
+ tree = index.write_tree()
33
+ sig = pygit2.Signature("tester", "tester@example.com")
34
+ repo.create_commit("HEAD", sig, sig, "initial", tree, [])
35
+
36
+
37
+ @pytest.fixture
38
+ def hook_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
39
+ root = tmp_path / "root"
40
+ root.mkdir()
41
+ repo_dir = root / "demo"
42
+ repo_dir.mkdir()
43
+ _create_initial_commit(pygit2.init_repository(str(repo_dir), bare=False), repo_dir)
44
+ with (
45
+ patch.object(settings, "PROJECTROOT", str(root)),
46
+ patch.object(settings, "PROJECTS_LIST", str(root)),
47
+ patch.object(settings, "PROJECT_MAXDEPTH", 3),
48
+ patch.object(settings, "STRICT_EXPORT", False),
49
+ patch.object(settings, "EXPORT_OK", ""),
50
+ patch.object(settings, "LIST_ALL", True),
51
+ patch.object(settings, "MAXLOAD", None),
52
+ patch("pygitweb.auth_config.auth_config", _HOOKS_AUTH),
53
+ patch("pygitweb.auth.auth_config", _HOOKS_AUTH),
54
+ patch("pygitweb.main.auth_config", _HOOKS_AUTH),
55
+ ):
56
+ yield {"project": "demo"}
57
+
58
+
59
+ @pytest.fixture
60
+ def client() -> Generator[TestClient, None, None]:
61
+ with TestClient(app) as c:
62
+ yield c
63
+
64
+
65
+ def test_hook_check_open_when_auth_disabled(client: TestClient, hook_env: dict[str, str]) -> None:
66
+ with patch.object(settings, "AUTH", False):
67
+ r = client.post(
68
+ f"/project/{hook_env['project']}/hook",
69
+ params={"name": "post-receive.notify", "op": "check"},
70
+ )
71
+ assert r.status_code == 200
72
+
73
+
74
+ def test_hook_add_open_when_auth_disabled(client: TestClient, hook_env: dict[str, str]) -> None:
75
+ with patch.object(settings, "AUTH", False):
76
+ r = client.post(
77
+ f"/project/{hook_env['project']}/hook",
78
+ params={"name": "post-receive.notify", "op": "add"},
79
+ )
80
+ assert r.status_code == 200
81
+
82
+
83
+ def test_hook_add_401_without_login(client: TestClient, hook_env: dict[str, str]) -> None:
84
+ with patch.object(settings, "AUTH", True):
85
+ r = client.post(
86
+ f"/project/{hook_env['project']}/hook",
87
+ params={"name": "post-receive.notify", "op": "add"},
88
+ )
89
+ assert r.status_code == 401
90
+
91
+
92
+ def test_hook_add_allowed_with_grant(client: TestClient, hook_env: dict[str, str]) -> None:
93
+ with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
94
+ r = client.post(
95
+ f"/project/{hook_env['project']}/hook",
96
+ params={"name": "post-receive.notify", "op": "add"},
97
+ )
98
+ assert r.status_code == 200
99
+
100
+
101
+ def test_hook_add_forbidden_without_grant(client: TestClient, hook_env: dict[str, str]) -> None:
102
+ with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
103
+ r = client.post(
104
+ f"/project/{hook_env['project']}/hook",
105
+ params={"name": "post-receive.notify", "op": "add"},
106
+ )
107
+ assert r.status_code == 403
108
+
109
+
110
+ def test_hook_check_allowed_without_hooks_grant(client: TestClient, hook_env: dict[str, str]) -> None:
111
+ with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
112
+ r = client.post(
113
+ f"/project/{hook_env['project']}/hook",
114
+ params={"name": "post-receive.notify", "op": "check"},
115
+ )
116
+ assert r.status_code == 200