1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from pygitweb.auth import authenticate_http_key, authenticate_local_user
from pygitweb.auth_config import AuthConfig, LocalUser
from pygitweb.config import Settings, settings
from pygitweb.conftest import set_client_session
from pygitweb.main import app
from pygitweb.password_hash import is_password_hash, verify_password
from pygitweb.sessions import create_session
_SALT = "0123456789abcdef"
def _config() -> AuthConfig:
return AuthConfig(
auth_mode="local",
salt=_SALT,
local_users=[LocalUser(user="admin", password="secret", email="admin@example.com")],
)
def test_api_key_settings_default_to_two_keys_without_expiry() -> None:
cfg = Settings(AUTH=False)
assert cfg.API_KEY_MAX_COUNT == 2
assert cfg.API_KEY_EXPIRY_DAYS == 0
with pytest.raises(ValidationError):
Settings(AUTH=False, API_KEY_MAX_COUNT=-1)
def test_issue_api_key_returns_plaintext_and_stores_hash(tmp_path: Path) -> None:
cfg = _config()
path = tmp_path / "auth.json"
client = TestClient(app)
with (
patch.object(settings, "AUTH", True),
patch.object(settings, "AUTH_CONFIG", str(path)),
patch("pygitweb.auth_config.auth_config", cfg),
patch("pygitweb.auth.auth_config", cfg),
patch("pygitweb.main.auth_config", cfg),
):
set_client_session(client, "admin")
page = client.get("/user")
assert page.status_code == 200
assert "API keys" in page.text
assert "Create key" in page.text
issued = client.post("/user/keys", json={"id": "laptop", "readonly": True})
assert issued.status_code == 200, issued.text
body = issued.json()
assert body["id"] == "laptop"
assert body["readonly"] is True
plaintext = body["secret"]
assert isinstance(plaintext, str)
assert not plaintext.startswith("scrypt$")
assert body["time_expiry"] is None
stored = cfg.local_users[0].keys[0]
assert stored.secret != plaintext
assert is_password_hash(stored.secret)
assert verify_password(plaintext, stored.secret, config_salt=_SALT)
on_disk = json.loads(path.read_text(encoding="utf-8"))
assert on_disk["local_users"][0]["keys"][0]["secret"] == stored.secret
assert plaintext not in path.read_text(encoding="utf-8")
matched = authenticate_http_key("admin", plaintext)
assert matched is not None
assert matched[1] is True
again = client.get("/user")
assert "laptop" in again.text
assert plaintext not in again.text
def test_issue_api_key_enforces_limit_expiry_and_duplicate_id(tmp_path: Path) -> None:
cfg = _config()
path = tmp_path / "auth.json"
client = TestClient(app)
with (
patch.object(settings, "AUTH", True),
patch.object(settings, "AUTH_CONFIG", str(path)),
patch.object(settings, "API_KEY_MAX_COUNT", 2),
patch.object(settings, "API_KEY_EXPIRY_DAYS", 7),
patch("pygitweb.auth_config.auth_config", cfg),
patch("pygitweb.auth.auth_config", cfg),
patch("pygitweb.main.auth_config", cfg),
):
set_client_session(client, "admin")
before = datetime.now(UTC)
first = client.post("/user/keys", json={"id": "alpha-key"})
assert first.status_code == 200, first.text
duplicate = client.post("/user/keys", json={"id": "alpha-key"})
second = client.post("/user/keys", json={"id": "beta-key"})
assert second.status_code == 200, second.text
third = client.post("/user/keys", json={"id": "gamma-key"})
assert duplicate.status_code == 409
assert third.status_code == 409
assert len(cfg.local_users[0].keys) == 2
expiry = datetime.fromisoformat(first.json()["time_expiry"])
assert before + timedelta(days=7) <= expiry <= datetime.now(UTC) + timedelta(days=7)
assert second.json()["time_expiry"] is not None
revoked = client.delete("/user/keys", params={"id": "alpha-key"})
assert revoked.status_code == 204
assert [key.id for key in cfg.local_users[0].keys] == ["beta-key"]
page = client.get("/user")
assert "alpha-key" not in page.text
assert "beta-key" in page.text
replaced = client.post("/user/keys", json={"id": "alpha-key"})
assert replaced.status_code == 200
assert replaced.json()["id"] == "alpha-key"
def test_issue_api_key_requires_a_local_account_and_a_session(tmp_path: Path) -> None:
cfg = _config()
path = tmp_path / "auth.json"
client = TestClient(app)
with (
patch.object(settings, "AUTH", True),
patch.object(settings, "AUTH_CONFIG", str(path)),
patch("pygitweb.auth_config.auth_config", cfg),
patch("pygitweb.auth.auth_config", cfg),
patch("pygitweb.main.auth_config", cfg),
):
missing = client.post("/user/keys", json={"id": "laptop"})
assert missing.status_code == 401
token = create_session("nobody", auth_method="local")
unknown = client.post(
"/user/keys",
json={"id": "laptop"},
headers={"Authorization": f"Bearer {token}"},
)
assert unknown.status_code == 404
page = client.get("/user", headers={"Authorization": f"Bearer {token}"})
assert "cannot be stored" in page.text
email_token = create_session("other-name", email="Admin@Example.com", auth_method="oauth")
by_email = client.post(
"/user/keys",
json={"id": "mail-key"},
headers={"Authorization": f"Bearer {email_token}"},
)
assert by_email.status_code == 200, by_email.text
assert len(cfg.local_users) == 1
assert cfg.local_users[0].keys[0].id == "mail-key"
missing_key = client.delete(
"/user/keys", params={"id": "missing"}, headers={"Authorization": f"Bearer {email_token}"}
)
assert missing_key.status_code == 404
revoke_unknown = client.delete(
"/user/keys", params={"id": "mail-key"}, headers={"Authorization": f"Bearer {token}"}
)
assert revoke_unknown.status_code == 404
with patch.object(settings, "AUTH", False):
disabled = client.post("/user/keys", json={"id": "laptop"})
assert disabled.status_code == 400
disabled_revoke = client.delete("/user/keys", params={"id": "laptop"})
assert disabled_revoke.status_code == 400
def test_issue_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
cfg = _config()
path = tmp_path / "auth.json"
client = TestClient(app, raise_server_exceptions=False)
with (
patch.object(settings, "AUTH", True),
patch.object(settings, "AUTH_CONFIG", str(path)),
patch("pygitweb.auth_config.auth_config", cfg),
patch("pygitweb.auth.auth_config", cfg),
patch("pygitweb.main.auth_config", cfg),
patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
):
set_client_session(client, "admin")
response = client.post("/user/keys", json={"id": "laptop"})
assert response.status_code == 500
assert cfg.local_users[0].keys == []
def test_revoke_api_key_keeps_memory_unchanged_when_save_fails(tmp_path: Path) -> None:
cfg = _config()
path = tmp_path / "auth.json"
client = TestClient(app, raise_server_exceptions=False)
with (
patch.object(settings, "AUTH", True),
patch.object(settings, "AUTH_CONFIG", str(path)),
patch("pygitweb.auth_config.auth_config", cfg),
patch("pygitweb.auth.auth_config", cfg),
patch("pygitweb.main.auth_config", cfg),
):
set_client_session(client, "admin")
issued = client.post("/user/keys", json={"id": "laptop"})
assert issued.status_code == 200, issued.text
with patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")):
response = client.delete("/user/keys", params={"id": "laptop"})
assert response.status_code == 500
assert [key.id for key in cfg.local_users[0].keys] == ["laptop"]
def test_oauth_session_issues_a_passwordless_local_user(tmp_path: Path) -> None:
cfg = _config()
path = tmp_path / "auth.json"
client = TestClient(app)
with (
patch.object(settings, "AUTH", True),
patch.object(settings, "AUTH_CONFIG", str(path)),
patch("pygitweb.auth_config.auth_config", cfg),
patch("pygitweb.auth.auth_config", cfg),
patch("pygitweb.main.auth_config", cfg),
):
token = create_session("admin", email="bob@example.com", auth_method="oauth")
headers = {"Authorization": f"Bearer {token}"}
page = client.get("/user", headers=headers)
assert page.status_code == 200
assert "Create key" in page.text
assert "Smart HTTP username for these keys: <code>bob@example.com</code>" in page.text
assert "cannot be stored" not in page.text
assert cfg.local_users[0].keys == []
issued = client.post("/user/keys", json={"id": "laptop", "readonly": True}, headers=headers)
assert issued.status_code == 200, issued.text
plaintext = issued.json()["secret"]
assert len(cfg.local_users) == 2
created = cfg.local_users[1]
assert created.user == "bob@example.com"
assert created.email == "bob@example.com"
assert created.password is None
assert created.pass_hash == ""
assert created.assume_anonymous is False
assert [key.id for key in cfg.local_users[0].keys] == []
assert created.keys[0].readonly is True
assert verify_password(plaintext, created.keys[0].secret, config_salt=_SALT)
on_disk = json.loads(path.read_text(encoding="utf-8"))
stored = on_disk["local_users"][1]
assert "pass" not in stored
assert stored["pass_hash"] == ""
assert plaintext not in path.read_text(encoding="utf-8")
assert authenticate_local_user("bob@example.com", plaintext) is None
matched = authenticate_http_key("bob@example.com", plaintext)
assert matched is not None
assert matched[0].email == "bob@example.com"
assert matched[1] is True
assert authenticate_http_key("BOB@example.com", plaintext) == matched
assert authenticate_http_key("admin", plaintext) is None
again = client.post("/user/keys", json={"id": "phone"}, headers=headers)
assert again.status_code == 200, again.text
assert len(cfg.local_users) == 2
assert [key.id for key in created.keys] == ["laptop", "phone"]
revoked = client.delete("/user/keys", params={"id": "laptop"}, headers=headers)
assert revoked.status_code == 204
assert [key.id for key in created.keys] == ["phone"]
no_email = create_session("admin", auth_method="oauth")
missing = client.post(
"/user/keys",
json={"id": "laptop"},
headers={"Authorization": f"Bearer {no_email}"},
)
assert missing.status_code == 404
assert missing.json()["detail"] == "An email is required to store API keys"
blocked = client.get("/user", headers={"Authorization": f"Bearer {no_email}"})
assert "cannot be stored" in blocked.text
def test_oauth_key_name_collision_does_not_attach_to_the_other_account(tmp_path: Path) -> None:
cfg = AuthConfig(
auth_mode="local",
salt=_SALT,
local_users=[LocalUser(user="taken@example.com", password="secret", email="other@example.com")],
)
path = tmp_path / "auth.json"
client = TestClient(app)
with (
patch.object(settings, "AUTH", True),
patch.object(settings, "AUTH_CONFIG", str(path)),
patch("pygitweb.auth_config.auth_config", cfg),
patch("pygitweb.auth.auth_config", cfg),
patch("pygitweb.main.auth_config", cfg),
):
token = create_session("taken@example.com", email="taken@example.com", auth_method="oauth")
response = client.post(
"/user/keys",
json={"id": "laptop"},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 409
assert response.json()["detail"] == "A local account already uses this name"
assert len(cfg.local_users) == 1
assert cfg.local_users[0].keys == []
def test_oauth_key_user_is_removed_when_save_fails(tmp_path: Path) -> None:
cfg = _config()
path = tmp_path / "auth.json"
client = TestClient(app, raise_server_exceptions=False)
with (
patch.object(settings, "AUTH", True),
patch.object(settings, "AUTH_CONFIG", str(path)),
patch("pygitweb.auth_config.auth_config", cfg),
patch("pygitweb.auth.auth_config", cfg),
patch("pygitweb.main.auth_config", cfg),
patch("pygitweb.auth.write_auth_config", side_effect=OSError("disk")),
):
token = create_session("admin", email="bob@example.com", auth_method="oauth")
response = client.post(
"/user/keys",
json={"id": "laptop"},
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 500
assert [user.user for user in cfg.local_users] == ["admin"]
assert cfg.local_users[0].keys == []