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.
- pygitweb/__init__.py +22 -0
- pygitweb/__meta__.py +9 -0
- pygitweb/actions.py +1303 -0
- pygitweb/addproject_permissions_test.py +114 -0
- pygitweb/api/__init__.py +0 -0
- pygitweb/api/action.py +20 -0
- pygitweb/api/plugins/__init__.py +0 -0
- pygitweb/api/plugins/project_zip.py +71 -0
- pygitweb/api/project.py +67 -0
- pygitweb/api/subpage.py +22 -0
- pygitweb/auth.py +387 -0
- pygitweb/auth.schema.json +106 -0
- pygitweb/auth_config.py +243 -0
- pygitweb/auth_oauth.py +343 -0
- pygitweb/auth_test.py +390 -0
- pygitweb/boards_permissions_test.py +113 -0
- pygitweb/change_queue.py +110 -0
- pygitweb/change_queue_routes_test.py +146 -0
- pygitweb/change_queue_test.py +91 -0
- pygitweb/comments_permissions_test.py +140 -0
- pygitweb/config.py +185 -0
- pygitweb/conftest.py +43 -0
- pygitweb/dependencies.py +87 -0
- pygitweb/formatting.py +182 -0
- pygitweb/formatting_test.py +68 -0
- pygitweb/git_helpers.py +454 -0
- pygitweb/git_helpers_test.py +223 -0
- pygitweb/gravatar.py +16 -0
- pygitweb/gravatar_test.py +10 -0
- pygitweb/hooks_install.py +278 -0
- pygitweb/hooks_install_test.py +363 -0
- pygitweb/hooks_permissions_test.py +116 -0
- pygitweb/main.py +887 -0
- pygitweb/merge_requests.py +230 -0
- pygitweb/merge_requests_test.py +85 -0
- pygitweb/mr_permissions_test.py +148 -0
- pygitweb/password_hash.py +74 -0
- pygitweb/password_hash_test.py +20 -0
- pygitweb/permissions.py +209 -0
- pygitweb/permissions_test.py +86 -0
- pygitweb/plugin_loader.py +83 -0
- pygitweb/projects.py +213 -0
- pygitweb/projects_test.py +186 -0
- pygitweb/sessions.py +73 -0
- pygitweb/settings.py +412 -0
- pygitweb/settings.schema.json +73 -0
- pygitweb/settings_config.py +122 -0
- pygitweb/settings_config_test.py +101 -0
- pygitweb/settings_permissions_test.py +76 -0
- pygitweb/static/blob-view.js +133 -0
- pygitweb/static/board.js +839 -0
- pygitweb/static/hook-install.js +53 -0
- pygitweb/static/icons/cloud-down.svg +1 -0
- pygitweb/static/icons/cloud-exclamation.svg +1 -0
- pygitweb/static/icons/cloud-filled.svg +1 -0
- pygitweb/static/icons/cloud-storm.svg +1 -0
- pygitweb/static/icons/cloud-up.svg +1 -0
- pygitweb/static/icons/cloud.svg +1 -0
- pygitweb/static/logo.svg +44 -0
- pygitweb/static/main.css +1210 -0
- pygitweb/static/navbar-auth.js +82 -0
- pygitweb/static/readme-render.js +174 -0
- pygitweb/static/search-page.js +139 -0
- pygitweb/static/summary-ref-switcher.js +130 -0
- pygitweb/static/themes/solarized.css +34 -0
- pygitweb/tasks.py +595 -0
- pygitweb/tasks_permissions_test.py +137 -0
- pygitweb/tasks_routes_test.py +430 -0
- pygitweb/templates/addproject.html +174 -0
- pygitweb/templates/blobdiff.html +57 -0
- pygitweb/templates/board.html +31 -0
- pygitweb/templates/board_card.html +44 -0
- pygitweb/templates/commitdiff.html +57 -0
- pygitweb/templates/hooks_summary_cell.html +37 -0
- pygitweb/templates/login.html +37 -0
- pygitweb/templates/merge_request_summary_cell.html +18 -0
- pygitweb/templates/merge_request_tag.html +90 -0
- pygitweb/templates/pagination.html +44 -0
- pygitweb/templates/preamble.html +89 -0
- pygitweb/templates/project_settings.html +13 -0
- pygitweb/templates/pygit2_settings.html +12 -0
- pygitweb/templates/pygitweb_settings.html +12 -0
- pygitweb/templates/search.html +77 -0
- pygitweb/templates/settings_form_field.html +27 -0
- pygitweb/templates/table.html +15 -0
- pygitweb/templates/user_profile.html +10 -0
- pygitweb/templates_env.py +21 -0
- pygitweb/timeline_cache.py +243 -0
- pygitweb/validation.py +75 -0
- pygitweb/validation_test.py +167 -0
- pygitweb-0.1.0.dist-info/METADATA +165 -0
- pygitweb-0.1.0.dist-info/RECORD +95 -0
- pygitweb-0.1.0.dist-info/WHEEL +5 -0
- pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
pygitweb/change_queue.py
ADDED
|
@@ -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()
|