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
pygitweb/auth_test.py ADDED
@@ -0,0 +1,390 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from collections.abc import Generator
5
+ from pathlib import Path
6
+ from unittest.mock import AsyncMock, patch
7
+
8
+ import pytest
9
+ from fastapi.testclient import TestClient
10
+
11
+ from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
12
+ from pygitweb.auth_config import AuthConfig, LocalUser, init_auth_config, write_auth_config
13
+ from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, _seal_oauth_state, is_oauth_email_allowed
14
+ from pygitweb.config import Settings, settings
15
+ from pygitweb.conftest import clear_client_cookies
16
+ from pygitweb.gravatar import gravatar_url
17
+ from pygitweb.main import app
18
+ from pygitweb.password_hash import hash_password, verify_password
19
+ from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
20
+ from pygitweb.sessions import get_session
21
+
22
+ _LOCAL_ADMIN = AuthConfig(
23
+ auth_mode="local",
24
+ local_users=[LocalUser(user="admin", password="secret")],
25
+ )
26
+
27
+
28
+ @pytest.fixture
29
+ def client() -> Generator[TestClient, None, None]:
30
+ with TestClient(app) as c:
31
+ yield c
32
+
33
+
34
+ @pytest.fixture
35
+ def local_auth_config() -> Generator[AuthConfig, None, None]:
36
+ with (
37
+ patch("pygitweb.auth_config.auth_config", _LOCAL_ADMIN),
38
+ patch("pygitweb.auth.auth_config", _LOCAL_ADMIN),
39
+ patch("pygitweb.main.auth_config", _LOCAL_ADMIN),
40
+ ):
41
+ yield _LOCAL_ADMIN
42
+
43
+
44
+ def test_token_disabled(client: TestClient) -> None:
45
+ with patch.object(settings, "AUTH", False):
46
+ r = client.post("/token", data={"username": "a", "password": "b"})
47
+ assert r.status_code == 400
48
+ assert r.json()["detail"] == "Authentication is disabled"
49
+
50
+
51
+ def test_token_success(client: TestClient, local_auth_config: AuthConfig) -> None:
52
+ with patch.object(settings, "AUTH", True):
53
+ r = client.post("/token", data={"username": "admin", "password": "secret"})
54
+ assert r.status_code == 200
55
+ data = r.json()
56
+ assert data["token_type"] == "bearer"
57
+ assert isinstance(data["access_token"], str)
58
+ assert len(data["access_token"]) >= 32
59
+ assert data["access_token"] != "admin"
60
+
61
+
62
+ def test_token_wrong_password(client: TestClient, local_auth_config: AuthConfig) -> None:
63
+ with patch.object(settings, "AUTH", True):
64
+ r = client.post("/token", data={"username": "admin", "password": "wrong"})
65
+ assert r.status_code == 400
66
+
67
+
68
+ def test_users_me_with_bearer(client: TestClient, local_auth_config: AuthConfig) -> None:
69
+ with patch.object(settings, "AUTH", True):
70
+ tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
71
+ r = client.get("/users/me", headers={"Authorization": f"Bearer {tok}"})
72
+ assert r.status_code == 200
73
+ assert r.json()["username"] == "admin"
74
+
75
+
76
+ def test_login_form_sets_cookie_and_users_me(client: TestClient, local_auth_config: AuthConfig) -> None:
77
+ with patch.object(settings, "AUTH", True):
78
+ r = client.post(
79
+ "/login",
80
+ data={"username": "admin", "password": "secret", "next": "/"},
81
+ follow_redirects=False,
82
+ )
83
+ assert r.status_code == 303
84
+ r2 = client.get("/users/me")
85
+ assert r2.status_code == 200
86
+ assert r2.json()["username"] == "admin"
87
+
88
+
89
+ def test_logout_revokes_cookie_session(client: TestClient, local_auth_config: AuthConfig) -> None:
90
+ with patch.object(settings, "AUTH", True):
91
+ client.post(
92
+ "/login",
93
+ data={"username": "admin", "password": "secret", "next": "/"},
94
+ follow_redirects=False,
95
+ )
96
+ assert client.get("/users/me").status_code == 200
97
+ r_out = client.get("/logout", params={"next": "/"}, follow_redirects=False)
98
+ assert r_out.status_code == 303
99
+ assert client.get("/users/me").status_code == 401
100
+
101
+
102
+ def test_logout_with_bearer_revokes_token(client: TestClient, local_auth_config: AuthConfig) -> None:
103
+ with patch.object(settings, "AUTH", True):
104
+ tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
105
+ headers = {"Authorization": f"Bearer {tok}"}
106
+ assert client.get("/users/me", headers=headers).status_code == 200
107
+ r_out = client.get("/logout", params={"next": "/"}, headers=headers, follow_redirects=False)
108
+ assert r_out.status_code == 303
109
+ assert client.get("/users/me", headers=headers).status_code == 401
110
+
111
+
112
+ def test_projectnamevalid_no_auth_required_when_auth_disabled(client: TestClient) -> None:
113
+ with patch.object(settings, "AUTH", False), patch("pygitweb.main.project_visible_in_list", return_value=False):
114
+ r = client.get("/projectnamevalid", params={"name": "newproj"})
115
+ assert r.status_code == 200
116
+
117
+
118
+ def test_projectnamevalid_401_without_credentials_when_auth_enabled(
119
+ client: TestClient, local_auth_config: AuthConfig
120
+ ) -> None:
121
+ with patch.object(settings, "AUTH", True):
122
+ r = client.get("/projectnamevalid", params={"name": "newproj"})
123
+ assert r.status_code == 401
124
+
125
+
126
+ def test_projectnamevalid_ok_with_bearer_when_auth_enabled(client: TestClient, local_auth_config: AuthConfig) -> None:
127
+ cfg = local_auth_config.model_copy(
128
+ update={
129
+ "oauth_permissions": PermissionsMap.model_validate({
130
+ "*": [],
131
+ PERMISSION_ADD_PROJECTS: ["admin"],
132
+ }),
133
+ },
134
+ )
135
+ with (
136
+ patch.object(settings, "AUTH", True),
137
+ patch("pygitweb.main.project_visible_in_list", return_value=False),
138
+ patch("pygitweb.auth_config.auth_config", cfg),
139
+ patch("pygitweb.auth.auth_config", cfg),
140
+ patch("pygitweb.main.auth_config", cfg),
141
+ ):
142
+ tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
143
+ r = client.get(
144
+ "/projectnamevalid",
145
+ params={"name": "newproj"},
146
+ headers={"Authorization": f"Bearer {tok}"},
147
+ )
148
+ assert r.status_code == 200
149
+
150
+
151
+ def test_auth_config_init_creates_file_only_when_auth_enabled(tmp_path: Path) -> None:
152
+ cfg_path = tmp_path / "auth.json"
153
+ s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
154
+ assert not cfg_path.is_file()
155
+ cfg = init_auth_config(s)
156
+ assert cfg_path.is_file()
157
+ assert cfg.local_users[0].user == "admin"
158
+ assert len(cfg.salt) == 16
159
+ assert cfg.local_users[0].pass_hash.startswith("scrypt$")
160
+ assert cfg.local_users[0].password is None
161
+ on_disk = json.loads(cfg_path.read_text(encoding="utf-8"))
162
+ assert len(on_disk["salt"]) == 16
163
+ user_entry = on_disk["local_users"][0]
164
+ assert "pass" not in user_entry
165
+ assert "pass_hash" in user_entry
166
+ if sys.platform != "win32":
167
+ assert oct(os.stat(cfg_path).st_mode & 0o777) == "0o600"
168
+
169
+
170
+ def test_auth_config_no_file_when_auth_disabled(tmp_path: Path) -> None:
171
+ from pygitweb.auth_config import init_auth_config
172
+ from pygitweb.config import Settings
173
+
174
+ cfg_path = tmp_path / "auth.json"
175
+ s = Settings(AUTH=False, AUTH_CONFIG=str(cfg_path))
176
+ init_auth_config(s)
177
+ assert not cfg_path.is_file()
178
+
179
+
180
+ def test_auth_config_does_not_regenerate_missing_password(tmp_path: Path) -> None:
181
+ cfg_path = tmp_path / "auth.json"
182
+ write_auth_config(
183
+ cfg_path,
184
+ AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="")]),
185
+ )
186
+ s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
187
+ cfg = init_auth_config(s)
188
+ assert cfg.local_users[0].password == ""
189
+ assert cfg.local_users[0].pass_hash == ""
190
+
191
+
192
+ def test_auth_config_adds_salt_to_existing_file(tmp_path: Path) -> None:
193
+ cfg_path = tmp_path / "auth.json"
194
+ write_auth_config(
195
+ cfg_path,
196
+ AuthConfig(
197
+ auth_mode="local",
198
+ local_users=[LocalUser(user="admin", pass_hash=hash_password("secret"))],
199
+ ),
200
+ )
201
+ s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
202
+ cfg = init_auth_config(s)
203
+ assert len(cfg.salt) == 16
204
+ assert verify_password("secret", cfg.local_users[0].pass_hash)
205
+
206
+
207
+ def test_auth_config_migrates_plaintext_pass_to_pass_hash(tmp_path: Path) -> None:
208
+ cfg_path = tmp_path / "auth.json"
209
+ cfg_path.write_text(
210
+ '{"auth_mode": "local", "local_users": [{"user": "admin", "pass": "secret1234"}]}\n',
211
+ encoding="utf-8",
212
+ )
213
+ if sys.platform != "win32":
214
+ os.chmod(cfg_path, 0o600)
215
+ s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
216
+ cfg = init_auth_config(s)
217
+ assert cfg.local_users[0].password is None
218
+ assert verify_password("secret1234", cfg.local_users[0].pass_hash, config_salt=cfg.salt)
219
+ on_disk = cfg_path.read_text(encoding="utf-8")
220
+ root = json.loads(on_disk)
221
+ assert len(root["salt"]) == 16
222
+ user_entry = root["local_users"][0]
223
+ assert "pass" not in user_entry
224
+ assert "pass_hash" in user_entry
225
+
226
+
227
+ @pytest.mark.skipif(sys.platform == "win32", reason="Unix file modes")
228
+ def test_auth_config_rejects_insecure_permissions(tmp_path: Path) -> None:
229
+ from pygitweb.auth_config import AuthConfigPermissionError, init_auth_config, write_auth_config
230
+ from pygitweb.config import Settings
231
+
232
+ cfg_path = tmp_path / "auth.json"
233
+ write_auth_config(
234
+ cfg_path,
235
+ AuthConfig(auth_mode="local", local_users=[LocalUser(user="admin", password="secret")]),
236
+ )
237
+ os.chmod(cfg_path, 0o644)
238
+ s = Settings(AUTH=True, AUTH_CONFIG=str(cfg_path))
239
+ with pytest.raises(AuthConfigPermissionError, match="0600"):
240
+ init_auth_config(s)
241
+
242
+
243
+ def test_local_login_stores_session_audit_fields(client: TestClient, local_auth_config: AuthConfig) -> None:
244
+ with patch.object(settings, "AUTH", True):
245
+ client.post(
246
+ "/login",
247
+ data={"username": "admin", "password": "secret", "next": "/"},
248
+ follow_redirects=False,
249
+ )
250
+ tok = client.post("/token", data={"username": "admin", "password": "secret"}).json()["access_token"]
251
+ rec = get_session(tok)
252
+ assert rec is not None
253
+ assert rec.username == "admin"
254
+ assert rec.auth_method == "local"
255
+ assert rec.subject is None
256
+ assert rec.email is None
257
+
258
+
259
+ def test_oauth_allowed_emails_glob() -> None:
260
+ cfg = AuthConfig(auth_mode="oauth", oauth_allowed_emails=["*@example.com", "guest@corp.org"])
261
+ assert is_oauth_email_allowed(cfg, "alice@example.com")
262
+ assert is_oauth_email_allowed(cfg, "Alice@Example.COM")
263
+ assert is_oauth_email_allowed(cfg, "guest@corp.org")
264
+ assert not is_oauth_email_allowed(cfg, "alice@other.org")
265
+ assert not is_oauth_email_allowed(cfg, None)
266
+
267
+
268
+ def test_oauth_allowed_emails_exact_match() -> None:
269
+ cfg = AuthConfig(auth_mode="oauth", oauth_allowed_emails=["only@example.com"])
270
+ assert is_oauth_email_allowed(cfg, "only@example.com")
271
+ assert not is_oauth_email_allowed(cfg, "other@example.com")
272
+
273
+
274
+ _OAUTH_CFG = AuthConfig(
275
+ auth_mode="oauth",
276
+ oauth_provider="google",
277
+ oauth_client_id="test-client-id",
278
+ oauth_client_secret="test-client-secret",
279
+ oauth_redirect_uri="http://testserver/auth/oauth/callback",
280
+ oauth_allowed_emails=[],
281
+ local_users=[],
282
+ )
283
+
284
+
285
+ @pytest.fixture
286
+ def oauth_auth_config() -> Generator[AuthConfig, None, None]:
287
+ with (
288
+ patch("pygitweb.auth_config.auth_config", _OAUTH_CFG),
289
+ patch("pygitweb.auth.auth_config", _OAUTH_CFG),
290
+ patch("pygitweb.auth_oauth.auth_config", _OAUTH_CFG),
291
+ patch("pygitweb.main.auth_config", _OAUTH_CFG),
292
+ ):
293
+ yield _OAUTH_CFG
294
+
295
+
296
+ def test_oauth_start_redirects_to_provider(client: TestClient, oauth_auth_config: AuthConfig) -> None:
297
+ with patch.object(settings, "AUTH", True):
298
+ r = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
299
+ assert r.status_code == 303
300
+ assert "accounts.google.com" in r.headers["location"]
301
+ assert OAUTH_STATE_COOKIE_NAME in r.cookies
302
+
303
+
304
+ def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: AuthConfig) -> None:
305
+ sealed = _seal_oauth_state(
306
+ {"state": "idp-state", "next": "/", "code_verifier": "verifier"},
307
+ oauth_auth_config.oauth_client_secret,
308
+ )
309
+ token_payload = {"access_token": "provider-token"}
310
+ profile = {"sub": "oauth-sub-1", "email": "user@example.com", "preferred_username": "oauthuser"}
311
+ with (
312
+ patch.object(settings, "AUTH", True),
313
+ patch("pygitweb.auth_oauth._exchange_code", new_callable=AsyncMock, return_value=token_payload),
314
+ patch("pygitweb.auth_oauth._fetch_profile", new_callable=AsyncMock, return_value=profile),
315
+ ):
316
+ client.cookies.set(OAUTH_STATE_COOKIE_NAME, sealed)
317
+ r = client.get(
318
+ "/auth/oauth/callback",
319
+ params={"code": "auth-code", "state": "idp-state"},
320
+ follow_redirects=False,
321
+ )
322
+ assert r.status_code == 303
323
+ rec = get_session(r.cookies["pygitweb_access_token"])
324
+ assert rec is not None
325
+ assert rec.username == "user@example.com"
326
+ assert rec.subject == "oauth-sub-1"
327
+ assert rec.email == "user@example.com"
328
+ assert rec.auth_method == "oauth"
329
+ assert client.get("/users/me").json()["username"] == "user@example.com"
330
+ status = client.get("/auth/status").json()
331
+ assert status["username"] == "user@example.com"
332
+ assert status["gravatar_url"] == gravatar_url("user@example.com")
333
+ clear_client_cookies(client)
334
+
335
+
336
+ def test_auth_status_no_gravatar_for_local_login(client: TestClient, local_auth_config: AuthConfig) -> None:
337
+ with patch.object(settings, "AUTH", True):
338
+ client.post(
339
+ "/login",
340
+ data={"username": "admin", "password": "secret", "next": "/"},
341
+ follow_redirects=False,
342
+ )
343
+ status = client.get("/auth/status").json()
344
+ assert status["username"] == "admin"
345
+ assert status["gravatar_url"] is None
346
+
347
+
348
+ def test_local_user_email_gravatar(client: TestClient) -> None:
349
+ cfg = AuthConfig(
350
+ auth_mode="local",
351
+ local_users=[LocalUser(user="admin", password="secret", email=" admin@example.com ")],
352
+ )
353
+ with (
354
+ patch.object(settings, "AUTH", True),
355
+ patch("pygitweb.auth_config.auth_config", cfg),
356
+ patch("pygitweb.auth.auth_config", cfg),
357
+ patch("pygitweb.main.auth_config", cfg),
358
+ ):
359
+ client.post(
360
+ "/login",
361
+ data={"username": "admin", "password": "secret", "next": "/"},
362
+ follow_redirects=False,
363
+ )
364
+ status = client.get("/auth/status").json()
365
+ assert status["gravatar_url"] == gravatar_url("admin@example.com")
366
+ rec = get_session(client.cookies[ACCESS_TOKEN_COOKIE_NAME])
367
+ assert rec is not None
368
+ assert rec.email == "admin@example.com"
369
+
370
+
371
+ def test_authenticate_local_user_disabled_in_oauth_only_mode() -> None:
372
+ from pygitweb.auth import authenticate_local_user
373
+
374
+ with patch("pygitweb.auth.auth_config", _OAUTH_CFG):
375
+ assert authenticate_local_user("admin", "secret") is None
376
+
377
+
378
+ def test_authenticate_local_user_pass_hash() -> None:
379
+ from pygitweb.auth import authenticate_local_user
380
+
381
+ cfg = AuthConfig(
382
+ auth_mode="local",
383
+ salt="0123456789abcdef",
384
+ local_users=[LocalUser(user="admin", pass_hash=hash_password("secret", config_salt="0123456789abcdef"))],
385
+ )
386
+ with patch("pygitweb.auth.auth_config", cfg):
387
+ user = authenticate_local_user("admin", "secret")
388
+ assert user is not None
389
+ assert user.username == "admin"
390
+ assert authenticate_local_user("admin", "wrong") is None
@@ -0,0 +1,113 @@
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 import FastAPI
10
+ from fastapi.testclient import TestClient
11
+
12
+ from pygitweb.auth_config import AuthConfig, LocalUser
13
+ from pygitweb.config import settings
14
+ from pygitweb.conftest import client_as_user
15
+ from pygitweb.permissions import Permission, PermissionsMap, permission_key
16
+ from pygitweb.tasks import board_router
17
+
18
+ _BOARDS_AUTH = AuthConfig(
19
+ auth_mode="local",
20
+ local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
21
+ oauth_permissions=PermissionsMap.model_validate({
22
+ "*": [],
23
+ permission_key(Permission.BOARDS, "demo"): ["admin"],
24
+ }),
25
+ )
26
+
27
+
28
+ def _build_client() -> TestClient:
29
+ app = FastAPI()
30
+ app.include_router(board_router, prefix="/board")
31
+ return TestClient(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 board_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
46
+ repo_dir = tmp_path / "demo"
47
+ repo = pygit2.init_repository(str(repo_dir), bare=False)
48
+ _create_initial_commit(repo, repo_dir)
49
+ with (
50
+ patch.object(settings, "PROJECTROOT", str(tmp_path)),
51
+ patch.object(settings, "PROJECTS_LIST", str(tmp_path)),
52
+ patch.object(settings, "STRICT_EXPORT", False),
53
+ patch.object(settings, "EXPORT_OK", ""),
54
+ patch.object(settings, "LIST_ALL", True),
55
+ patch("pygitweb.auth_config.auth_config", _BOARDS_AUTH),
56
+ patch("pygitweb.auth.auth_config", _BOARDS_AUTH),
57
+ ):
58
+ yield {"client": _build_client(), "project": "demo"}
59
+
60
+
61
+ def test_board_post_open_when_auth_disabled(board_env: dict[str, str | TestClient]) -> None:
62
+ client = board_env["client"]
63
+ assert isinstance(client, TestClient)
64
+ with patch.object(settings, "AUTH", False):
65
+ r = client.post(
66
+ "/board/create",
67
+ params={"project": board_env["project"], "name": "temp"},
68
+ )
69
+ assert r.status_code == 200
70
+
71
+
72
+ def test_board_create_401_without_login(board_env: dict[str, str | TestClient]) -> None:
73
+ client = board_env["client"]
74
+ assert isinstance(client, TestClient)
75
+ with patch.object(settings, "AUTH", True):
76
+ r = client.post(
77
+ "/board/create",
78
+ params={"project": board_env["project"], "name": "temp"},
79
+ )
80
+ assert r.status_code == 401
81
+
82
+
83
+ def test_board_create_allowed_with_grant(board_env: dict[str, str | TestClient]) -> None:
84
+ client = board_env["client"]
85
+ assert isinstance(client, TestClient)
86
+ with patch.object(settings, "AUTH", True), client_as_user(client, "admin"):
87
+ r = client.post(
88
+ "/board/create",
89
+ params={"project": board_env["project"], "name": "temp-board"},
90
+ )
91
+ assert r.status_code == 200
92
+
93
+
94
+ def test_board_create_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
95
+ client = board_env["client"]
96
+ assert isinstance(client, TestClient)
97
+ with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
98
+ r = client.post(
99
+ "/board/create",
100
+ params={"project": board_env["project"], "name": "temp-board"},
101
+ )
102
+ assert r.status_code == 403
103
+
104
+
105
+ def test_board_delete_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
106
+ client = board_env["client"]
107
+ assert isinstance(client, TestClient)
108
+ with patch.object(settings, "AUTH", True), client_as_user(client, "viewer"):
109
+ r = client.post(
110
+ "/board/delete",
111
+ params={"project": board_env["project"], "name": "Tasks"},
112
+ )
113
+ assert r.status_code == 403
@@ -0,0 +1,110 @@
1
+ """
2
+ Per-project async change queue for long-polling subscribers.
3
+
4
+ Producers (typically server-side git hooks via /_internal/notify) call notify(project)
5
+ when a project's refs change. Consumers (action handlers with updates=true) call
6
+ wait_for_changes(projects, timeout) to park their request until any matching project
7
+ is notified or the timeout elapses.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+
14
+
15
+ class ChangeQueue:
16
+ """Per-project notification fan-out using one-shot asyncio.Events per waiter."""
17
+
18
+ DEFAULT_TIMEOUT_SECONDS: float = 30.0
19
+
20
+ def __init__(self) -> None:
21
+ self._waiters: dict[str, list[asyncio.Event]] = {}
22
+ self._lock: asyncio.Lock = asyncio.Lock()
23
+
24
+ async def wait_for_changes(
25
+ self,
26
+ projects: list[str],
27
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
28
+ shutdown_event: asyncio.Event | None = None,
29
+ ) -> str | None:
30
+ """Park until any of `projects` is notified, `shutdown_event` is set, or `timeout` elapses.
31
+
32
+ Returns the notified project name, or None on timeout/shutdown.
33
+ """
34
+ if shutdown_event and shutdown_event.is_set():
35
+ return None
36
+
37
+ if not projects:
38
+ if shutdown_event:
39
+ t_sleep: asyncio.Task[None] = asyncio.create_task(asyncio.sleep(timeout))
40
+ t_sd: asyncio.Task[None] = asyncio.create_task(shutdown_event.wait())
41
+ try:
42
+ done, pending = await asyncio.wait(
43
+ {t_sleep, t_sd},
44
+ timeout=timeout,
45
+ return_when=asyncio.FIRST_COMPLETED,
46
+ )
47
+ finally:
48
+ for t in pending:
49
+ t.cancel()
50
+ await asyncio.gather(t_sleep, t_sd, return_exceptions=True)
51
+ return None
52
+ await asyncio.sleep(timeout)
53
+ return None
54
+
55
+ event: asyncio.Event = asyncio.Event()
56
+ async with self._lock:
57
+ for project in projects:
58
+ self._waiters.setdefault(project, []).append(event)
59
+ try:
60
+ if shutdown_event:
61
+ t_notify: asyncio.Task[None] = asyncio.create_task(event.wait())
62
+ t_shutdown: asyncio.Task[None] = asyncio.create_task(shutdown_event.wait())
63
+ try:
64
+ done, pending = await asyncio.wait(
65
+ {t_notify, t_shutdown},
66
+ timeout=timeout,
67
+ return_when=asyncio.FIRST_COMPLETED,
68
+ )
69
+ finally:
70
+ for t in pending:
71
+ t.cancel()
72
+ await asyncio.gather(t_notify, t_shutdown, return_exceptions=True)
73
+ if not done:
74
+ return None
75
+ if t_shutdown in done:
76
+ return None
77
+ return getattr(event, "_change_queue_project", None)
78
+
79
+ try:
80
+ await asyncio.wait_for(event.wait(), timeout=timeout)
81
+ except TimeoutError:
82
+ return None
83
+ return getattr(event, "_change_queue_project", None)
84
+ finally:
85
+ async with self._lock:
86
+ for project in projects:
87
+ bucket: list[asyncio.Event] = self._waiters.get(project, [])
88
+ if event in bucket:
89
+ bucket.remove(event)
90
+ if not bucket:
91
+ self._waiters.pop(project, None)
92
+
93
+ async def notify(self, project: str) -> int:
94
+ """Wake all waiters subscribed to `project`. Returns the number of waiters woken."""
95
+ async with self._lock:
96
+ waiters: list[asyncio.Event] = self._waiters.pop(project, [])
97
+ for event in waiters:
98
+ event._change_queue_project = project # type: ignore[attr-defined]
99
+ event.set()
100
+ return len(waiters)
101
+
102
+ async def waiter_count(self, project: str | None = None) -> int:
103
+ """Diagnostic: count waiters for one project (or total across all projects)."""
104
+ async with self._lock:
105
+ if project is not None:
106
+ return len(self._waiters.get(project, []))
107
+ return sum(len(bucket) for bucket in self._waiters.values())
108
+
109
+
110
+ CHANGE_QUEUE: ChangeQueue = ChangeQueue()