devpi-admin 1.4.2__tar.gz → 1.4.4__tar.gz
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.
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/PKG-INFO +29 -1
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/README.md +28 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/_version.py +3 -3
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/main.py +84 -22
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/static/css/style.css +61 -2
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/static/js/app.js +171 -23
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin.egg-info/PKG-INFO +29 -1
- devpi_admin-1.4.4/tests/test_view_helpers.py +229 -0
- devpi_admin-1.4.2/tests/test_view_helpers.py +0 -79
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/.github/workflows/publish.yml +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/.github/workflows/tests.yml +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/.gitignore +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/LICENSE +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/__init__.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/customizer.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/static/favicon.svg +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/static/index.html +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/static/js/api.js +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/static/js/marked.min.js +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/static/js/theme.js +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin/tokens.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin.egg-info/SOURCES.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin.egg-info/dependency_links.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin.egg-info/entry_points.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin.egg-info/requires.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/devpi_admin.egg-info/top_level.txt +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/pyproject.toml +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/setup.cfg +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/__init__.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_acl_read.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_devpi_tokens_ui.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_filter.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_hooks.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_json_safe.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_package.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_pipconf.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_tokens.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_tween.py +0 -0
- {devpi_admin-1.4.2 → devpi_admin-1.4.4}/tests/test_wants_html.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devpi-admin
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
|
|
5
5
|
Author-email: Pavel Revak <pavelrevak@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,6 +61,10 @@ talks to the standard devpi JSON API directly.
|
|
|
61
61
|
- **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
|
|
62
62
|
sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
|
|
63
63
|
ones with the index pre-filled and locked
|
|
64
|
+
- **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
|
|
65
|
+
the in-memory per-project and project-names caches; the next `+simple/<project>/`
|
|
66
|
+
query (from pip, the UI, or `devpi-client`) goes back to upstream
|
|
67
|
+
(etag-conditional, cheap)
|
|
64
68
|
- **`Edit`** / **`Delete`** (owner / root)
|
|
65
69
|
- Create / edit / delete indexes via modal dialogs
|
|
66
70
|
- `bases` editor with drag & drop priority ordering and transitive inheritance display
|
|
@@ -597,6 +601,30 @@ Revoke a single token.
|
|
|
597
601
|
- **200:** `{"revoked": true, "id": "abc..."}`
|
|
598
602
|
- **404:** token id not found
|
|
599
603
|
|
|
604
|
+
### Mirror cache
|
|
605
|
+
|
|
606
|
+
#### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
|
|
607
|
+
Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
|
|
608
|
+
re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
|
|
609
|
+
re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
|
|
610
|
+
mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
|
|
611
|
+
queried). Two caches are expired:
|
|
612
|
+
|
|
613
|
+
- `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
|
|
614
|
+
project, in one pass)
|
|
615
|
+
- `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
|
|
616
|
+
projects" call)
|
|
617
|
+
|
|
618
|
+
Useful when waiting for a freshly-published upstream release that's still hidden
|
|
619
|
+
behind the `mirror_cache_expiry` TTL (default 30 min).
|
|
620
|
+
|
|
621
|
+
- **Auth:** required (any authenticated user)
|
|
622
|
+
- **Primary only:** replicas return 400 (caches are process-local; replicas sync the
|
|
623
|
+
persisted state via the changelog stream once the primary refetches)
|
|
624
|
+
- **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
|
|
625
|
+
- **400:** index is not a mirror, or the call hit a replica
|
|
626
|
+
- **404:** index doesn't exist
|
|
627
|
+
|
|
600
628
|
### Replication observability (primary only)
|
|
601
629
|
|
|
602
630
|
#### `GET /+admin-api/replicas`
|
|
@@ -37,6 +37,10 @@ talks to the standard devpi JSON API directly.
|
|
|
37
37
|
- **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
|
|
38
38
|
sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
|
|
39
39
|
ones with the index pre-filled and locked
|
|
40
|
+
- **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
|
|
41
|
+
the in-memory per-project and project-names caches; the next `+simple/<project>/`
|
|
42
|
+
query (from pip, the UI, or `devpi-client`) goes back to upstream
|
|
43
|
+
(etag-conditional, cheap)
|
|
40
44
|
- **`Edit`** / **`Delete`** (owner / root)
|
|
41
45
|
- Create / edit / delete indexes via modal dialogs
|
|
42
46
|
- `bases` editor with drag & drop priority ordering and transitive inheritance display
|
|
@@ -573,6 +577,30 @@ Revoke a single token.
|
|
|
573
577
|
- **200:** `{"revoked": true, "id": "abc..."}`
|
|
574
578
|
- **404:** token id not found
|
|
575
579
|
|
|
580
|
+
### Mirror cache
|
|
581
|
+
|
|
582
|
+
#### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
|
|
583
|
+
Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
|
|
584
|
+
re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
|
|
585
|
+
re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
|
|
586
|
+
mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
|
|
587
|
+
queried). Two caches are expired:
|
|
588
|
+
|
|
589
|
+
- `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
|
|
590
|
+
project, in one pass)
|
|
591
|
+
- `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
|
|
592
|
+
projects" call)
|
|
593
|
+
|
|
594
|
+
Useful when waiting for a freshly-published upstream release that's still hidden
|
|
595
|
+
behind the `mirror_cache_expiry` TTL (default 30 min).
|
|
596
|
+
|
|
597
|
+
- **Auth:** required (any authenticated user)
|
|
598
|
+
- **Primary only:** replicas return 400 (caches are process-local; replicas sync the
|
|
599
|
+
persisted state via the changelog stream once the primary refetches)
|
|
600
|
+
- **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
|
|
601
|
+
- **400:** index is not a mirror, or the call hit a replica
|
|
602
|
+
- **404:** index doesn't exist
|
|
603
|
+
|
|
576
604
|
### Replication observability (primary only)
|
|
577
605
|
|
|
578
606
|
#### `GET /+admin-api/replicas`
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.4.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 4,
|
|
21
|
+
__version__ = version = '1.4.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 4)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g312e747ee'
|
|
@@ -255,6 +255,16 @@ def devpiserver_pyramid_configure(config, pyramid_config):
|
|
|
255
255
|
_revoke_token_view, route_name="devpi_admin_token_revoke",
|
|
256
256
|
request_method="DELETE")
|
|
257
257
|
|
|
258
|
+
# Mirror cache refresh — invalidate per-project retrieve-times and
|
|
259
|
+
# the project-names list so the next pip request re-fetches upstream.
|
|
260
|
+
pyramid_config.add_route(
|
|
261
|
+
"devpi_admin_mirror_refresh_cache",
|
|
262
|
+
"/+admin-api/mirror/{user}/{index}/refresh-cache")
|
|
263
|
+
pyramid_config.add_view(
|
|
264
|
+
_refresh_mirror_cache_view,
|
|
265
|
+
route_name="devpi_admin_mirror_refresh_cache",
|
|
266
|
+
request_method="POST")
|
|
267
|
+
|
|
258
268
|
# Redirect browser visits to "/" to the SPA. Other routes (JSON API
|
|
259
269
|
# calls, CLI requests) pass through untouched because they send
|
|
260
270
|
# Accept: application/json.
|
|
@@ -372,32 +382,29 @@ def _versiondata_view(request):
|
|
|
372
382
|
return HTTPNotFound(json_body={"error": "version not found"})
|
|
373
383
|
# Convert to plain dict with JSON-safe types
|
|
374
384
|
result = _to_json_safe(verdata)
|
|
375
|
-
#
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
"Failed to get releaselinks for %s/%s/%s",
|
|
381
|
-
user, index, project, exc_info=True)
|
|
382
|
-
all_links = []
|
|
385
|
+
# Build file links from the verdata's own +elinks. Unlike
|
|
386
|
+
# get_releaselinks() — which reconstructs ELinks from simplelinks
|
|
387
|
+
# metadata and therefore has no "_log" — these carry the upload
|
|
388
|
+
# log entries (who/when). Mirror elinks have no "_log" at all;
|
|
389
|
+
# the "log" key is then simply absent.
|
|
383
390
|
result["+links"] = []
|
|
384
|
-
for
|
|
385
|
-
if
|
|
391
|
+
for linkdict in result.pop("+elinks", None) or []:
|
|
392
|
+
if linkdict.get("rel") != "releasefile":
|
|
386
393
|
continue
|
|
394
|
+
entrypath = linkdict.get("entrypath") or ""
|
|
395
|
+
hashes = linkdict.get("hashes") or {}
|
|
396
|
+
if hashes.get("sha256"):
|
|
397
|
+
hash_spec = "sha256=" + hashes["sha256"]
|
|
398
|
+
else:
|
|
399
|
+
hash_spec = linkdict.get("hash_spec") or ""
|
|
387
400
|
link_info = {
|
|
388
|
-
"href": "/" +
|
|
389
|
-
"basename":
|
|
390
|
-
"hash_spec":
|
|
401
|
+
"href": "/" + entrypath,
|
|
402
|
+
"basename": entrypath.rsplit("/", 1)[-1],
|
|
403
|
+
"hash_spec": hash_spec,
|
|
391
404
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
link_info["log"] =
|
|
395
|
-
{k: (list(v) if k == "when" else v)
|
|
396
|
-
for k, v in entry.items()}
|
|
397
|
-
for entry in log
|
|
398
|
-
]
|
|
399
|
-
except (AttributeError, TypeError):
|
|
400
|
-
pass
|
|
405
|
+
log = linkdict.get("_log")
|
|
406
|
+
if log:
|
|
407
|
+
link_info["log"] = log
|
|
401
408
|
result["+links"].append(link_info)
|
|
402
409
|
return _json_response({"result": result})
|
|
403
410
|
|
|
@@ -1415,6 +1422,61 @@ def _revoke_token_view(request):
|
|
|
1415
1422
|
return _json_response({"revoked": True, "id": tid})
|
|
1416
1423
|
|
|
1417
1424
|
|
|
1425
|
+
def _refresh_mirror_cache_view(request):
|
|
1426
|
+
"""POST /+admin-api/mirror/{user}/{index}/refresh-cache
|
|
1427
|
+
|
|
1428
|
+
Invalidate the in-memory mirror caches so the next pip request goes
|
|
1429
|
+
back to upstream:
|
|
1430
|
+
|
|
1431
|
+
* ``cache_retrieve_times`` — per-project last-fetch timestamp + etag.
|
|
1432
|
+
Expiring forces a conditional GET on the next ``+simple/<project>/``
|
|
1433
|
+
lookup; etag match still reuses the persisted ``PROJSIMPLELINKS``
|
|
1434
|
+
keyfs entry, so the only cost is one HTTP round-trip per project
|
|
1435
|
+
that pip actually queries (we don't pre-fetch).
|
|
1436
|
+
* ``cache_projectnames`` — full PyPI manifest. Expiring forces the
|
|
1437
|
+
next "list all projects" call to refetch (rare path — mostly used
|
|
1438
|
+
by ``pip install <unknown>`` to discover the project exists).
|
|
1439
|
+
|
|
1440
|
+
Caches are process-local: this only works on the primary (the
|
|
1441
|
+
replica's local cache is meaningless to invalidate, and replicas
|
|
1442
|
+
sync persisted state via the changelog stream). Any authenticated
|
|
1443
|
+
user may trigger the refresh — anonymous spam is blocked by auth,
|
|
1444
|
+
upstream-side abuse is bounded by mirror semantics (etag-conditional
|
|
1445
|
+
requests dominate).
|
|
1446
|
+
"""
|
|
1447
|
+
xom = request.registry["xom"]
|
|
1448
|
+
_refuse_on_replica(xom)
|
|
1449
|
+
_require_authenticated(request)
|
|
1450
|
+
user = request.matchdict["user"]
|
|
1451
|
+
index = request.matchdict["index"]
|
|
1452
|
+
_validate_name(user, "user")
|
|
1453
|
+
_validate_name(index, "index")
|
|
1454
|
+
with xom.keyfs.read_transaction(allow_reuse=True):
|
|
1455
|
+
stage = xom.model.getstage(user, index)
|
|
1456
|
+
if stage is None:
|
|
1457
|
+
raise HTTPNotFound(
|
|
1458
|
+
json_body={"error": f"index {user}/{index} does not exist"})
|
|
1459
|
+
if stage.ixconfig.get("type") != "mirror":
|
|
1460
|
+
raise HTTPBadRequest(json_body={
|
|
1461
|
+
"error": "cache refresh applies to mirror indexes only"})
|
|
1462
|
+
# `_project2time` is the per-xom singleton dict that backs the
|
|
1463
|
+
# per-project cache; iterating its keys lets us expire only entries
|
|
1464
|
+
# devpi has actually populated. The public `expire(project)` API is
|
|
1465
|
+
# safe for non-tracked projects too (it's pop-with-default), so we
|
|
1466
|
+
# don't need to special-case empty caches.
|
|
1467
|
+
crt = stage.cache_retrieve_times
|
|
1468
|
+
projects = list(getattr(crt, "_project2time", {}).keys())
|
|
1469
|
+
for project in projects:
|
|
1470
|
+
crt.expire(project)
|
|
1471
|
+
stage.cache_projectnames.expire()
|
|
1472
|
+
return _json_response({
|
|
1473
|
+
"result": {
|
|
1474
|
+
"projects_invalidated": len(projects),
|
|
1475
|
+
"projectnames_invalidated": True,
|
|
1476
|
+
},
|
|
1477
|
+
})
|
|
1478
|
+
|
|
1479
|
+
|
|
1418
1480
|
def _reset_tokens_view(request):
|
|
1419
1481
|
"""DELETE /+admin-api/users/{user}/tokens — revoke all for a user."""
|
|
1420
1482
|
xom = request.registry["xom"]
|
|
@@ -428,7 +428,7 @@ body {
|
|
|
428
428
|
|
|
429
429
|
.index-grid {
|
|
430
430
|
display: grid;
|
|
431
|
-
grid-template-columns: repeat(auto-fill, minmax(
|
|
431
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
432
432
|
gap: 12px;
|
|
433
433
|
}
|
|
434
434
|
|
|
@@ -461,10 +461,19 @@ body {
|
|
|
461
461
|
gap: 8px;
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
.index-card-path {
|
|
465
|
+
display: flex;
|
|
466
|
+
align-items: baseline;
|
|
467
|
+
gap: 2px;
|
|
468
|
+
min-width: 0;
|
|
469
|
+
flex: 1 1 auto;
|
|
470
|
+
}
|
|
471
|
+
|
|
464
472
|
.index-card-tags {
|
|
465
473
|
display: flex;
|
|
466
474
|
gap: 4px;
|
|
467
475
|
margin-left: auto;
|
|
476
|
+
flex-shrink: 0;
|
|
468
477
|
}
|
|
469
478
|
|
|
470
479
|
.index-card-owner {
|
|
@@ -481,7 +490,7 @@ body {
|
|
|
481
490
|
.index-card-sep {
|
|
482
491
|
font-size: 16px;
|
|
483
492
|
color: var(--text-faint);
|
|
484
|
-
margin: 0
|
|
493
|
+
margin: 0;
|
|
485
494
|
}
|
|
486
495
|
|
|
487
496
|
.index-card-name {
|
|
@@ -1088,6 +1097,44 @@ body {
|
|
|
1088
1097
|
color: var(--tag-text);
|
|
1089
1098
|
}
|
|
1090
1099
|
|
|
1100
|
+
/* Compact single-letter variant used in index-card heads where the
|
|
1101
|
+
full word (mirror/stage/volatile/...) would push the kebab off the
|
|
1102
|
+
row in narrow grid columns. Hover tooltip (data-tooltip) carries
|
|
1103
|
+
the long form — uses a CSS pseudo so it appears instantly instead
|
|
1104
|
+
of waiting for the browser's native title delay. */
|
|
1105
|
+
.tag-letter {
|
|
1106
|
+
padding: 1px 6px;
|
|
1107
|
+
font-size: 11px;
|
|
1108
|
+
font-weight: 700;
|
|
1109
|
+
font-family: var(--mono, ui-monospace, monospace);
|
|
1110
|
+
min-width: 18px;
|
|
1111
|
+
text-align: center;
|
|
1112
|
+
position: relative;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
.tag-letter[data-tooltip]:hover::after,
|
|
1116
|
+
.tag-letter[data-tooltip]:focus-visible::after {
|
|
1117
|
+
content: attr(data-tooltip);
|
|
1118
|
+
position: absolute;
|
|
1119
|
+
bottom: calc(100% + 6px);
|
|
1120
|
+
left: 50%;
|
|
1121
|
+
transform: translateX(-50%);
|
|
1122
|
+
background: var(--bg-surface);
|
|
1123
|
+
color: var(--text);
|
|
1124
|
+
border: 1px solid var(--border);
|
|
1125
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
|
1126
|
+
padding: 5px 8px;
|
|
1127
|
+
border-radius: 4px;
|
|
1128
|
+
font-size: 12px;
|
|
1129
|
+
font-weight: 400;
|
|
1130
|
+
font-family: inherit;
|
|
1131
|
+
white-space: normal;
|
|
1132
|
+
max-width: 260px;
|
|
1133
|
+
width: max-content;
|
|
1134
|
+
z-index: 100;
|
|
1135
|
+
pointer-events: none;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1091
1138
|
.tag-volatile {
|
|
1092
1139
|
background: #fef3c7;
|
|
1093
1140
|
color: #92400e;
|
|
@@ -1532,6 +1579,13 @@ body {
|
|
|
1532
1579
|
word-break: break-word;
|
|
1533
1580
|
}
|
|
1534
1581
|
|
|
1582
|
+
.pkg-sidebar-text {
|
|
1583
|
+
margin-top: 4px;
|
|
1584
|
+
font-size: 12px;
|
|
1585
|
+
color: var(--text);
|
|
1586
|
+
word-break: break-word;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1535
1589
|
.pkg-sidebar-list {
|
|
1536
1590
|
list-style: none;
|
|
1537
1591
|
margin-top: 4px;
|
|
@@ -1644,6 +1698,11 @@ body {
|
|
|
1644
1698
|
color: var(--text-faint);
|
|
1645
1699
|
}
|
|
1646
1700
|
|
|
1701
|
+
.pkg-card-updated {
|
|
1702
|
+
font-size: 12px;
|
|
1703
|
+
color: var(--text-faint);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1647
1706
|
/* --- Version cards --- */
|
|
1648
1707
|
|
|
1649
1708
|
.ver-grid {
|
|
@@ -2421,7 +2421,13 @@
|
|
|
2421
2421
|
closeModal();
|
|
2422
2422
|
updateAuthUI();
|
|
2423
2423
|
navigate();
|
|
2424
|
-
|
|
2424
|
+
// Preserve the current hash through the PRG redirect that
|
|
2425
|
+
// form.submit() to /+admin triggers — otherwise the
|
|
2426
|
+
// browser lands on /+admin/ with no fragment and the
|
|
2427
|
+
// user's original deep link (or the page they were on
|
|
2428
|
+
// when the session expired) is lost.
|
|
2429
|
+
var currentHash = (window.location.hash || '').replace(/^#/, '');
|
|
2430
|
+
_triggerPasswordSave(user, pass, currentHash);
|
|
2425
2431
|
})
|
|
2426
2432
|
.catch(showModalError);
|
|
2427
2433
|
}
|
|
@@ -2825,47 +2831,61 @@
|
|
|
2825
2831
|
|
|
2826
2832
|
// Card header: name + type badge
|
|
2827
2833
|
var cardHead = el('div', {className: 'index-card-head'});
|
|
2828
|
-
|
|
2834
|
+
// Path container groups owner / sep / name so the only
|
|
2835
|
+
// wide flex gap in the head is between path, tags, and
|
|
2836
|
+
// kebab — the separator hugs both sides like a real
|
|
2837
|
+
// filesystem path.
|
|
2838
|
+
var pathWrap = el('div', {className: 'index-card-path'});
|
|
2839
|
+
pathWrap.appendChild(el('a', {
|
|
2829
2840
|
href: '#indexes/' + idx._user,
|
|
2830
2841
|
className: 'index-card-owner',
|
|
2831
2842
|
textContent: idx._user,
|
|
2832
2843
|
}));
|
|
2833
|
-
|
|
2844
|
+
pathWrap.appendChild(el('span', {
|
|
2834
2845
|
className: 'index-card-sep',
|
|
2835
2846
|
textContent: '/',
|
|
2836
2847
|
}));
|
|
2837
|
-
|
|
2848
|
+
pathWrap.appendChild(el('a', {
|
|
2838
2849
|
href: '#packages/' + idx._full,
|
|
2839
2850
|
className: 'index-card-name',
|
|
2840
2851
|
textContent: idx._name,
|
|
2841
2852
|
}));
|
|
2853
|
+
cardHead.appendChild(pathWrap);
|
|
2854
|
+
// Type / state badges. Single-letter labels (M/S/V/W/N)
|
|
2855
|
+
// keep multi-badge headers from wrapping in tight grids;
|
|
2856
|
+
// `title` exposes the full word on hover for clarity.
|
|
2842
2857
|
var tagGroup = el('div', {className: 'index-card-tags'});
|
|
2843
2858
|
tagGroup.appendChild(el('span', {
|
|
2844
|
-
className: 'tag
|
|
2845
|
-
|
|
2859
|
+
className: 'tag tag-letter'
|
|
2860
|
+
+ (isMirror ? ' tag-mirror' : ' tag-stage'),
|
|
2861
|
+
textContent: isMirror ? 'M' : 'S',
|
|
2862
|
+
'data-tooltip': idx.type || 'stage',
|
|
2846
2863
|
}));
|
|
2847
2864
|
if (!isMirror && idx.volatile) {
|
|
2848
2865
|
tagGroup.appendChild(el('span', {
|
|
2849
|
-
className: 'tag tag-volatile',
|
|
2850
|
-
textContent: '
|
|
2866
|
+
className: 'tag tag-letter tag-volatile',
|
|
2867
|
+
textContent: 'V',
|
|
2868
|
+
'data-tooltip': 'volatile — uploads may overwrite '
|
|
2869
|
+
+ 'existing versions',
|
|
2851
2870
|
}));
|
|
2852
2871
|
}
|
|
2853
2872
|
if (!isMirror && isAnonymousAclUpload(idx.acl_upload)) {
|
|
2854
2873
|
tagGroup.appendChild(el('span', {
|
|
2855
|
-
className: 'tag tag-world-writable',
|
|
2856
|
-
textContent: '
|
|
2857
|
-
|
|
2858
|
-
+ '
|
|
2859
|
-
+ 'publish
|
|
2874
|
+
className: 'tag tag-letter tag-world-writable',
|
|
2875
|
+
textContent: 'W',
|
|
2876
|
+
'data-tooltip': 'world-writable — acl_upload '
|
|
2877
|
+
+ 'contains :ANONYMOUS:; anyone (including '
|
|
2878
|
+
+ 'unauthenticated callers) can publish to '
|
|
2879
|
+
+ 'this index.',
|
|
2860
2880
|
}));
|
|
2861
2881
|
} else if (!isMirror && isUploadFrozen(idx.acl_upload)) {
|
|
2862
2882
|
tagGroup.appendChild(el('span', {
|
|
2863
|
-
className: 'tag tag-no-upload',
|
|
2864
|
-
textContent: '
|
|
2865
|
-
|
|
2866
|
-
+ 'to this index, not even
|
|
2867
|
-
+ 'Add a principal to
|
|
2868
|
-
+ 'uploads.',
|
|
2883
|
+
className: 'tag tag-letter tag-no-upload',
|
|
2884
|
+
textContent: 'N',
|
|
2885
|
+
'data-tooltip': 'no upload — acl_upload is empty; '
|
|
2886
|
+
+ 'nobody can publish to this index, not even '
|
|
2887
|
+
+ 'the owner or root. Add a principal to '
|
|
2888
|
+
+ 'acl_upload to enable uploads.',
|
|
2869
2889
|
}));
|
|
2870
2890
|
}
|
|
2871
2891
|
cardHead.appendChild(tagGroup);
|
|
@@ -2959,6 +2979,22 @@
|
|
|
2959
2979
|
});
|
|
2960
2980
|
})(idx);
|
|
2961
2981
|
}
|
|
2982
|
+
// Mirror-only: any authenticated user may trigger an
|
|
2983
|
+
// upstream re-fetch (etag-conditional, low cost). Useful
|
|
2984
|
+
// when waiting for a freshly-published upstream release
|
|
2985
|
+
// that's hidden behind the `mirror_cache_expiry` TTL.
|
|
2986
|
+
if (isMirror && loggedIn) {
|
|
2987
|
+
(function (idxRef) {
|
|
2988
|
+
menuItems.push({
|
|
2989
|
+
label: 'Refresh cache',
|
|
2990
|
+
onclick: function () {
|
|
2991
|
+
closeAllKebabs();
|
|
2992
|
+
refreshMirrorCache(
|
|
2993
|
+
idxRef._user, idxRef._name);
|
|
2994
|
+
},
|
|
2995
|
+
});
|
|
2996
|
+
})(idx);
|
|
2997
|
+
}
|
|
2962
2998
|
if (menuItems.length) {
|
|
2963
2999
|
cardHead.appendChild(buildKebabMenu(menuItems));
|
|
2964
3000
|
}
|
|
@@ -3223,6 +3259,51 @@
|
|
|
3223
3259
|
.catch(handleApiError);
|
|
3224
3260
|
}
|
|
3225
3261
|
|
|
3262
|
+
function refreshMirrorCache(user, index) {
|
|
3263
|
+
// Non-destructive: just bumps the upstream-fetch clocks. Skip
|
|
3264
|
+
// the confirm dialog; show a small result modal so the user
|
|
3265
|
+
// knows whether the request reached the primary.
|
|
3266
|
+
Api.post(
|
|
3267
|
+
'/+admin-api/mirror/'
|
|
3268
|
+
+ encodeURIComponent(user) + '/'
|
|
3269
|
+
+ encodeURIComponent(index) + '/refresh-cache',
|
|
3270
|
+
{})
|
|
3271
|
+
.then(function (data) {
|
|
3272
|
+
var r = (data && data.result) || {};
|
|
3273
|
+
var count = r.projects_invalidated;
|
|
3274
|
+
openModal('Cache refreshed', function (body) {
|
|
3275
|
+
body.appendChild(el('p', {
|
|
3276
|
+
textContent: 'Mirror "' + user + '/' + index
|
|
3277
|
+
+ '" cache invalidated. '
|
|
3278
|
+
+ count + ' project'
|
|
3279
|
+
+ (count === 1 ? '' : 's')
|
|
3280
|
+
+ ' will re-check upstream on next access.',
|
|
3281
|
+
}));
|
|
3282
|
+
body.appendChild(el('p', {
|
|
3283
|
+
className: 'note',
|
|
3284
|
+
textContent: 'Note: cache is process-local; '
|
|
3285
|
+
+ 'replicas will sync once the primary refetches.',
|
|
3286
|
+
}));
|
|
3287
|
+
}, [el('button', {
|
|
3288
|
+
className: 'btn btn-primary',
|
|
3289
|
+
textContent: 'OK',
|
|
3290
|
+
onclick: closeModal,
|
|
3291
|
+
})]);
|
|
3292
|
+
})
|
|
3293
|
+
.catch(function (err) {
|
|
3294
|
+
openModal('Cache refresh failed', function (body) {
|
|
3295
|
+
body.appendChild(el('p', {
|
|
3296
|
+
className: 'error',
|
|
3297
|
+
textContent: (err && err.message) || String(err),
|
|
3298
|
+
}));
|
|
3299
|
+
}, [el('button', {
|
|
3300
|
+
className: 'btn btn-primary',
|
|
3301
|
+
textContent: 'Close',
|
|
3302
|
+
onclick: closeModal,
|
|
3303
|
+
})]);
|
|
3304
|
+
});
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3226
3307
|
// ========== PACKAGES ==========
|
|
3227
3308
|
|
|
3228
3309
|
var PKG_LIMIT = 100;
|
|
@@ -3260,6 +3341,18 @@
|
|
|
3260
3341
|
Api.get('/' + indexPath + '/' + pkg).then(function (pkgData) {
|
|
3261
3342
|
var vers = Object.keys(pkgData.result).sort(compareVersions);
|
|
3262
3343
|
versionEl.textContent = vers.length ? 'v' + vers[0] : 'no versions';
|
|
3344
|
+
// Last update across all versions of the package
|
|
3345
|
+
var best = null;
|
|
3346
|
+
for (var i = 0; i < vers.length; i++) {
|
|
3347
|
+
var w = lastLogWhen(pkgData.result[vers[i]]['+links']);
|
|
3348
|
+
if (w && (!best || cmpLogWhen(w, best) > 0)) best = w;
|
|
3349
|
+
}
|
|
3350
|
+
if (best) {
|
|
3351
|
+
card.appendChild(el('div', {
|
|
3352
|
+
className: 'pkg-card-updated',
|
|
3353
|
+
textContent: 'Updated ' + formatLogWhen(best),
|
|
3354
|
+
}));
|
|
3355
|
+
}
|
|
3263
3356
|
}).catch(function () {
|
|
3264
3357
|
versionEl.textContent = '';
|
|
3265
3358
|
});
|
|
@@ -3518,7 +3611,22 @@
|
|
|
3518
3611
|
content.appendChild(el('div', {className: 'view-header'}, [
|
|
3519
3612
|
buildBreadcrumb(indexPath, [
|
|
3520
3613
|
' / ',
|
|
3521
|
-
el('a', {
|
|
3614
|
+
el('a', {
|
|
3615
|
+
href: '#package/' + indexPath + '/' + pkg,
|
|
3616
|
+
textContent: pkg,
|
|
3617
|
+
onclick: function (e) {
|
|
3618
|
+
// Same-hash click fires no hashchange event —
|
|
3619
|
+
// force a reload of the package detail.
|
|
3620
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
|
3621
|
+
e.preventDefault();
|
|
3622
|
+
var target = '#package/' + indexPath + '/' + pkg;
|
|
3623
|
+
if (window.location.hash !== target) {
|
|
3624
|
+
_skipHashChange = true;
|
|
3625
|
+
window.location.hash = target;
|
|
3626
|
+
}
|
|
3627
|
+
loadPackageDetail(indexPath, pkg);
|
|
3628
|
+
},
|
|
3629
|
+
}),
|
|
3522
3630
|
' ',
|
|
3523
3631
|
el('span', {className: 'page-heading-version', textContent: 'v' + currentVer}),
|
|
3524
3632
|
]),
|
|
@@ -3555,6 +3663,18 @@
|
|
|
3555
3663
|
}));
|
|
3556
3664
|
}
|
|
3557
3665
|
|
|
3666
|
+
var uploadedWhen = lastLogWhen(info['+links']);
|
|
3667
|
+
if (uploadedWhen) {
|
|
3668
|
+
infoCard.appendChild(el('div', {
|
|
3669
|
+
className: 'pkg-sidebar-label',
|
|
3670
|
+
textContent: 'Uploaded',
|
|
3671
|
+
}));
|
|
3672
|
+
infoCard.appendChild(el('div', {
|
|
3673
|
+
className: 'pkg-sidebar-text',
|
|
3674
|
+
textContent: formatLogWhen(uploadedWhen),
|
|
3675
|
+
}));
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3558
3678
|
var infoRows = [];
|
|
3559
3679
|
if (info.requires_python) infoRows.push(['Python', info.requires_python]);
|
|
3560
3680
|
var license = info.license_expression || info.license;
|
|
@@ -3655,9 +3775,7 @@
|
|
|
3655
3775
|
var dateStr = '';
|
|
3656
3776
|
if (link.log && link.log.length) {
|
|
3657
3777
|
var log = link.log[0];
|
|
3658
|
-
|
|
3659
|
-
dateStr = when[0] + '-' + pad(when[1]) + '-' + pad(when[2]) + ' ' +
|
|
3660
|
-
pad(when[3]) + ':' + pad(when[4]);
|
|
3778
|
+
dateStr = formatLogWhen(log.when);
|
|
3661
3779
|
if (log.who) dateStr = log.who + ', ' + dateStr;
|
|
3662
3780
|
}
|
|
3663
3781
|
if (dateStr && dateStr !== lastDate) {
|
|
@@ -3839,6 +3957,36 @@
|
|
|
3839
3957
|
return n < 10 ? '0' + n : '' + n;
|
|
3840
3958
|
}
|
|
3841
3959
|
|
|
3960
|
+
function formatLogWhen(when) {
|
|
3961
|
+
// keyfs link log 'when' is a UTC tuple [Y, M, D, h, m, s]
|
|
3962
|
+
return when[0] + '-' + pad(when[1]) + '-' + pad(when[2]) + ' ' +
|
|
3963
|
+
pad(when[3]) + ':' + pad(when[4]);
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
function cmpLogWhen(a, b) {
|
|
3967
|
+
for (var i = 0; i < a.length && i < b.length; i++) {
|
|
3968
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
3969
|
+
}
|
|
3970
|
+
return 0;
|
|
3971
|
+
}
|
|
3972
|
+
|
|
3973
|
+
function lastLogWhen(links) {
|
|
3974
|
+
// Latest log timestamp across release links — upload, push and
|
|
3975
|
+
// overwrite entries all represent a content update. Mirror links
|
|
3976
|
+
// carry no log → returns null.
|
|
3977
|
+
var best = null;
|
|
3978
|
+
for (var i = 0; i < (links || []).length; i++) {
|
|
3979
|
+
var log = links[i].log || [];
|
|
3980
|
+
for (var j = 0; j < log.length; j++) {
|
|
3981
|
+
var when = log[j].when;
|
|
3982
|
+
if (when && (!best || cmpLogWhen(when, best) > 0)) {
|
|
3983
|
+
best = when;
|
|
3984
|
+
}
|
|
3985
|
+
}
|
|
3986
|
+
}
|
|
3987
|
+
return best;
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3842
3990
|
var _preReleaseOrder = {dev: 0, a: 1, alpha: 1, b: 2, beta: 2, rc: 3, c: 3};
|
|
3843
3991
|
|
|
3844
3992
|
function _parseVersion(v) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devpi-admin
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.4
|
|
4
4
|
Summary: Modern web UI plugin for devpi-server — drop-in replacement for devpi-web
|
|
5
5
|
Author-email: Pavel Revak <pavelrevak@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -61,6 +61,10 @@ talks to the standard devpi JSON API directly.
|
|
|
61
61
|
- **`Tokens`** (owner / root only) — opens the per-index unified Tokens modal with two
|
|
62
62
|
sections (Admin + Devpi), shows existing tokens for this index, lets you issue new
|
|
63
63
|
ones with the index pre-filled and locked
|
|
64
|
+
- **`Refresh cache`** (mirror indexes only, any authenticated user) — invalidates
|
|
65
|
+
the in-memory per-project and project-names caches; the next `+simple/<project>/`
|
|
66
|
+
query (from pip, the UI, or `devpi-client`) goes back to upstream
|
|
67
|
+
(etag-conditional, cheap)
|
|
64
68
|
- **`Edit`** / **`Delete`** (owner / root)
|
|
65
69
|
- Create / edit / delete indexes via modal dialogs
|
|
66
70
|
- `bases` editor with drag & drop priority ordering and transitive inheritance display
|
|
@@ -597,6 +601,30 @@ Revoke a single token.
|
|
|
597
601
|
- **200:** `{"revoked": true, "id": "abc..."}`
|
|
598
602
|
- **404:** token id not found
|
|
599
603
|
|
|
604
|
+
### Mirror cache
|
|
605
|
+
|
|
606
|
+
#### `POST /+admin-api/mirror/{user}/{index}/refresh-cache`
|
|
607
|
+
Invalidate the in-memory mirror caches so the next pip / UI / `devpi-client` request
|
|
608
|
+
re-checks upstream. Lazy — no upstream fetch happens at the moment of the call; the
|
|
609
|
+
re-fetch is triggered by the next `+simple/<project>/` lookup that traverses this
|
|
610
|
+
mirror (etag-conditional, typically one cheap HTTP round-trip per project actually
|
|
611
|
+
queried). Two caches are expired:
|
|
612
|
+
|
|
613
|
+
- `cache_retrieve_times` — per-project last-fetch timestamp + etag (every tracked
|
|
614
|
+
project, in one pass)
|
|
615
|
+
- `cache_projectnames` — full PyPI project-name list (refetched on the next "list all
|
|
616
|
+
projects" call)
|
|
617
|
+
|
|
618
|
+
Useful when waiting for a freshly-published upstream release that's still hidden
|
|
619
|
+
behind the `mirror_cache_expiry` TTL (default 30 min).
|
|
620
|
+
|
|
621
|
+
- **Auth:** required (any authenticated user)
|
|
622
|
+
- **Primary only:** replicas return 400 (caches are process-local; replicas sync the
|
|
623
|
+
persisted state via the changelog stream once the primary refetches)
|
|
624
|
+
- **200:** `{"result": {"projects_invalidated": N, "projectnames_invalidated": true}}`
|
|
625
|
+
- **400:** index is not a mirror, or the call hit a replica
|
|
626
|
+
- **404:** index doesn't exist
|
|
627
|
+
|
|
600
628
|
### Replication observability (primary only)
|
|
601
629
|
|
|
602
630
|
#### `GET /+admin-api/replicas`
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Tests for view-layer helpers (_get_stage_or_404, _check_read_access)."""
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden, HTTPNotFound
|
|
7
|
+
|
|
8
|
+
from devpi_admin.main import (
|
|
9
|
+
_check_read_access, _get_stage_or_404, _refresh_mirror_cache_view,
|
|
10
|
+
_serve_index, _versiondata_view)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GetStageOr404Tests(unittest.TestCase):
|
|
14
|
+
|
|
15
|
+
def test_returns_stage(self):
|
|
16
|
+
xom = MagicMock()
|
|
17
|
+
stage = MagicMock()
|
|
18
|
+
xom.model.getstage.return_value = stage
|
|
19
|
+
self.assertIs(_get_stage_or_404(xom, "alice", "dev"), stage)
|
|
20
|
+
|
|
21
|
+
def test_raises_404_when_missing(self):
|
|
22
|
+
xom = MagicMock()
|
|
23
|
+
xom.model.getstage.return_value = None
|
|
24
|
+
with self.assertRaises(HTTPNotFound):
|
|
25
|
+
_get_stage_or_404(xom, "ghost", "ix")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CheckReadAccessTests(unittest.TestCase):
|
|
29
|
+
|
|
30
|
+
def _make(self, allow, auth_user):
|
|
31
|
+
req = MagicMock()
|
|
32
|
+
req.has_permission.return_value = allow
|
|
33
|
+
req.authenticated_userid = auth_user
|
|
34
|
+
return req
|
|
35
|
+
|
|
36
|
+
def test_allowed_returns_none(self):
|
|
37
|
+
# Should silently pass — no exception, no return value relied upon.
|
|
38
|
+
self.assertIsNone(
|
|
39
|
+
_check_read_access(self._make(True, "alice"), MagicMock()))
|
|
40
|
+
|
|
41
|
+
def test_denied_anonymous_gets_403(self):
|
|
42
|
+
# Anonymous denial returns 403 so devpi-cli / pip can retry with auth.
|
|
43
|
+
with self.assertRaises(HTTPForbidden):
|
|
44
|
+
_check_read_access(self._make(False, None), MagicMock())
|
|
45
|
+
|
|
46
|
+
def test_denied_authenticated_gets_404(self):
|
|
47
|
+
# Authenticated user without read access gets 404 — hides the
|
|
48
|
+
# existence of the private index.
|
|
49
|
+
with self.assertRaises(HTTPNotFound):
|
|
50
|
+
_check_read_access(self._make(False, "bob"), MagicMock())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ServeIndexTests(unittest.TestCase):
|
|
54
|
+
|
|
55
|
+
def _serve(self):
|
|
56
|
+
# FileResponse needs a real file on disk; STATIC_DIR/index.html
|
|
57
|
+
# is bundled and exists in test runs (verified by test_package).
|
|
58
|
+
request = MagicMock()
|
|
59
|
+
return _serve_index(request)
|
|
60
|
+
|
|
61
|
+
def test_csp_header_present(self):
|
|
62
|
+
resp = self._serve()
|
|
63
|
+
csp = resp.headers.get("Content-Security-Policy", "")
|
|
64
|
+
self.assertIn("default-src 'self'", csp)
|
|
65
|
+
self.assertIn("frame-ancestors 'none'", csp)
|
|
66
|
+
self.assertIn("script-src 'self'", csp)
|
|
67
|
+
# inline script must NOT be allowed
|
|
68
|
+
self.assertNotIn("'unsafe-inline'", csp.split("script-src")[1].split(";")[0])
|
|
69
|
+
|
|
70
|
+
def test_csp_allows_pypi_for_readme_fallback(self):
|
|
71
|
+
resp = self._serve()
|
|
72
|
+
csp = resp.headers.get("Content-Security-Policy", "")
|
|
73
|
+
self.assertIn("https://pypi.org", csp)
|
|
74
|
+
|
|
75
|
+
def test_security_headers_present(self):
|
|
76
|
+
resp = self._serve()
|
|
77
|
+
self.assertEqual(resp.headers.get("X-Content-Type-Options"), "nosniff")
|
|
78
|
+
self.assertEqual(resp.headers.get("Referrer-Policy"), "no-referrer")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RefreshMirrorCacheViewTests(unittest.TestCase):
|
|
82
|
+
|
|
83
|
+
def _stub_xom(self, stage=None, is_replica=False):
|
|
84
|
+
xom = MagicMock()
|
|
85
|
+
xom.config.role = "replica" if is_replica else "primary"
|
|
86
|
+
xom.model.getstage.return_value = stage
|
|
87
|
+
ctx = MagicMock()
|
|
88
|
+
ctx.__enter__ = MagicMock(return_value=ctx)
|
|
89
|
+
ctx.__exit__ = MagicMock(return_value=False)
|
|
90
|
+
xom.keyfs.read_transaction.return_value = ctx
|
|
91
|
+
return xom
|
|
92
|
+
|
|
93
|
+
def _stub_mirror_stage(self, tracked_projects=("setuptools", "pip")):
|
|
94
|
+
stage = MagicMock()
|
|
95
|
+
stage.ixconfig = {"type": "mirror"}
|
|
96
|
+
stage.cache_retrieve_times._project2time = {
|
|
97
|
+
p: (1.0, None) for p in tracked_projects}
|
|
98
|
+
stage.cache_retrieve_times.expire = MagicMock()
|
|
99
|
+
stage.cache_projectnames.expire = MagicMock()
|
|
100
|
+
return stage
|
|
101
|
+
|
|
102
|
+
def _make(self, xom, user="root", index="pypi", auth_user="alice"):
|
|
103
|
+
req = MagicMock()
|
|
104
|
+
req.registry = {"xom": xom}
|
|
105
|
+
req.matchdict = {"user": user, "index": index}
|
|
106
|
+
req.authenticated_userid = auth_user
|
|
107
|
+
return req
|
|
108
|
+
|
|
109
|
+
def test_expires_all_tracked_projects_and_projectnames(self):
|
|
110
|
+
stage = self._stub_mirror_stage(("setuptools", "pip", "wheel"))
|
|
111
|
+
xom = self._stub_xom(stage=stage)
|
|
112
|
+
with patch("devpi_admin.main._is_replica", return_value=False):
|
|
113
|
+
resp = _refresh_mirror_cache_view(self._make(xom))
|
|
114
|
+
body = json.loads(resp.body)
|
|
115
|
+
self.assertEqual(body["result"]["projects_invalidated"], 3)
|
|
116
|
+
self.assertTrue(body["result"]["projectnames_invalidated"])
|
|
117
|
+
# Every tracked project must have been expired exactly once;
|
|
118
|
+
# the project-names cache must be expired regardless.
|
|
119
|
+
self.assertEqual(stage.cache_retrieve_times.expire.call_count, 3)
|
|
120
|
+
stage.cache_projectnames.expire.assert_called_once_with()
|
|
121
|
+
|
|
122
|
+
def test_404_when_index_missing(self):
|
|
123
|
+
xom = self._stub_xom(stage=None)
|
|
124
|
+
with patch("devpi_admin.main._is_replica", return_value=False):
|
|
125
|
+
with self.assertRaises(HTTPNotFound):
|
|
126
|
+
_refresh_mirror_cache_view(self._make(xom))
|
|
127
|
+
|
|
128
|
+
def test_400_when_index_is_not_mirror(self):
|
|
129
|
+
stage = MagicMock()
|
|
130
|
+
stage.ixconfig = {"type": "stage"}
|
|
131
|
+
xom = self._stub_xom(stage=stage)
|
|
132
|
+
with patch("devpi_admin.main._is_replica", return_value=False):
|
|
133
|
+
with self.assertRaises(HTTPBadRequest):
|
|
134
|
+
_refresh_mirror_cache_view(self._make(xom))
|
|
135
|
+
|
|
136
|
+
def test_replica_refuses(self):
|
|
137
|
+
xom = self._stub_xom(stage=self._stub_mirror_stage())
|
|
138
|
+
with patch("devpi_admin.main._is_replica", return_value=True):
|
|
139
|
+
with self.assertRaises(HTTPBadRequest):
|
|
140
|
+
_refresh_mirror_cache_view(self._make(xom))
|
|
141
|
+
|
|
142
|
+
def test_unauthenticated_blocked(self):
|
|
143
|
+
xom = self._stub_xom(stage=self._stub_mirror_stage())
|
|
144
|
+
with patch("devpi_admin.main._is_replica", return_value=False):
|
|
145
|
+
with self.assertRaises(HTTPForbidden):
|
|
146
|
+
_refresh_mirror_cache_view(
|
|
147
|
+
self._make(xom, auth_user=None))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class VersiondataViewTests(unittest.TestCase):
|
|
151
|
+
"""+links must be built from verdata +elinks (which carry _log).
|
|
152
|
+
|
|
153
|
+
Regression: get_releaselinks() reconstructs ELinks from simplelinks
|
|
154
|
+
metadata without "_log", so the upload timestamps never reached the
|
|
155
|
+
response when the view used it.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
def _make(self, verdata):
|
|
159
|
+
stage = MagicMock()
|
|
160
|
+
stage.get_versiondata.return_value = verdata
|
|
161
|
+
xom = MagicMock()
|
|
162
|
+
xom.model.getstage.return_value = stage
|
|
163
|
+
req = MagicMock()
|
|
164
|
+
req.registry = {"xom": xom}
|
|
165
|
+
req.matchdict = {
|
|
166
|
+
"user": "alice", "index": "dev",
|
|
167
|
+
"project": "testpkg", "version": "1.0"}
|
|
168
|
+
req.has_permission.return_value = True
|
|
169
|
+
req.authenticated_userid = "alice"
|
|
170
|
+
return req
|
|
171
|
+
|
|
172
|
+
def test_links_carry_upload_log(self):
|
|
173
|
+
verdata = {
|
|
174
|
+
"name": "testpkg", "version": "1.0",
|
|
175
|
+
"+elinks": [
|
|
176
|
+
{
|
|
177
|
+
"rel": "releasefile",
|
|
178
|
+
"entrypath": "alice/dev/+f/b28/abc/testpkg-1.0.tar.gz",
|
|
179
|
+
"hash_spec": "md5=deadbeef",
|
|
180
|
+
"hashes": {"sha256": "cafe"},
|
|
181
|
+
"_log": [{
|
|
182
|
+
"what": "upload", "who": "alice",
|
|
183
|
+
"when": (2026, 6, 5, 8, 10, 44),
|
|
184
|
+
"dst": "alice/dev"}],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
# non-releasefile links must be filtered out
|
|
188
|
+
"rel": "toxresult",
|
|
189
|
+
"entrypath": "alice/dev/+f/123/tox.json",
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
body = json.loads(_versiondata_view(self._make(verdata)).body)
|
|
194
|
+
result = body["result"]
|
|
195
|
+
self.assertNotIn("+elinks", result)
|
|
196
|
+
self.assertEqual(len(result["+links"]), 1)
|
|
197
|
+
link = result["+links"][0]
|
|
198
|
+
self.assertEqual(
|
|
199
|
+
link["href"], "/alice/dev/+f/b28/abc/testpkg-1.0.tar.gz")
|
|
200
|
+
self.assertEqual(link["basename"], "testpkg-1.0.tar.gz")
|
|
201
|
+
self.assertEqual(link["hash_spec"], "sha256=cafe")
|
|
202
|
+
self.assertEqual(link["log"][0]["when"], [2026, 6, 5, 8, 10, 44])
|
|
203
|
+
self.assertEqual(link["log"][0]["who"], "alice")
|
|
204
|
+
|
|
205
|
+
def test_mirror_links_without_log(self):
|
|
206
|
+
# Mirror elinks carry no "_log" — the "log" key must be absent,
|
|
207
|
+
# not present-but-empty.
|
|
208
|
+
verdata = {
|
|
209
|
+
"name": "testpkg", "version": "1.0",
|
|
210
|
+
"+elinks": [{
|
|
211
|
+
"rel": "releasefile",
|
|
212
|
+
"entrypath": "root/pypi/+f/abc/testpkg-1.0.tar.gz",
|
|
213
|
+
"hash_spec": "md5=deadbeef",
|
|
214
|
+
"hashes": {},
|
|
215
|
+
}],
|
|
216
|
+
}
|
|
217
|
+
body = json.loads(_versiondata_view(self._make(verdata)).body)
|
|
218
|
+
link = body["result"]["+links"][0]
|
|
219
|
+
self.assertNotIn("log", link)
|
|
220
|
+
self.assertEqual(link["hash_spec"], "md5=deadbeef")
|
|
221
|
+
|
|
222
|
+
def test_no_elinks_yields_empty_links(self):
|
|
223
|
+
verdata = {"name": "testpkg", "version": "1.0"}
|
|
224
|
+
body = json.loads(_versiondata_view(self._make(verdata)).body)
|
|
225
|
+
self.assertEqual(body["result"]["+links"], [])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
unittest.main()
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
"""Tests for view-layer helpers (_get_stage_or_404, _check_read_access)."""
|
|
2
|
-
import unittest
|
|
3
|
-
from unittest.mock import MagicMock, patch
|
|
4
|
-
|
|
5
|
-
from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound
|
|
6
|
-
|
|
7
|
-
from devpi_admin.main import _check_read_access, _get_stage_or_404, _serve_index
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class GetStageOr404Tests(unittest.TestCase):
|
|
11
|
-
|
|
12
|
-
def test_returns_stage(self):
|
|
13
|
-
xom = MagicMock()
|
|
14
|
-
stage = MagicMock()
|
|
15
|
-
xom.model.getstage.return_value = stage
|
|
16
|
-
self.assertIs(_get_stage_or_404(xom, "alice", "dev"), stage)
|
|
17
|
-
|
|
18
|
-
def test_raises_404_when_missing(self):
|
|
19
|
-
xom = MagicMock()
|
|
20
|
-
xom.model.getstage.return_value = None
|
|
21
|
-
with self.assertRaises(HTTPNotFound):
|
|
22
|
-
_get_stage_or_404(xom, "ghost", "ix")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class CheckReadAccessTests(unittest.TestCase):
|
|
26
|
-
|
|
27
|
-
def _make(self, allow, auth_user):
|
|
28
|
-
req = MagicMock()
|
|
29
|
-
req.has_permission.return_value = allow
|
|
30
|
-
req.authenticated_userid = auth_user
|
|
31
|
-
return req
|
|
32
|
-
|
|
33
|
-
def test_allowed_returns_none(self):
|
|
34
|
-
# Should silently pass — no exception, no return value relied upon.
|
|
35
|
-
self.assertIsNone(
|
|
36
|
-
_check_read_access(self._make(True, "alice"), MagicMock()))
|
|
37
|
-
|
|
38
|
-
def test_denied_anonymous_gets_403(self):
|
|
39
|
-
# Anonymous denial returns 403 so devpi-cli / pip can retry with auth.
|
|
40
|
-
with self.assertRaises(HTTPForbidden):
|
|
41
|
-
_check_read_access(self._make(False, None), MagicMock())
|
|
42
|
-
|
|
43
|
-
def test_denied_authenticated_gets_404(self):
|
|
44
|
-
# Authenticated user without read access gets 404 — hides the
|
|
45
|
-
# existence of the private index.
|
|
46
|
-
with self.assertRaises(HTTPNotFound):
|
|
47
|
-
_check_read_access(self._make(False, "bob"), MagicMock())
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
class ServeIndexTests(unittest.TestCase):
|
|
51
|
-
|
|
52
|
-
def _serve(self):
|
|
53
|
-
# FileResponse needs a real file on disk; STATIC_DIR/index.html
|
|
54
|
-
# is bundled and exists in test runs (verified by test_package).
|
|
55
|
-
request = MagicMock()
|
|
56
|
-
return _serve_index(request)
|
|
57
|
-
|
|
58
|
-
def test_csp_header_present(self):
|
|
59
|
-
resp = self._serve()
|
|
60
|
-
csp = resp.headers.get("Content-Security-Policy", "")
|
|
61
|
-
self.assertIn("default-src 'self'", csp)
|
|
62
|
-
self.assertIn("frame-ancestors 'none'", csp)
|
|
63
|
-
self.assertIn("script-src 'self'", csp)
|
|
64
|
-
# inline script must NOT be allowed
|
|
65
|
-
self.assertNotIn("'unsafe-inline'", csp.split("script-src")[1].split(";")[0])
|
|
66
|
-
|
|
67
|
-
def test_csp_allows_pypi_for_readme_fallback(self):
|
|
68
|
-
resp = self._serve()
|
|
69
|
-
csp = resp.headers.get("Content-Security-Policy", "")
|
|
70
|
-
self.assertIn("https://pypi.org", csp)
|
|
71
|
-
|
|
72
|
-
def test_security_headers_present(self):
|
|
73
|
-
resp = self._serve()
|
|
74
|
-
self.assertEqual(resp.headers.get("X-Content-Type-Options"), "nosniff")
|
|
75
|
-
self.assertEqual(resp.headers.get("Referrer-Policy"), "no-referrer")
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
if __name__ == "__main__":
|
|
79
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|