devpi-admin 1.4.1__tar.gz → 1.4.3__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.1 → devpi_admin-1.4.3}/.github/workflows/publish.yml +2 -2
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/.github/workflows/tests.yml +2 -2
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/PKG-INFO +29 -1
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/README.md +28 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/_version.py +3 -3
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/main.py +65 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/css/style.css +54 -3
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/js/app.js +173 -28
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/PKG-INFO +29 -1
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_devpi_tokens_ui.py +17 -0
- devpi_admin-1.4.3/tests/test_view_helpers.py +151 -0
- devpi_admin-1.4.1/tests/test_view_helpers.py +0 -79
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/.gitignore +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/LICENSE +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/__init__.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/customizer.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/favicon.svg +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/index.html +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/js/api.js +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/js/marked.min.js +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/static/js/theme.js +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin/tokens.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/SOURCES.txt +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/dependency_links.txt +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/entry_points.txt +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/requires.txt +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/devpi_admin.egg-info/top_level.txt +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/pyproject.toml +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/setup.cfg +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/__init__.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_acl_read.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_filter.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_hooks.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_json_safe.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_package.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_pipconf.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_tokens.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_tween.py +0 -0
- {devpi_admin-1.4.1 → devpi_admin-1.4.3}/tests/test_wants_html.py +0 -0
|
@@ -12,12 +12,12 @@ jobs:
|
|
|
12
12
|
id-token: write
|
|
13
13
|
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
15
|
+
- uses: actions/checkout@v5
|
|
16
16
|
with:
|
|
17
17
|
fetch-depth: 0 # needed for setuptools-scm to derive version from tag
|
|
18
18
|
|
|
19
19
|
- name: Set up Python
|
|
20
|
-
uses: actions/setup-python@
|
|
20
|
+
uses: actions/setup-python@v6
|
|
21
21
|
with:
|
|
22
22
|
python-version: "3.14"
|
|
23
23
|
|
|
@@ -14,12 +14,12 @@ jobs:
|
|
|
14
14
|
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
15
15
|
|
|
16
16
|
steps:
|
|
17
|
-
- uses: actions/checkout@
|
|
17
|
+
- uses: actions/checkout@v5
|
|
18
18
|
with:
|
|
19
19
|
fetch-depth: 0 # needed for setuptools-scm to read git tags
|
|
20
20
|
|
|
21
21
|
- name: Set up Python ${{ matrix.python-version }}
|
|
22
|
-
uses: actions/setup-python@
|
|
22
|
+
uses: actions/setup-python@v6
|
|
23
23
|
with:
|
|
24
24
|
python-version: ${{ matrix.python-version }}
|
|
25
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devpi-admin
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
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.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 4, 3)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'geb4eac9d6'
|
|
@@ -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.
|
|
@@ -1415,6 +1425,61 @@ def _revoke_token_view(request):
|
|
|
1415
1425
|
return _json_response({"revoked": True, "id": tid})
|
|
1416
1426
|
|
|
1417
1427
|
|
|
1428
|
+
def _refresh_mirror_cache_view(request):
|
|
1429
|
+
"""POST /+admin-api/mirror/{user}/{index}/refresh-cache
|
|
1430
|
+
|
|
1431
|
+
Invalidate the in-memory mirror caches so the next pip request goes
|
|
1432
|
+
back to upstream:
|
|
1433
|
+
|
|
1434
|
+
* ``cache_retrieve_times`` — per-project last-fetch timestamp + etag.
|
|
1435
|
+
Expiring forces a conditional GET on the next ``+simple/<project>/``
|
|
1436
|
+
lookup; etag match still reuses the persisted ``PROJSIMPLELINKS``
|
|
1437
|
+
keyfs entry, so the only cost is one HTTP round-trip per project
|
|
1438
|
+
that pip actually queries (we don't pre-fetch).
|
|
1439
|
+
* ``cache_projectnames`` — full PyPI manifest. Expiring forces the
|
|
1440
|
+
next "list all projects" call to refetch (rare path — mostly used
|
|
1441
|
+
by ``pip install <unknown>`` to discover the project exists).
|
|
1442
|
+
|
|
1443
|
+
Caches are process-local: this only works on the primary (the
|
|
1444
|
+
replica's local cache is meaningless to invalidate, and replicas
|
|
1445
|
+
sync persisted state via the changelog stream). Any authenticated
|
|
1446
|
+
user may trigger the refresh — anonymous spam is blocked by auth,
|
|
1447
|
+
upstream-side abuse is bounded by mirror semantics (etag-conditional
|
|
1448
|
+
requests dominate).
|
|
1449
|
+
"""
|
|
1450
|
+
xom = request.registry["xom"]
|
|
1451
|
+
_refuse_on_replica(xom)
|
|
1452
|
+
_require_authenticated(request)
|
|
1453
|
+
user = request.matchdict["user"]
|
|
1454
|
+
index = request.matchdict["index"]
|
|
1455
|
+
_validate_name(user, "user")
|
|
1456
|
+
_validate_name(index, "index")
|
|
1457
|
+
with xom.keyfs.read_transaction(allow_reuse=True):
|
|
1458
|
+
stage = xom.model.getstage(user, index)
|
|
1459
|
+
if stage is None:
|
|
1460
|
+
raise HTTPNotFound(
|
|
1461
|
+
json_body={"error": f"index {user}/{index} does not exist"})
|
|
1462
|
+
if stage.ixconfig.get("type") != "mirror":
|
|
1463
|
+
raise HTTPBadRequest(json_body={
|
|
1464
|
+
"error": "cache refresh applies to mirror indexes only"})
|
|
1465
|
+
# `_project2time` is the per-xom singleton dict that backs the
|
|
1466
|
+
# per-project cache; iterating its keys lets us expire only entries
|
|
1467
|
+
# devpi has actually populated. The public `expire(project)` API is
|
|
1468
|
+
# safe for non-tracked projects too (it's pop-with-default), so we
|
|
1469
|
+
# don't need to special-case empty caches.
|
|
1470
|
+
crt = stage.cache_retrieve_times
|
|
1471
|
+
projects = list(getattr(crt, "_project2time", {}).keys())
|
|
1472
|
+
for project in projects:
|
|
1473
|
+
crt.expire(project)
|
|
1474
|
+
stage.cache_projectnames.expire()
|
|
1475
|
+
return _json_response({
|
|
1476
|
+
"result": {
|
|
1477
|
+
"projects_invalidated": len(projects),
|
|
1478
|
+
"projectnames_invalidated": True,
|
|
1479
|
+
},
|
|
1480
|
+
})
|
|
1481
|
+
|
|
1482
|
+
|
|
1418
1483
|
def _reset_tokens_view(request):
|
|
1419
1484
|
"""DELETE /+admin-api/users/{user}/tokens — revoke all for a user."""
|
|
1420
1485
|
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;
|
|
@@ -1588,15 +1635,18 @@ body {
|
|
|
1588
1635
|
align-items: center;
|
|
1589
1636
|
justify-content: space-between;
|
|
1590
1637
|
padding: 2px 0;
|
|
1638
|
+
gap: 4px;
|
|
1591
1639
|
}
|
|
1592
1640
|
|
|
1593
1641
|
.pkg-version-link {
|
|
1594
|
-
flex: 1;
|
|
1642
|
+
flex: 1 1 auto;
|
|
1643
|
+
min-width: 0;
|
|
1595
1644
|
padding: 5px 8px;
|
|
1596
1645
|
border-radius: 4px;
|
|
1597
1646
|
font-size: 13px;
|
|
1598
1647
|
color: var(--text-muted);
|
|
1599
1648
|
text-decoration: none;
|
|
1649
|
+
overflow-wrap: anywhere;
|
|
1600
1650
|
}
|
|
1601
1651
|
|
|
1602
1652
|
.pkg-version-link:hover {
|
|
@@ -1619,6 +1669,7 @@ body {
|
|
|
1619
1669
|
font-size: 16px;
|
|
1620
1670
|
line-height: 1;
|
|
1621
1671
|
border-radius: 4px;
|
|
1672
|
+
flex-shrink: 0;
|
|
1622
1673
|
}
|
|
1623
1674
|
|
|
1624
1675
|
.pkg-version-del:hover {
|
|
@@ -1242,10 +1242,65 @@
|
|
|
1242
1242
|
// Token types supported by the unified Issue modal. The selector at the
|
|
1243
1243
|
// top of the modal switches the form between Devpi tokens (multi-index,
|
|
1244
1244
|
// permission checkboxes) and Admin tokens (single index, scope select).
|
|
1245
|
-
//
|
|
1245
|
+
// When the caller doesn't pin a type, the modal defaults to whichever
|
|
1246
|
+
// backend the user *most recently* issued against — saves picking the
|
|
1247
|
+
// same radio every time. Falls back to Devpi (or Admin if the plugin
|
|
1248
|
+
// isn't installed) for users with no tokens yet.
|
|
1246
1249
|
var TOKEN_TYPE_DEVPI = 'devpi';
|
|
1247
1250
|
var TOKEN_TYPE_ADMIN = 'admin';
|
|
1248
1251
|
|
|
1252
|
+
function _detectRecentTokenType(username) {
|
|
1253
|
+
// Returns Promise<TOKEN_TYPE_*|null>. ``null`` means the user has
|
|
1254
|
+
// no tokens (or detection failed) — caller picks a static default.
|
|
1255
|
+
//
|
|
1256
|
+
// Admin tokens carry ``issued_at`` (epoch). Macaroon tokens have
|
|
1257
|
+
// no first-class issuance timestamp; we use ``not_before`` when
|
|
1258
|
+
// present (it tracks the issue moment for the typical "starts
|
|
1259
|
+
// now" flow) and fall back to ``expires`` so a freshly-issued
|
|
1260
|
+
// long-TTL token still wins over an older short-TTL one.
|
|
1261
|
+
var pAdmin = Api.get(
|
|
1262
|
+
'/+admin-api/users/' + encodeURIComponent(username) + '/tokens')
|
|
1263
|
+
.then(function (data) {
|
|
1264
|
+
var tokens = (data && data.result) || [];
|
|
1265
|
+
var max = 0;
|
|
1266
|
+
for (var i = 0; i < tokens.length; i++) {
|
|
1267
|
+
var t = tokens[i].issued_at || 0;
|
|
1268
|
+
if (t > max) max = t;
|
|
1269
|
+
}
|
|
1270
|
+
return max || null;
|
|
1271
|
+
})
|
|
1272
|
+
.catch(function () { return null; });
|
|
1273
|
+
|
|
1274
|
+
var pDevpi = hasDevpiTokens()
|
|
1275
|
+
? Api.get('/' + encodeURIComponent(username) + '/+tokens')
|
|
1276
|
+
.then(function (data) {
|
|
1277
|
+
var tokens = (data && data.result
|
|
1278
|
+
&& data.result.tokens) || {};
|
|
1279
|
+
var max = 0;
|
|
1280
|
+
for (var id in tokens) {
|
|
1281
|
+
if (!Object.prototype.hasOwnProperty.call(
|
|
1282
|
+
tokens, id)) continue;
|
|
1283
|
+
var parsed = parseMacaroonRestrictions(
|
|
1284
|
+
tokens[id].restrictions);
|
|
1285
|
+
var t = parsed.not_before
|
|
1286
|
+
|| parsed.expires || 0;
|
|
1287
|
+
if (t > max) max = t;
|
|
1288
|
+
}
|
|
1289
|
+
return max || null;
|
|
1290
|
+
})
|
|
1291
|
+
.catch(function () { return null; })
|
|
1292
|
+
: Promise.resolve(null);
|
|
1293
|
+
|
|
1294
|
+
return Promise.all([pAdmin, pDevpi]).then(function (results) {
|
|
1295
|
+
var adminTs = results[0];
|
|
1296
|
+
var devpiTs = results[1];
|
|
1297
|
+
if (!adminTs && !devpiTs) return null;
|
|
1298
|
+
if (!adminTs) return TOKEN_TYPE_DEVPI;
|
|
1299
|
+
if (!devpiTs) return TOKEN_TYPE_ADMIN;
|
|
1300
|
+
return adminTs >= devpiTs ? TOKEN_TYPE_ADMIN : TOKEN_TYPE_DEVPI;
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1249
1304
|
// Public entry point. `options` may include:
|
|
1250
1305
|
// • `preselectType` — 'devpi' or 'admin'
|
|
1251
1306
|
// • `preselectIndexes` — list of 'user/index' strings
|
|
@@ -1259,18 +1314,25 @@
|
|
|
1259
1314
|
function showIssueTokenModal(username, options) {
|
|
1260
1315
|
options = options || {};
|
|
1261
1316
|
var presel = (options.preselectIndexes || []).slice();
|
|
1262
|
-
var
|
|
1317
|
+
var explicitType = options.preselectType || null;
|
|
1263
1318
|
_issueReturnTo = options.returnTo || function () {
|
|
1264
1319
|
showTokensModal(username);
|
|
1265
1320
|
};
|
|
1266
1321
|
_issueLockedIndex = (options.lockIndex && presel.length)
|
|
1267
1322
|
? presel[0] : null;
|
|
1268
|
-
//
|
|
1269
|
-
//
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1323
|
+
// Caller pinned the type → honour it; otherwise detect from the
|
|
1324
|
+
// user's existing tokens (most-recently-issued backend wins).
|
|
1325
|
+
// Detection failure falls back to Devpi (or Admin if the plugin
|
|
1326
|
+
// isn't installed).
|
|
1327
|
+
var pType = explicitType
|
|
1328
|
+
? Promise.resolve(explicitType)
|
|
1329
|
+
: _detectRecentTokenType(username);
|
|
1330
|
+
Promise.all([fetchRoot(), pType]).then(function (results) {
|
|
1331
|
+
var rootResult = results[0];
|
|
1332
|
+
var preselType = results[1] || TOKEN_TYPE_DEVPI;
|
|
1333
|
+
if (preselType === TOKEN_TYPE_DEVPI && !hasDevpiTokens()) {
|
|
1334
|
+
preselType = TOKEN_TYPE_ADMIN;
|
|
1335
|
+
}
|
|
1274
1336
|
var indexInfos = getAllIndexes(rootResult);
|
|
1275
1337
|
var aclByIndex = {};
|
|
1276
1338
|
// Indexes accessible to the bound user: ones they own, ones
|
|
@@ -1304,11 +1366,13 @@
|
|
|
1304
1366
|
};
|
|
1305
1367
|
_renderIssueTokenModal(username, presel, preselType);
|
|
1306
1368
|
}).catch(function () {
|
|
1369
|
+
var fallbackType = explicitType
|
|
1370
|
+
|| (hasDevpiTokens() ? TOKEN_TYPE_DEVPI : TOKEN_TYPE_ADMIN);
|
|
1307
1371
|
_issueContext = {
|
|
1308
1372
|
accessibleIndexes: presel.slice(),
|
|
1309
1373
|
aclByIndex: {},
|
|
1310
1374
|
};
|
|
1311
|
-
_renderIssueTokenModal(username, presel,
|
|
1375
|
+
_renderIssueTokenModal(username, presel, fallbackType);
|
|
1312
1376
|
});
|
|
1313
1377
|
}
|
|
1314
1378
|
|
|
@@ -2357,7 +2421,13 @@
|
|
|
2357
2421
|
closeModal();
|
|
2358
2422
|
updateAuthUI();
|
|
2359
2423
|
navigate();
|
|
2360
|
-
|
|
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);
|
|
2361
2431
|
})
|
|
2362
2432
|
.catch(showModalError);
|
|
2363
2433
|
}
|
|
@@ -2761,47 +2831,61 @@
|
|
|
2761
2831
|
|
|
2762
2832
|
// Card header: name + type badge
|
|
2763
2833
|
var cardHead = el('div', {className: 'index-card-head'});
|
|
2764
|
-
|
|
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', {
|
|
2765
2840
|
href: '#indexes/' + idx._user,
|
|
2766
2841
|
className: 'index-card-owner',
|
|
2767
2842
|
textContent: idx._user,
|
|
2768
2843
|
}));
|
|
2769
|
-
|
|
2844
|
+
pathWrap.appendChild(el('span', {
|
|
2770
2845
|
className: 'index-card-sep',
|
|
2771
2846
|
textContent: '/',
|
|
2772
2847
|
}));
|
|
2773
|
-
|
|
2848
|
+
pathWrap.appendChild(el('a', {
|
|
2774
2849
|
href: '#packages/' + idx._full,
|
|
2775
2850
|
className: 'index-card-name',
|
|
2776
2851
|
textContent: idx._name,
|
|
2777
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.
|
|
2778
2857
|
var tagGroup = el('div', {className: 'index-card-tags'});
|
|
2779
2858
|
tagGroup.appendChild(el('span', {
|
|
2780
|
-
className: 'tag
|
|
2781
|
-
|
|
2859
|
+
className: 'tag tag-letter'
|
|
2860
|
+
+ (isMirror ? ' tag-mirror' : ' tag-stage'),
|
|
2861
|
+
textContent: isMirror ? 'M' : 'S',
|
|
2862
|
+
'data-tooltip': idx.type || 'stage',
|
|
2782
2863
|
}));
|
|
2783
2864
|
if (!isMirror && idx.volatile) {
|
|
2784
2865
|
tagGroup.appendChild(el('span', {
|
|
2785
|
-
className: 'tag tag-volatile',
|
|
2786
|
-
textContent: '
|
|
2866
|
+
className: 'tag tag-letter tag-volatile',
|
|
2867
|
+
textContent: 'V',
|
|
2868
|
+
'data-tooltip': 'volatile — uploads may overwrite '
|
|
2869
|
+
+ 'existing versions',
|
|
2787
2870
|
}));
|
|
2788
2871
|
}
|
|
2789
2872
|
if (!isMirror && isAnonymousAclUpload(idx.acl_upload)) {
|
|
2790
2873
|
tagGroup.appendChild(el('span', {
|
|
2791
|
-
className: 'tag tag-world-writable',
|
|
2792
|
-
textContent: '
|
|
2793
|
-
|
|
2794
|
-
+ '
|
|
2795
|
-
+ '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.',
|
|
2796
2880
|
}));
|
|
2797
2881
|
} else if (!isMirror && isUploadFrozen(idx.acl_upload)) {
|
|
2798
2882
|
tagGroup.appendChild(el('span', {
|
|
2799
|
-
className: 'tag tag-no-upload',
|
|
2800
|
-
textContent: '
|
|
2801
|
-
|
|
2802
|
-
+ 'to this index, not even
|
|
2803
|
-
+ 'Add a principal to
|
|
2804
|
-
+ '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.',
|
|
2805
2889
|
}));
|
|
2806
2890
|
}
|
|
2807
2891
|
cardHead.appendChild(tagGroup);
|
|
@@ -2895,6 +2979,22 @@
|
|
|
2895
2979
|
});
|
|
2896
2980
|
})(idx);
|
|
2897
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
|
+
}
|
|
2898
2998
|
if (menuItems.length) {
|
|
2899
2999
|
cardHead.appendChild(buildKebabMenu(menuItems));
|
|
2900
3000
|
}
|
|
@@ -3159,6 +3259,51 @@
|
|
|
3159
3259
|
.catch(handleApiError);
|
|
3160
3260
|
}
|
|
3161
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
|
+
|
|
3162
3307
|
// ========== PACKAGES ==========
|
|
3163
3308
|
|
|
3164
3309
|
var PKG_LIMIT = 100;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devpi-admin
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
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`
|
|
@@ -277,6 +277,23 @@ class UnifiedIssueModalTests(unittest.TestCase):
|
|
|
277
277
|
def test_unified_entry_point_exists(self):
|
|
278
278
|
self.assertIn("function showIssueTokenModal(username, options)", self.js)
|
|
279
279
|
|
|
280
|
+
def test_recent_token_type_detector_present(self):
|
|
281
|
+
# Auto-preselect: when caller doesn't pin a type, pick the
|
|
282
|
+
# backend the user most recently issued against.
|
|
283
|
+
self.assertIn("function _detectRecentTokenType(username)", self.js)
|
|
284
|
+
# Admin tokens compared by issued_at, macaroons by
|
|
285
|
+
# not_before / expires.
|
|
286
|
+
self.assertIn("tokens[i].issued_at", self.js)
|
|
287
|
+
self.assertIn("parsed.not_before", self.js)
|
|
288
|
+
|
|
289
|
+
def test_issue_modal_uses_detector_when_caller_is_silent(self):
|
|
290
|
+
# `options.preselectType` short-circuits detection; absence
|
|
291
|
+
# triggers _detectRecentTokenType.
|
|
292
|
+
self.assertIn("var explicitType = options.preselectType || null;",
|
|
293
|
+
self.js)
|
|
294
|
+
self.assertIn("explicitType\n ? Promise.resolve(explicitType)\n"
|
|
295
|
+
" : _detectRecentTokenType(username);", self.js)
|
|
296
|
+
|
|
280
297
|
def test_unified_listing_has_issue_button(self):
|
|
281
298
|
# Single Issue entry point on the unified per-user Tokens modal —
|
|
282
299
|
# no preselectType so the user picks devpi/admin in the form.
|
|
@@ -0,0 +1,151 @@
|
|
|
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)
|
|
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
|
+
if __name__ == "__main__":
|
|
151
|
+
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
|