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
|
@@ -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))
|