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,230 @@
1
+ """Merge request tag view helpers and fast-forward merge endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+ from urllib.parse import quote
8
+
9
+ import pygit2
10
+ from fastapi import APIRouter, Depends, Form, HTTPException, Query
11
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
12
+
13
+ from pygittools.merge import MergeRequest, MergeRequestStatus, get_merge_request_by_oid
14
+ from pygitweb.auth import require_permission
15
+ from pygitweb.config import settings
16
+ from pygitweb.dependencies import ValidatedFormProject, ValidatedQueryProject
17
+ from pygitweb.git_helpers import open_repo
18
+ from pygitweb.permissions import Permission
19
+ from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env, jinja_escape
20
+ from pygitweb.validation import is_valid_ref_format
21
+
22
+
23
+ def _branch_short_name(ref: str) -> str:
24
+ return ref.removeprefix("refs/heads/") if ref.startswith("refs/heads/") else ref
25
+
26
+
27
+ merge_router = APIRouter(tags=["merge_requests"])
28
+
29
+ _STATUS_LABELS: dict[MergeRequestStatus, str] = {
30
+ MergeRequestStatus.CAN_FAST_FORWARD: "Can fast-forward",
31
+ MergeRequestStatus.UP_TO_DATE: "Already up to date",
32
+ MergeRequestStatus.BLOCKED_DIVERGED: "Blocked: histories diverged (not fast-forward)",
33
+ MergeRequestStatus.OURS_REF_MISSING: "Blocked: ours ref is missing",
34
+ MergeRequestStatus.MR_HEAD_MISSING: "Blocked: merge request head is missing from the repository",
35
+ }
36
+
37
+
38
+ def try_merge_request_from_tag(repo: pygit2.Repository, tag: pygit2.Tag) -> MergeRequest | None:
39
+ try:
40
+ return get_merge_request_by_oid(repo, tag.id)
41
+ except (ValueError, json.JSONDecodeError, TypeError, KeyError):
42
+ return None
43
+
44
+
45
+ def resolve_merge_request_tag_to_tip(
46
+ repo: pygit2.Repository, obj: pygit2.Tag
47
+ ) -> tuple[pygit2.Tag, MergeRequest] | None:
48
+ """If ``obj`` is an MR tag and ``refs/tags/mr/…`` points to a newer tag object, return that tip."""
49
+ mr = try_merge_request_from_tag(repo, obj)
50
+ if mr is None:
51
+ return None
52
+ if mr.name not in repo.references:
53
+ return obj, mr
54
+ tip_id = repo.references[mr.name].resolve().target
55
+ if tip_id == obj.id:
56
+ return obj, mr
57
+ tip_obj = repo[tip_id]
58
+ if not isinstance(tip_obj, pygit2.Tag):
59
+ return obj, mr
60
+ mr_tip = try_merge_request_from_tag(repo, tip_obj)
61
+ if mr_tip is None:
62
+ return obj, mr
63
+ return tip_obj, mr_tip
64
+
65
+
66
+ def merge_request_status_fields(repo: pygit2.Repository, mr: MergeRequest) -> dict[str, Any]:
67
+ try:
68
+ status = mr.check_status(repo)
69
+ except ValueError as exc:
70
+ return {
71
+ "status_code": "error",
72
+ "status_label": "Unable to analyze merge",
73
+ "status_detail": str(exc),
74
+ "show_merge_button": False,
75
+ }
76
+ label = _STATUS_LABELS.get(status, status.value)
77
+ return {
78
+ "status_code": status.value,
79
+ "status_label": label,
80
+ "status_detail": "",
81
+ "show_merge_button": status == MergeRequestStatus.CAN_FAST_FORWARD,
82
+ }
83
+
84
+
85
+ def _head_view_url(project: str, repo: pygit2.Repository, head_hex: str) -> tuple[str, str]:
86
+ try:
87
+ oid = pygit2.Oid(hex=head_hex)
88
+ obj = repo[oid]
89
+ except (KeyError, ValueError, TypeError):
90
+ return head_hex[:7], ""
91
+ t = getattr(obj, "type_str", "") or ""
92
+ short = head_hex[:7]
93
+ if t == "commit":
94
+ return short, f"/project/{project}?a=commit&h={quote(head_hex, safe='')}"
95
+ if t == "tag":
96
+ return short, f"/project/{project}?a=tag&h={quote(head_hex, safe='')}"
97
+ return short, ""
98
+
99
+
100
+ def merge_request_tag_response(
101
+ project: str,
102
+ repo: pygit2.Repository,
103
+ mr: MergeRequest,
104
+ tag_name: str,
105
+ tag_oid: str,
106
+ tagger: str,
107
+ tagger_date: str,
108
+ ) -> Response:
109
+ new_oid = mr.refresh_from_theirs(repo)
110
+ if new_oid is not None:
111
+ return RedirectResponse(
112
+ url=f"/project/{project}?a=tag&h={quote(str(new_oid), safe='')}",
113
+ status_code=302,
114
+ )
115
+
116
+ status_fields = merge_request_status_fields(repo, mr)
117
+ head_short, head_url = _head_view_url(project, repo, str(mr.target))
118
+ ours_ref = mr.ours
119
+ ours_url = f"/project/{project}?a=log&h={quote(ours_ref, safe='')}"
120
+ theirs_ref = mr.theirs or ""
121
+ theirs_url = f"/project/{project}?a=log&h={quote(theirs_ref, safe='')}" if mr.theirs else ""
122
+
123
+ comments_rows: list[tuple[str, str]] = []
124
+ for c in mr.comments:
125
+ ch = str(c)
126
+ comments_rows.append((ch[:7], f"/project/{project}?a=tag&h={quote(ch, safe='')}"))
127
+
128
+ current_tag_oid = str(repo.references[mr.name].resolve().target)
129
+
130
+ ctx = {
131
+ "project": project,
132
+ "project_url": f"/project/{project}",
133
+ "tag_name": tag_name,
134
+ "tag_oid": current_tag_oid,
135
+ "tag_view_url": f"/project/{project}?a=tag&h={quote(current_tag_oid, safe='')}",
136
+ "mr_title": mr.title,
137
+ "mr_description": mr.description or "",
138
+ "ours_ref": ours_ref,
139
+ "ours_url": ours_url,
140
+ "theirs_ref": theirs_ref,
141
+ "theirs_url": theirs_url,
142
+ "head_short": head_short,
143
+ "head_url": head_url,
144
+ "head_full": str(mr.target),
145
+ "tagger": tagger,
146
+ "tagger_date": tagger_date,
147
+ "opener": str(mr.opener),
148
+ "created_at": mr.created_at.isoformat() if mr.created_at else "",
149
+ "updated_at": mr.updated_at.isoformat() if mr.updated_at else "",
150
+ "comments": comments_rows,
151
+ "merge_action_url": f"/mr/ff?project={quote(project, safe='')}&h={quote(current_tag_oid, safe='')}",
152
+ **status_fields,
153
+ }
154
+ title = f"Merge request: {jinja_escape(mr.title) or ''} — {jinja_escape(project) or ''}"
155
+ pre = PREAMBLE.render(title=title, site_name=settings.SITE_NAME)
156
+ body = env.get_template("merge_request_tag.html").render(**ctx)
157
+ return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
158
+
159
+
160
+ @merge_router.post(
161
+ "/create",
162
+ dependencies=[Depends(require_permission(Permission.MR_CREATE, project_from_form=True))],
163
+ )
164
+ def merge_request_create(
165
+ project: ValidatedFormProject,
166
+ theirs: str = Form(...),
167
+ ours: str = Form(...),
168
+ title: str = Form(""),
169
+ ) -> RedirectResponse:
170
+ theirs_ref = theirs.strip()
171
+ ours_ref = ours.strip()
172
+ if not theirs_ref or not ours_ref:
173
+ raise HTTPException(status_code=400, detail="theirs and ours are required")
174
+ if not is_valid_ref_format(theirs_ref) or not is_valid_ref_format(ours_ref):
175
+ raise HTTPException(status_code=400, detail="Invalid ref format")
176
+ repo = open_repo(project)
177
+ try:
178
+ theirs_tip = repo.revparse_single(theirs_ref).peel(pygit2.Commit).id
179
+ except (KeyError, ValueError, pygit2.GitError) as exc:
180
+ raise HTTPException(status_code=404, detail=f"Cannot resolve theirs ref {theirs_ref!r}") from exc
181
+ try:
182
+ repo.revparse_single(ours_ref)
183
+ except (KeyError, ValueError, pygit2.GitError) as exc:
184
+ raise HTTPException(status_code=404, detail=f"Cannot resolve ours ref {ours_ref!r}") from exc
185
+ opener = repo.default_signature
186
+ t = title.strip()
187
+ if not t:
188
+ t = f"Merge {_branch_short_name(theirs_ref)} into {_branch_short_name(ours_ref)}"
189
+ mr = MergeRequest(
190
+ target=theirs_tip,
191
+ opener=opener,
192
+ ours=ours_ref,
193
+ theirs=theirs_ref,
194
+ title=t,
195
+ description="",
196
+ )
197
+ oid = mr.write(repo)
198
+ dest = f"/project/{project}?a=tag&h={quote(str(oid), safe='')}"
199
+ return RedirectResponse(url=dest, status_code=303)
200
+
201
+
202
+ @merge_router.post(
203
+ "/ff",
204
+ dependencies=[Depends(require_permission(Permission.MR_MERGE, project_from_query=True))],
205
+ )
206
+ def merge_fast_forward(
207
+ project: ValidatedQueryProject,
208
+ h: str = Query(..., description="Annotated tag object id (hex)"),
209
+ ) -> RedirectResponse:
210
+ if not h or not is_valid_ref_format(h):
211
+ raise HTTPException(status_code=400, detail="Invalid tag hash (h)")
212
+ repo = open_repo(project)
213
+ try:
214
+ obj = repo.revparse_single(h)
215
+ except (KeyError, pygit2.GitError, OSError) as exc:
216
+ raise HTTPException(status_code=404, detail="Tag not found") from exc
217
+ if not isinstance(obj, pygit2.Tag):
218
+ raise HTTPException(status_code=400, detail="Not an annotated tag")
219
+ resolved = resolve_merge_request_tag_to_tip(repo, obj)
220
+ if resolved is None:
221
+ raise HTTPException(status_code=400, detail="Not a merge request tag")
222
+ obj, mr = resolved
223
+ mr.refresh_from_theirs(repo)
224
+ try:
225
+ mr.merge(repo)
226
+ except ValueError as exc:
227
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
228
+ tag_oid = str(repo.references[mr.name].resolve().target)
229
+ dest = f"/project/{project}?a=tag&h={quote(tag_oid, safe='')}"
230
+ return RedirectResponse(url=dest, status_code=303)
@@ -0,0 +1,85 @@
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 pygittools.merge import MR_REF_PREFIX, MergeRequest
12
+ from pygitweb.config import settings
13
+ from pygitweb.main import app
14
+
15
+
16
+ def _commit_chain(repo: pygit2.Repository) -> tuple[pygit2.Oid, pygit2.Oid]:
17
+ sig = pygit2.Signature("tester", "tester@example.com")
18
+ tb = repo.TreeBuilder()
19
+ tree = tb.write()
20
+ a = repo.create_commit(None, sig, sig, "a", tree, [])
21
+ repo.create_reference("refs/heads/main", a)
22
+ b = repo.create_commit(None, sig, sig, "b", tree, [a])
23
+ return a, b
24
+
25
+
26
+ class TestMergeRequestTagView:
27
+ @pytest.fixture
28
+ def mr_env(self, tmp_path: Path) -> Generator[dict[str, str], None, None]:
29
+ root: Path = tmp_path / "merge-requests-web"
30
+ root.mkdir()
31
+ repo_dir: Path = root / "demo"
32
+ repo_dir.mkdir()
33
+ repo = pygit2.init_repository(str(repo_dir), bare=False)
34
+ _a, b = _commit_chain(repo)
35
+ mr = MergeRequest(
36
+ b,
37
+ repo.default_signature,
38
+ ours="refs/heads/main",
39
+ title="Feature MR",
40
+ description="Desc line",
41
+ name=f"{MR_REF_PREFIX}aaaabbbbccccdddd",
42
+ )
43
+ mr_oid = mr.write(repo)
44
+ projects_list: Path = root / "projects.list"
45
+ projects_list.write_text("demo tester\n", encoding="utf-8")
46
+ with (
47
+ patch.object(settings, "PROJECTROOT", str(root)),
48
+ patch.object(settings, "PROJECTS_LIST", str(projects_list)),
49
+ patch.object(settings, "STRICT_EXPORT", False),
50
+ patch.object(settings, "EXPORT_OK", ""),
51
+ patch.object(settings, "AUTH", False),
52
+ ):
53
+ client = TestClient(app)
54
+ yield {
55
+ "client": client,
56
+ "project": "demo",
57
+ "mr_tag_oid": str(mr_oid),
58
+ "mr_ref": mr.name,
59
+ "tip_b": str(b),
60
+ }
61
+
62
+ def test_tag_action_renders_merge_request_template(self, mr_env: dict[str, str]) -> None:
63
+ client = mr_env["client"]
64
+ resp = client.get(
65
+ f"/project/{mr_env['project']}",
66
+ params={"a": "tag", "h": mr_env["mr_tag_oid"]},
67
+ )
68
+ assert resp.status_code == 200
69
+ text = resp.text
70
+ assert "Merge request" in text
71
+ assert "Feature MR" in text
72
+ assert "Desc line" in text
73
+ assert "Can fast-forward" in text
74
+ assert "refs/heads/main" in text
75
+
76
+ def test_merge_ff_updates_branch(self, mr_env: dict[str, str]) -> None:
77
+ client = mr_env["client"]
78
+ post = client.post(
79
+ "/mr/ff",
80
+ params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
81
+ follow_redirects=False,
82
+ )
83
+ assert post.status_code == 303
84
+ repo = pygit2.Repository(str(Path(settings.PROJECTROOT) / mr_env["project"]))
85
+ assert repo.references["refs/heads/main"].resolve().target == pygit2.Oid(hex=mr_env["tip_b"])
@@ -0,0 +1,148 @@
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 pygittools.merge import MR_REF_PREFIX, MergeRequest
13
+ from pygitweb.auth_config import AuthConfig, LocalUser
14
+ from pygitweb.config import settings
15
+ from pygitweb.conftest import client_as_user
16
+ from pygitweb.merge_requests import merge_router
17
+ from pygitweb.permissions import Permission, PermissionsMap, permission_key
18
+
19
+
20
+ def _commit_chain(repo: pygit2.Repository) -> tuple[pygit2.Oid, pygit2.Oid]:
21
+ sig = pygit2.Signature("tester", "tester@example.com")
22
+ tb = repo.TreeBuilder()
23
+ tree = tb.write()
24
+ a = repo.create_commit(None, sig, sig, "a", tree, [])
25
+ repo.create_reference("refs/heads/main", a)
26
+ b = repo.create_commit(None, sig, sig, "b", tree, [a])
27
+ return a, b
28
+
29
+
30
+ _MR_AUTH = AuthConfig(
31
+ auth_mode="local",
32
+ local_users=[LocalUser(user="creator", password="secret"), LocalUser(user="merger", password="secret")],
33
+ oauth_permissions=PermissionsMap.model_validate({
34
+ "*": [],
35
+ permission_key(Permission.MR_CREATE, "demo"): ["creator"],
36
+ permission_key(Permission.MR_MERGE, "demo"): ["merger"],
37
+ }),
38
+ )
39
+
40
+
41
+ def _build_client() -> TestClient:
42
+ app = FastAPI()
43
+ app.include_router(merge_router, prefix="/mr")
44
+ return TestClient(app)
45
+
46
+
47
+ @pytest.fixture
48
+ def mr_env(tmp_path: Path) -> Generator[dict[str, str], None, None]:
49
+ root = tmp_path / "mr-perms"
50
+ root.mkdir()
51
+ repo_dir = root / "demo"
52
+ repo_dir.mkdir()
53
+ repo = pygit2.init_repository(str(repo_dir), bare=False)
54
+ _a, b = _commit_chain(repo)
55
+ mr = MergeRequest(
56
+ b,
57
+ repo.default_signature,
58
+ ours="refs/heads/main",
59
+ title="Feature MR",
60
+ name=f"{MR_REF_PREFIX}aaaabbbbccccdddd",
61
+ )
62
+ mr_oid = mr.write(repo)
63
+ projects_list = root / "projects.list"
64
+ projects_list.write_text("demo tester\n", encoding="utf-8")
65
+ with (
66
+ patch.object(settings, "PROJECTROOT", str(root)),
67
+ patch.object(settings, "PROJECTS_LIST", str(projects_list)),
68
+ patch.object(settings, "STRICT_EXPORT", False),
69
+ patch.object(settings, "EXPORT_OK", ""),
70
+ patch("pygitweb.auth_config.auth_config", _MR_AUTH),
71
+ patch("pygitweb.auth.auth_config", _MR_AUTH),
72
+ ):
73
+ yield {
74
+ "client": _build_client(),
75
+ "project": "demo",
76
+ "mr_tag_oid": str(mr_oid),
77
+ "tip_b": str(b),
78
+ }
79
+
80
+
81
+ def test_mr_ff_open_when_auth_disabled(mr_env: dict[str, str]) -> None:
82
+ client = mr_env["client"]
83
+ assert isinstance(client, TestClient)
84
+ with patch.object(settings, "AUTH", False):
85
+ r = client.post(
86
+ "/mr/ff",
87
+ params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
88
+ follow_redirects=False,
89
+ )
90
+ assert r.status_code == 303
91
+
92
+
93
+ def test_mr_create_401_without_login(mr_env: dict[str, str]) -> None:
94
+ client = mr_env["client"]
95
+ assert isinstance(client, TestClient)
96
+ with patch.object(settings, "AUTH", True):
97
+ r = client.post(
98
+ "/mr/create",
99
+ data={
100
+ "project": mr_env["project"],
101
+ "theirs": "refs/heads/main",
102
+ "ours": "refs/heads/main",
103
+ "title": "MR",
104
+ },
105
+ follow_redirects=False,
106
+ )
107
+ assert r.status_code == 401
108
+
109
+
110
+ def test_mr_create_forbidden_without_create_grant(mr_env: dict[str, str]) -> None:
111
+ client = mr_env["client"]
112
+ assert isinstance(client, TestClient)
113
+ with patch.object(settings, "AUTH", True), client_as_user(client, "merger"):
114
+ r = client.post(
115
+ "/mr/create",
116
+ data={
117
+ "project": mr_env["project"],
118
+ "theirs": "refs/heads/main",
119
+ "ours": "refs/heads/main",
120
+ "title": "MR",
121
+ },
122
+ follow_redirects=False,
123
+ )
124
+ assert r.status_code == 403
125
+
126
+
127
+ def test_mr_ff_forbidden_without_merge_grant(mr_env: dict[str, str]) -> None:
128
+ client = mr_env["client"]
129
+ assert isinstance(client, TestClient)
130
+ with patch.object(settings, "AUTH", True), client_as_user(client, "creator"):
131
+ r = client.post(
132
+ "/mr/ff",
133
+ params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
134
+ follow_redirects=False,
135
+ )
136
+ assert r.status_code == 403
137
+
138
+
139
+ def test_mr_ff_allowed_with_merge_grant(mr_env: dict[str, str]) -> None:
140
+ client = mr_env["client"]
141
+ assert isinstance(client, TestClient)
142
+ with patch.object(settings, "AUTH", True), client_as_user(client, "merger"):
143
+ r = client.post(
144
+ "/mr/ff",
145
+ params={"project": mr_env["project"], "h": mr_env["mr_tag_oid"]},
146
+ follow_redirects=False,
147
+ )
148
+ assert r.status_code == 303
@@ -0,0 +1,74 @@
1
+ """Password hashing with stdlib scrypt (Argon2id is not used)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import secrets
9
+
10
+ _SCRYPT_N = 2**17
11
+ _SCRYPT_R = 8
12
+ _SCRYPT_P = 1
13
+ _SCRYPT_DKLEN = 64
14
+ _SCRYPT_SALT_BYTES = 16
15
+ _SCRYPT_MAXMEM = 128 * _SCRYPT_N * _SCRYPT_R * (_SCRYPT_P + 2)
16
+ _ALGORITHM = "scrypt"
17
+
18
+
19
+ def _password_material(password: str, config_salt: str) -> str:
20
+ if not config_salt:
21
+ return password
22
+ return f"{config_salt}:{password}"
23
+
24
+
25
+ def _scrypt_key(password: str, *, salt: bytes, n: int, r: int, p: int, dklen: int) -> bytes:
26
+ return hashlib.scrypt(
27
+ password.encode("utf-8"),
28
+ salt=salt,
29
+ n=n,
30
+ r=r,
31
+ p=p,
32
+ maxmem=max(128 * n * r * (p + 2), _SCRYPT_MAXMEM),
33
+ dklen=dklen,
34
+ )
35
+
36
+
37
+ def hash_password(password: str, *, config_salt: str = "") -> str:
38
+ material = _password_material(password, config_salt)
39
+ salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
40
+ key = _scrypt_key(material, salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN)
41
+ salt_b64 = base64.b64encode(salt).decode("ascii")
42
+ key_b64 = base64.b64encode(key).decode("ascii")
43
+ return f"{_ALGORITHM}${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt_b64}${key_b64}"
44
+
45
+
46
+ def verify_password(password: str, pass_hash: str, *, config_salt: str = "") -> bool:
47
+ if not pass_hash:
48
+ return False
49
+ if config_salt and _verify_password_material(_password_material(password, config_salt), pass_hash):
50
+ return True
51
+ return _verify_password_material(password, pass_hash)
52
+
53
+
54
+ def _verify_password_material(material: str, pass_hash: str) -> bool:
55
+ if not pass_hash:
56
+ return False
57
+ parts = pass_hash.split("$")
58
+ if len(parts) != 6 or parts[0] != _ALGORITHM:
59
+ return False
60
+ try:
61
+ n = int(parts[1])
62
+ r = int(parts[2])
63
+ p = int(parts[3])
64
+ if n < _SCRYPT_N or r < _SCRYPT_R or p < _SCRYPT_P:
65
+ return False
66
+ salt = base64.b64decode(parts[4], validate=True)
67
+ expected = base64.b64decode(parts[5], validate=True)
68
+ except (ValueError, TypeError):
69
+ return False
70
+ try:
71
+ actual = _scrypt_key(material, salt=salt, n=n, r=r, p=p, dklen=len(expected))
72
+ except ValueError:
73
+ return False
74
+ return hmac.compare_digest(actual, expected)
@@ -0,0 +1,20 @@
1
+ from pygitweb.password_hash import hash_password, verify_password
2
+
3
+
4
+ def test_hash_and_verify_roundtrip() -> None:
5
+ stored = hash_password("secret-password", config_salt="a1b2c3d4e5f67890")
6
+ assert stored.startswith("scrypt$131072$8$1$")
7
+ assert verify_password("secret-password", stored, config_salt="a1b2c3d4e5f67890")
8
+ assert not verify_password("wrong", stored, config_salt="a1b2c3d4e5f67890")
9
+
10
+
11
+ def test_verify_legacy_hash_without_config_salt() -> None:
12
+ stored = hash_password("legacy")
13
+ assert verify_password("legacy", stored, config_salt="0123456789abcdef")
14
+
15
+
16
+ def test_verify_rejects_weak_parameters() -> None:
17
+ stored = hash_password("x")
18
+ parts = stored.split("$")
19
+ parts[1] = "65536"
20
+ assert not verify_password("x", "$".join(parts))