mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-18 14:32:43 +00:00
A plugin reload silently did nothing for scriptType:"module" plugins. ES modules are evaluated ONCE PER URL PER DOCUMENT, so re-inserting a <script type="module"> whose src the module map has already seen fires `load` without re-running the body — and the loader then recorded the reload as applied. A no-op that reported success. THE ISSUE UNDERSTATES IT. #879 says "upgrades are fine — a new version yields a new URL". That is true of screen.js and FALSE of the plugin. I drove a real browser through install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0), counting evaluations of src/main.js: ONE. Not three, not two. The upgrade re-runs the one-line screen.js shim at its new ?v= URL; the shim does `import './src/main.js'`; a relative specifier resolves against the base URL WITH THE QUERY DROPPED; that is the same URL as before; the module map hands back the already-evaluated v1.0.0 module. The plugin's own code never re-ran. Busting the entry point cannot fix this, whatever token you hang off it. So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. From there './src/main.js' resolves to /api/plugins/<id>/g/<n>/src/main.js — every relative import inherits it, at every depth, for free. No import-specifier rewriting (which could never see `import(expr)` anyway). Same browser drive after the fix: THREE evaluations. Keyed on the plugin ID, not id@version: EVERY re-load of a module plugin needs a fresh path, not just a rollback. First load keeps the stable ?v= URL, so the ETag/304 live-edit caching the R0 rails depend on is untouched. Classic-script plugins are not affected and never take a /g/ path. ━━━ A PATH REWRITE, NOT TWO MIRRORED ROUTES ━━━ Codex caught this, and it was right. The token shifts the BASE URL, so EVERYTHING the module graph resolves relatively moves with it — not only imports. `new URL('../assets/worklet.js', import.meta.url)` from /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/worklet.js. Mirroring only screen.js and src/ would have fixed imports and 404'd every asset, worklet and wasm file the graph reaches — and would have broken again the next time someone added a plugin route. So the /g/<token> segment is STRIPPED BEFORE ROUTING. Every plugin route, present and future, works under the prefix with no extra wiring. The token is opaque and never joined into a filesystem path, so containment still rests entirely on the same safe_join. Codex then caught a [P3] in that: eagerly re-encoding raw_path with latin-1 raises UnicodeEncodeError on a valid plugin file like src/工具.js, 500ing a request the plain route serves fine. raw_path is informational and Starlette routes on scope["path"], so the mutation is simply gone — and leaving raw_path as the client sent it is more truthful for logs anyway. TESTS. tests/js/plugin_module_rollback.test.js (5) + 8 in test_plugin_src_route.py: identical bytes under the prefix, the whole graph one and two levels deep, ASSETS (the Codex [P2]), every plugin route, non-ASCII filenames (the [P3]), an opaque token, and containment asserted as PARITY with the un-prefixed route rather than a guessed 404 — `../screen.js` legitimately 200s on both, because the URL normalises before routing. All bite-tested: reverting the fix fails the rollback tests, disabling the rewrite fails the asset tests. Two harnesses re-anchored on `script.src = _pluginScriptUrl(` — the URL literal they keyed on now lives in the helper, further down the file, so their slice ran off the end. node 1045, pytest 2404, ESLint 0, Codex 0. Closes #879 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bd830328f0
commit
756588678b
@@ -6,6 +6,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -2384,6 +2385,53 @@ def register_plugin_api(app: FastAPI):
|
|||||||
return _plugin_file_response(request, script_file, "application/javascript")
|
return _plugin_file_response(request, script_file, "application/javascript")
|
||||||
return Response("", status_code=404)
|
return Response("", status_code=404)
|
||||||
|
|
||||||
|
# ── Module-graph cache busting (#879) ────────────────────────────────
|
||||||
|
#
|
||||||
|
# ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||||
|
# <script type="module"> whose src the module map has already seen fires
|
||||||
|
# `load` but does NOT re-run the body. So re-loading a plugin — a rollback,
|
||||||
|
# and (see below) an upgrade too — silently kept the OLD module live while
|
||||||
|
# the loader recorded success: a no-op that reported it worked.
|
||||||
|
#
|
||||||
|
# Busting the ENTRY url does not help. A module plugin's screen.js is a
|
||||||
|
# one-line `import './src/main.js'`, and a relative specifier resolves
|
||||||
|
# against the base URL WITH THE QUERY DROPPED — so a ?v= token never reaches
|
||||||
|
# the graph. Driving a real browser through install -> upgrade -> rollback and
|
||||||
|
# counting evaluations of src/main.js gives ONE. The upgrade re-runs the shim
|
||||||
|
# at its new ?v= URL; the shim imports './src/main.js'; that resolves to the
|
||||||
|
# same URL; the module map returns the already-evaluated old module.
|
||||||
|
#
|
||||||
|
# So the token goes in the PATH: /api/plugins/<id>/g/<n>/screen.js. Every
|
||||||
|
# relative import inherits it at every depth — for free, with no
|
||||||
|
# import-specifier rewriting (which could never see `import(expr)` anyway).
|
||||||
|
#
|
||||||
|
# WHY A PATH REWRITE AND NOT TWO MIRRORED ROUTES. The token shifts the base
|
||||||
|
# URL, so EVERYTHING a module resolves relatively moves with it — not just
|
||||||
|
# imports. `new URL('../assets/worklet.js', import.meta.url)` from
|
||||||
|
# /api/plugins/x/g/1/src/main.js resolves to /api/plugins/x/g/1/assets/... .
|
||||||
|
# Mirroring only screen.js and src/ would fix imports and 404 every asset,
|
||||||
|
# worklet and wasm file the graph reaches — and would silently break again the
|
||||||
|
# next time someone adds a plugin route. Stripping the segment before routing
|
||||||
|
# makes every plugin route, present and future, work under the prefix.
|
||||||
|
#
|
||||||
|
# The token is opaque: it is never joined into a filesystem path (and is gone
|
||||||
|
# by the time any handler runs), so containment still rests entirely on the
|
||||||
|
# same safe_join the un-prefixed routes use.
|
||||||
|
_GEN_PREFIX = re.compile(r"^(/api/plugins/[^/]+)/g/[^/]+(/.+)$")
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def _strip_plugin_generation_prefix(request: Request, call_next):
|
||||||
|
m = _GEN_PREFIX.match(request.scope.get("path", ""))
|
||||||
|
if m:
|
||||||
|
# Starlette routes on scope["path"] alone. raw_path is deliberately left
|
||||||
|
# ALONE: it is informational, and re-encoding the rewritten str back to
|
||||||
|
# bytes would have to guess a codec — `.encode("latin-1")` raises
|
||||||
|
# UnicodeEncodeError on a perfectly valid plugin file like src/工具.js,
|
||||||
|
# 500ing a request the un-prefixed route serves fine. Leaving raw_path as
|
||||||
|
# the client actually sent it is also simply more truthful for logs.
|
||||||
|
request.scope["path"] = m.group(1) + m.group(2)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
@app.get("/api/plugins/{plugin_id}/settings.html")
|
@app.get("/api/plugins/{plugin_id}/settings.html")
|
||||||
def plugin_settings_html(plugin_id: str):
|
def plugin_settings_html(plugin_id: str):
|
||||||
with PLUGINS_LOCK:
|
with PLUGINS_LOCK:
|
||||||
|
|||||||
@@ -654,7 +654,8 @@ export async function loadPlugins() {
|
|||||||
// of a cached copy keyed only by path (matches the art
|
// of a cached copy keyed only by path (matches the art
|
||||||
// URL ?v=mtime convention elsewhere in this file).
|
// URL ?v=mtime convention elsewhere in this file).
|
||||||
const v = encodeURIComponent(wantedVersion);
|
const v = encodeURIComponent(wantedVersion);
|
||||||
script.src = `/api/plugins/${plugin.id}/screen.js${v ? `?v=${v}` : ''}`;
|
const query = v ? `?v=${v}` : '';
|
||||||
|
script.src = _pluginScriptUrl(plugin, wantedVersion, query);
|
||||||
// Module-migration (R0): a migrated plugin declares
|
// Module-migration (R0): a migrated plugin declares
|
||||||
// scriptType:"module" and its screen.js is `import
|
// scriptType:"module" and its screen.js is `import
|
||||||
// './src/main.js'`. A <script type="module"> fires load
|
// './src/main.js'`. A <script type="module"> fires load
|
||||||
@@ -844,6 +845,56 @@ export async function checkPluginUpdates() {
|
|||||||
btn.textContent = 'Check for Updates';
|
btn.textContent = 'Check for Updates';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Module re-evaluation (#879) ─────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// ES modules are evaluated ONCE PER URL PER DOCUMENT. Re-inserting a
|
||||||
|
// <script type="module"> whose src the module map has already seen fires `load` but
|
||||||
|
// does NOT re-run the body. So a ROLLBACK — reloading a version already evaluated
|
||||||
|
// this session — silently kept the OLD module live, while onload fired and
|
||||||
|
// loadedScripts recorded the rollback as applied. A no-op that reported success.
|
||||||
|
// (Upgrades were fine: a new version means a new ?v=, hence a new URL.)
|
||||||
|
//
|
||||||
|
// Busting the ENTRY url alone does NOT fix it. A module plugin's screen.js is a
|
||||||
|
// one-line `import './src/main.js'`, and a relative specifier resolves against the
|
||||||
|
// base URL WITH THE QUERY STRING DROPPED — so ?v= never reaches the graph, and
|
||||||
|
// src/main.js (where the plugin actually lives) stays cached no matter what we hang
|
||||||
|
// off screen.js.
|
||||||
|
//
|
||||||
|
// So the token goes in the PATH. From /api/plugins/x/g/7/screen.js, './src/main.js'
|
||||||
|
// resolves to /api/plugins/x/g/7/src/main.js — every relative import in the graph
|
||||||
|
// inherits it, at every depth, with no import-specifier rewriting (which could not
|
||||||
|
// see `import(expr)` anyway). The server ignores the token and serves identical
|
||||||
|
// bytes.
|
||||||
|
//
|
||||||
|
// ─── AND THE UPGRADE PATH WAS BROKEN TOO ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// #879 says "upgrades are fine — a new version yields a new URL". That is true of
|
||||||
|
// screen.js and FALSE of the plugin. Driving a real browser through
|
||||||
|
// install(1.0.0) -> upgrade(1.1.0) -> rollback(1.0.0) and counting evaluations of
|
||||||
|
// src/main.js gives ONE. Not two, not three: ONE. The upgrade re-evaluates the
|
||||||
|
// one-line screen.js shim at its new ?v= URL, that shim imports './src/main.js',
|
||||||
|
// that resolves to the same URL as before, and the module map hands back the
|
||||||
|
// ALREADY-EVALUATED v1.0.0 module. The plugin's actual code never re-ran.
|
||||||
|
//
|
||||||
|
// So the generation token is not a rollback special case. EVERY re-load of a module
|
||||||
|
// plugin needs it — the key is the plugin id, NOT id@version. Only the first load of
|
||||||
|
// a given plugin in this document takes the stable URL, which is what keeps the
|
||||||
|
// ETag/304 live-edit contract the R0 rails depend on.
|
||||||
|
const _evaluatedModules = new Set(); // plugin ids whose module graph is live in this document
|
||||||
|
let _moduleReloadSeq = 0;
|
||||||
|
|
||||||
|
function _pluginScriptUrl(plugin, wantedVersion, query) {
|
||||||
|
const base = `/api/plugins/${plugin.id}/screen.js${query}`;
|
||||||
|
if (plugin.script_type !== 'module') return base; // classic scripts always re-run
|
||||||
|
if (!_evaluatedModules.has(plugin.id)) {
|
||||||
|
_evaluatedModules.add(plugin.id);
|
||||||
|
return base; // first load: stable URL, 304-able
|
||||||
|
}
|
||||||
|
// Re-load of a module plugin — upgrade OR rollback. Its graph is already in the
|
||||||
|
// module map, so it needs an entirely fresh path or nothing below screen.js re-runs.
|
||||||
|
return `/api/plugins/${plugin.id}/g/${++_moduleReloadSeq}/screen.js${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updatePlugin(pluginId, btn) {
|
export async function updatePlugin(pluginId, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Updating...';
|
btn.textContent = 'Updating...';
|
||||||
|
|||||||
@@ -98,7 +98,10 @@ function region(src, needle, length = 1200) {
|
|||||||
|
|
||||||
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
test('plugin script hydration exposes the current plugin id for legacy registrations', () => {
|
||||||
const src = source(PLUGIN_LOADER_JS);
|
const src = source(PLUGIN_LOADER_JS);
|
||||||
const block = region(src, 'script.src = `/api/plugins/${plugin.id}/screen.js');
|
// Anchored on the ASSIGNMENT, not the URL literal: the URL is built in
|
||||||
|
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL for the whole
|
||||||
|
// import graph), so the old literal no longer appears at the injection site.
|
||||||
|
const block = region(src, 'script.src = _pluginScriptUrl(');
|
||||||
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
assert.match(block, /window\.feedBack\._loadingPluginId\s*=\s*plugin\.id/);
|
||||||
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
assert.match(block, /delete\s+window\.feedBack\._loadingPluginId/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,13 +19,19 @@ const path = require('node:path');
|
|||||||
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
const PLUGIN_LOADER_JS = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||||
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
const src = fs.readFileSync(PLUGIN_LOADER_JS, 'utf8');
|
||||||
|
|
||||||
// Isolate the screen.js <script> injection block: from where its src is built
|
// Isolate the screen.js <script> injection block: from where its src is assigned to
|
||||||
// to where the element is appended.
|
// where the element is appended.
|
||||||
|
//
|
||||||
|
// Anchored on the ASSIGNMENT, not on the URL literal. The URL is built in
|
||||||
|
// _pluginScriptUrl() now (#879 — a rollback needs a fresh module URL), so the literal
|
||||||
|
// '/api/plugins/${plugin.id}/screen.js' appears FURTHER DOWN the file than the block
|
||||||
|
// that uses it, and slicing from it ran off the end of the injection block entirely.
|
||||||
|
const SRC_ASSIGN = 'script.src = _pluginScriptUrl(';
|
||||||
function injectionBlock() {
|
function injectionBlock() {
|
||||||
const start = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
const start = src.indexOf(SRC_ASSIGN);
|
||||||
assert.ok(start !== -1, 'screen.js injection src not found — loader moved?');
|
assert.ok(start !== -1, 'screen.js src assignment not found — loader moved?');
|
||||||
const end = src.indexOf('document.body.appendChild(script)', start);
|
const end = src.indexOf('document.body.appendChild(script)', start);
|
||||||
assert.ok(end !== -1, 'appendChild(script) not found after screen.js src');
|
assert.ok(end !== -1, 'appendChild(script) not found after the src assignment');
|
||||||
return src.slice(start, end);
|
return src.slice(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +58,7 @@ test('the module type is gated, never set unconditionally', () => {
|
|||||||
|
|
||||||
test('the module guard sits before appendChild, after the src assignment', () => {
|
test('the module guard sits before appendChild, after the src assignment', () => {
|
||||||
const guardAt = src.indexOf('script.type = \'module\'');
|
const guardAt = src.indexOf('script.type = \'module\'');
|
||||||
const srcAt = src.indexOf('/api/plugins/${plugin.id}/screen.js');
|
const srcAt = src.indexOf(SRC_ASSIGN);
|
||||||
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
|
const appendAt = src.indexOf('document.body.appendChild(script)', srcAt);
|
||||||
assert.ok(guardAt > srcAt && guardAt < appendAt,
|
assert.ok(guardAt > srcAt && guardAt < appendAt,
|
||||||
'the module guard must live inside the screen.js injection block');
|
'the module guard must live inside the screen.js injection block');
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// #879 — a plugin ROLLBACK must actually re-evaluate a module plugin.
|
||||||
|
//
|
||||||
|
// ES modules are evaluated once per URL per document. Re-inserting a
|
||||||
|
// <script type="module"> whose src the module map has already seen fires `load` but
|
||||||
|
// does NOT re-run the body — so rolling back to a version already evaluated this
|
||||||
|
// session left the OLD module live while the loader recorded success.
|
||||||
|
//
|
||||||
|
// The fix puts a generation token in the PATH (/api/plugins/x/g/7/screen.js), not the
|
||||||
|
// query, because a relative specifier resolves against the base URL with the query
|
||||||
|
// DROPPED — so './src/main.js' would otherwise keep resolving to the same cached URL
|
||||||
|
// and the plugin's actual code would never re-run.
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const { extractFunction } = require('./test_utils');
|
||||||
|
const LOADER = path.join(__dirname, '..', '..', 'static', 'js', 'plugin-loader.js');
|
||||||
|
|
||||||
|
function makeUrlBuilder() {
|
||||||
|
const src = fs.readFileSync(LOADER, 'utf8');
|
||||||
|
const sandbox = { _evaluatedModules: new Set(), _moduleReloadSeq: 0 };
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(`
|
||||||
|
${extractFunction(src, 'function _pluginScriptUrl(')}
|
||||||
|
globalThis.url = _pluginScriptUrl;
|
||||||
|
`, sandbox);
|
||||||
|
return sandbox.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOD = { id: 'editor', script_type: 'module' };
|
||||||
|
const CLASSIC = { id: 'legacy', script_type: 'classic' };
|
||||||
|
|
||||||
|
test('a module plugin first load uses the stable ?v= URL (ETag/304 stays intact)', () => {
|
||||||
|
const url = makeUrlBuilder();
|
||||||
|
assert.equal(url(MOD, '1.0.0', '?v=1.0.0'), '/api/plugins/editor/screen.js?v=1.0.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// An UPGRADE has to bust the graph too, and this is the part #879 got wrong. It says
|
||||||
|
// "upgrades are fine — a new version yields a new URL". True of screen.js; FALSE of the
|
||||||
|
// plugin. Driving a real browser through install -> upgrade -> rollback and counting
|
||||||
|
// evaluations of src/main.js gives ONE: the upgrade re-runs the one-line screen.js shim
|
||||||
|
// at its new ?v= URL, the shim imports './src/main.js', that resolves to the SAME url,
|
||||||
|
// and the module map hands back the already-evaluated old module. So the key here is the
|
||||||
|
// plugin ID, not id@version — every re-load of a module plugin needs a fresh path.
|
||||||
|
test('an UPGRADE also gets a fresh /g/<n>/ path — a new ?v= does NOT reach the graph', () => {
|
||||||
|
const url = makeUrlBuilder();
|
||||||
|
url(MOD, '1.0.0', '?v=1.0.0');
|
||||||
|
assert.equal(url(MOD, '1.1.0', '?v=1.1.0'), '/api/plugins/editor/g/1/screen.js?v=1.1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ROLLBACK to an already-evaluated version gets a fresh /g/<n>/ PATH', () => {
|
||||||
|
const url = makeUrlBuilder();
|
||||||
|
url(MOD, '1.0.0', '?v=1.0.0'); // installed
|
||||||
|
url(MOD, '1.1.0', '?v=1.1.0'); // upgraded -> /g/1/
|
||||||
|
const back = url(MOD, '1.0.0', '?v=1.0.0'); // rolled back -> /g/2/
|
||||||
|
assert.equal(back, '/api/plugins/editor/g/2/screen.js?v=1.0.0');
|
||||||
|
|
||||||
|
// The token must be in the PATH so a relative import INHERITS it — the whole point.
|
||||||
|
// A query token is dropped by URL resolution and never reaches src/main.js.
|
||||||
|
const resolved = new URL('./src/main.js', `http://h${back}`).pathname;
|
||||||
|
assert.equal(resolved, '/api/plugins/editor/g/2/src/main.js',
|
||||||
|
'the token must reach the module GRAPH, not just the entry point');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every re-load gets a distinct URL (no reuse across a bounce)', () => {
|
||||||
|
const url = makeUrlBuilder();
|
||||||
|
url(MOD, '1.0.0', '?v=1.0.0');
|
||||||
|
const seen = new Set();
|
||||||
|
for (const v of ['1.1.0', '1.0.0', '1.1.0', '1.0.0']) seen.add(url(MOD, v, `?v=${v}`));
|
||||||
|
assert.equal(seen.size, 4, 'each re-load must be a URL the module map has never seen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('classic-script plugins are untouched — they always re-run on re-insert', () => {
|
||||||
|
const url = makeUrlBuilder();
|
||||||
|
const first = url(CLASSIC, '1.0.0', '?v=1.0.0');
|
||||||
|
url(CLASSIC, '1.1.0', '?v=1.1.0');
|
||||||
|
const back = url(CLASSIC, '1.0.0', '?v=1.0.0');
|
||||||
|
assert.equal(first, '/api/plugins/legacy/screen.js?v=1.0.0');
|
||||||
|
assert.equal(back, first, 'a classic script needs no cache-busting and must not get a /g/ path');
|
||||||
|
});
|
||||||
@@ -138,3 +138,119 @@ def test_unready_plugin_src_is_404(client):
|
|||||||
c, _ = client
|
c, _ = client
|
||||||
plugins.LOADED_PLUGINS[0]["status"] = "installing"
|
plugins.LOADED_PLUGINS[0]["status"] = "installing"
|
||||||
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").status_code == 404
|
assert c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── #879: the /g/<token>/ generation prefix ────────────────────────────────────
|
||||||
|
#
|
||||||
|
# A plugin ROLLBACK must actually re-evaluate a module plugin. ES modules are
|
||||||
|
# evaluated once per URL per document, so re-inserting a <script type="module">
|
||||||
|
# whose src the module map has already seen fires `load` without re-running the
|
||||||
|
# body. Busting the ENTRY url alone does not help — screen.js is a one-line
|
||||||
|
# `import './src/main.js'`, and a relative specifier resolves against the base URL
|
||||||
|
# with the QUERY DROPPED, so a ?v= token never reaches the graph.
|
||||||
|
#
|
||||||
|
# Hence a token in the PATH: every relative import inherits it, at every depth,
|
||||||
|
# with no import-specifier rewriting. These routes must serve the SAME bytes and
|
||||||
|
# keep the SAME containment.
|
||||||
|
|
||||||
|
def test_generation_prefix_serves_identical_screen_js(client):
|
||||||
|
c, _ = client
|
||||||
|
plain = c.get(f"/api/plugins/{PLUGIN_ID}/screen.js")
|
||||||
|
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/screen.js")
|
||||||
|
assert gen.status_code == 200
|
||||||
|
assert gen.content == plain.content
|
||||||
|
assert "import './src/main.js'" in gen.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_prefix_serves_the_whole_module_graph(client):
|
||||||
|
"""The point of the path token: a relative import from a /g/7/ entry resolves
|
||||||
|
to a /g/7/ URL, so the graph is fetched fresh — not just the entry."""
|
||||||
|
c, _ = client
|
||||||
|
main = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/src/main.js")
|
||||||
|
assert main.status_code == 200
|
||||||
|
assert main.text == c.get(f"/api/plugins/{PLUGIN_ID}/src/main.js").text
|
||||||
|
# and one level deeper, which is where a query-string token would already have
|
||||||
|
# been lost twice over
|
||||||
|
nested = c.get(f"/api/plugins/{PLUGIN_ID}/g/7/src/util/x.js")
|
||||||
|
assert nested.status_code == 200
|
||||||
|
assert "export const x = 42" in nested.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_token_is_opaque(client):
|
||||||
|
"""Any token serves the same bytes — it exists only to vary the URL."""
|
||||||
|
c, _ = client
|
||||||
|
a = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/main.js")
|
||||||
|
b = c.get(f"/api/plugins/{PLUGIN_ID}/g/999999/src/main.js")
|
||||||
|
assert a.status_code == b.status_code == 200
|
||||||
|
assert a.text == b.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_prefix_does_not_widen_containment(client):
|
||||||
|
"""The token is never joined into a path, so containment must be EXACTLY what the
|
||||||
|
un-prefixed route already gives. Asserted as parity rather than as a flat 404:
|
||||||
|
`../screen.js` legitimately 200s on BOTH, because the URL normalises to
|
||||||
|
/api/plugins/<id>/screen.js before routing ever happens — it never leaves the
|
||||||
|
plugin dir. Pinning an absolute expectation here would have encoded my guess
|
||||||
|
about the existing route instead of testing the thing that matters, which is
|
||||||
|
that /g/ changes nothing."""
|
||||||
|
c, _ = client
|
||||||
|
for bad in ("../screen.js", "../../etc/passwd", "..%2f..%2fetc%2fpasswd",
|
||||||
|
"..%5c..%5cwindows%5cwin.ini", "/etc/passwd"):
|
||||||
|
plain = c.get(f"/api/plugins/{PLUGIN_ID}/src/{bad}")
|
||||||
|
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/{bad}")
|
||||||
|
assert gen.status_code == plain.status_code, f"/g/ diverged on {bad!r}"
|
||||||
|
assert gen.content == plain.content, f"/g/ served different bytes for {bad!r}"
|
||||||
|
assert "root:" not in gen.text and "[extensions]" not in gen.text
|
||||||
|
|
||||||
|
# and the real traversals are genuinely rejected, on both
|
||||||
|
for bad in ("../../etc/passwd", "..%2f..%2fetc%2fpasswd"):
|
||||||
|
assert c.get(f"/api/plugins/{PLUGIN_ID}/g/1/src/{bad}").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_prefix_404s_for_unknown_plugin(client):
|
||||||
|
c, _ = client
|
||||||
|
assert c.get("/api/plugins/nope/g/1/screen.js").status_code == 404
|
||||||
|
assert c.get("/api/plugins/nope/g/1/src/main.js").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_prefix_serves_ASSETS_too(client):
|
||||||
|
"""Codex [P2] on the first cut of this fix, and it was right.
|
||||||
|
|
||||||
|
The path token shifts the BASE URL, so everything a module resolves relatively moves
|
||||||
|
with it — not just imports. `new URL('../assets/worklet.js', import.meta.url)` from
|
||||||
|
/api/plugins/<id>/g/1/src/main.js resolves to /api/plugins/<id>/g/1/assets/worklet.js.
|
||||||
|
Mirroring only screen.js and src/ would have fixed imports and 404'd every asset,
|
||||||
|
worklet and wasm file the graph reaches. Hence a path REWRITE, so every plugin route
|
||||||
|
— present and future — works under the prefix."""
|
||||||
|
c, _ = client
|
||||||
|
plain = c.get(f"/api/plugins/{PLUGIN_ID}/assets/worklet.js")
|
||||||
|
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/1/assets/worklet.js")
|
||||||
|
assert plain.status_code == 200
|
||||||
|
assert gen.status_code == 200, "an asset reached relatively from a reloaded module graph 404'd"
|
||||||
|
assert gen.content == plain.content
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_prefix_covers_every_plugin_route(client):
|
||||||
|
"""The rewrite is generic, so this holds for routes nobody thought about — which is
|
||||||
|
the point. Any plugin route added later works under /g/ with no extra wiring."""
|
||||||
|
c, _ = client
|
||||||
|
for route in ("screen.js", "src/main.js", "src/util/x.js", "src/theme.css",
|
||||||
|
"assets/worklet.js", "settings.html"):
|
||||||
|
plain = c.get(f"/api/plugins/{PLUGIN_ID}/{route}")
|
||||||
|
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/42/{route}")
|
||||||
|
assert gen.status_code == plain.status_code, f"/g/ diverged on {route}"
|
||||||
|
assert gen.content == plain.content, f"/g/ served different bytes for {route}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generation_prefix_handles_non_ascii_filenames(client):
|
||||||
|
"""Codex [P3] on the second cut. A plugin file named e.g. src/工具.js is perfectly
|
||||||
|
valid, and the middleware must not 500 on it — which an eager
|
||||||
|
raw_path.encode("latin-1") did, making the prefixed route LESS capable than the
|
||||||
|
plain one. raw_path is informational; Starlette routes on scope["path"]."""
|
||||||
|
c, tmp = client
|
||||||
|
(tmp / "src" / "工具.js").write_text("export const t = 1;\n")
|
||||||
|
plain = c.get(f"/api/plugins/{PLUGIN_ID}/src/工具.js")
|
||||||
|
gen = c.get(f"/api/plugins/{PLUGIN_ID}/g/3/src/工具.js")
|
||||||
|
assert plain.status_code == 200
|
||||||
|
assert gen.status_code == 200, "non-ASCII module path 500'd or 404'd under /g/"
|
||||||
|
assert gen.content == plain.content
|
||||||
|
|||||||
Reference in New Issue
Block a user