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
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
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 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"]