feat(modules): install, uninstall, purge and restart (phase 4, slice 1)
The consumer half of a release module-uo's CI has been publishing since
phase 3 closed. Before this, core had the installed_modules provenance
columns and no code that could ever fill them: nothing fetched, verified,
unpacked, removed or purged anything, and there was no admin route at all.
Adds modules/archive.js, modules/install.js, schema.runPurge(),
lifecycle.stop(), loader.stopHook(), and /api/v1/admin/modules with eight
routes. 797 server tests (+76), manifest 158 -> 166 + 2 internal, OpenAPI
gains 8 operations and loses nothing.
Reject, never sanitise
----------------------
The download is the easy part: an https-only allowlist re-checked on every
redirect hop, a declared sha256 compared against the bytes that arrived, and
a byte cap. Unpacking is where the archive chooses the filenames, and core
writes into a directory bind-mounted from the host, so an escape is not
confined to the container.
archive.js inspects the whole archive before a byte is unpacked and refuses
absolute and drive-absolute paths, `..` segments, NUL bytes, backslashes,
anything that is not a regular file or a directory, more than one top-level
entry, and anything over the entry or byte caps. Refusing symlinks and
hardlinks outright is what keeps this off the majority of node-tar's
published advisories rather than depending on the library to contain them.
That two-pass shape is load-bearing, and it was measured rather than assumed:
extracting an archive whose fourth member escapes upward throws under
node-tar 7.5.22 -- and leaves the first three members on disk. The loader
scans that directory at require time on the next boot, so a half-unpacked
module is a module. Everything therefore happens in a scratch directory that
is removed on any failure, and the move into place is the last step.
`tar` is pinned to ^7.5.22 rather than the ^6 that installs by default: 6.x
is flagged critical, and reading the advisory list is what the file's header
now says out loud -- almost all of it is hardlink or symlink traversal and
PAX header interpretation differentials, which is exactly this feature's
threat model.
Two things the plan had wrong
-----------------------------
The bundle's top-level directory is `module-uo-<version>`, not the module id
-- so "the top-level name must equal the id" was checked against nothing real.
The extractor strips that level instead, because its name belongs to whoever
published the bundle and the directory it lands in is core's. What is checked
instead is the unpacked module.json: a manifest promising `uo` and delivering
something else is refused rather than installed under the name it promised.
And purge cannot be a follow-up action (decision 5): purge.sql lives inside
the directory uninstall deletes. It is offered in the uninstall flow and as a
standalone action on a still-installed module, and the standalone one refuses
unless the module is already disabled -- dropping tables under something that
is still serving leaves it answering out of a world that no longer exists.
Disable now means stopped
-------------------------
lifecycle.stop() dispatches that one module's onShutdown before flipping the
guard, so a module an operator switches off actually releases its sockets and
closes its streams instead of merely becoming unreachable. The hook runs
first and the state moves after it, because while onShutdown runs the module
is still `started` and that is the only state in which its routes and the
world it is tearing down agree. A hook that throws does not stop the disable
-- the opposite of the boot path's rule, and deliberately.
Enable is not its mirror and there is no start(id) beside it. There is no
onBoot re-dispatch and the hooks were never promised re-entrant, so enable
moves the row and the restart route starts it. A test pins that enable does
not touch the loader, because "fixing" it is a one-line change that would put
a module with closed sockets back on the nav.
Restart raises SIGTERM against its own process rather than calling the
shutdown path directly, so server.js's handler stays the one graceful-shutdown
path and this route cannot drift from it.
The allowlist bootstraps from MODULE_SOURCE_HOSTS into a settings row and is
admin-managed after that (decision 6); seedDefault is INSERT IGNORE, so
changing the variable on an existing deployment is a no-op by design. An empty
list forbids every install rather than allowing every host -- the safe
direction for a value someone might blank by accident.
Verified against the real v0.3.0 release
----------------------------------------
Not a fixture: fetched the published install manifest over the real Gitea
host and its redirect chain, verified the sha256, inspected and unpacked the
252,517-byte artifact to 82 files, and then booted core against the result --
the module registered its five mounts, seven streams and eight capabilities
and resolved its client chunk, with no scratch directory left behind.
Two defects this slice's own tooling caught, both of which had already been
written down as classes:
- the controller destructured runPurge at require time, capturing the
function rather than the module, which made the one dependency whose
ORDER matters the one that could not be substituted;
- two swagger annotations carried an apostrophe inside a quoted string,
dropped silently by swagger-autogen before slice 5 taught it to fail loudly.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
448
server/test/moduleInstall.test.js
Normal file
448
server/test/moduleInstall.test.js
Normal file
@@ -0,0 +1,448 @@
|
||||
// modules/install.js — fetching, verifying and placing a module bundle.
|
||||
//
|
||||
// Phase 4, slice 1 of MODULE_SYSTEM.md §2.7.2. The rules under test are all
|
||||
// refusals, and each one is a step an attacker would otherwise walk through:
|
||||
// a non-https URL, a host that is not allowed, a REDIRECT to a host that is not
|
||||
// allowed, a body larger than declared, a hash that does not match, an archive
|
||||
// that does not agree with the manifest about what it is.
|
||||
//
|
||||
// `fetchImpl` is injected rather than a TLS server being stood up, for the same
|
||||
// reason `replayFragments` takes a `query`: what is being tested is what this
|
||||
// file decides about a response, and a real server would mostly test Node's
|
||||
// certificate handling. The responses below are real `Response` objects with
|
||||
// real bodies, so the streaming, the hashing and the byte cap are exercised for
|
||||
// real — only the transport is stubbed.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
const zlib = require('zlib')
|
||||
|
||||
const { test, beforeEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const HOSTS = ['releases.example.com']
|
||||
const MANIFEST_URL = 'https://releases.example.com/mod/uo-1.0.0.json'
|
||||
|
||||
let tmpRoot
|
||||
let install
|
||||
|
||||
/**
|
||||
* A fresh install.js bound to a fresh modules directory.
|
||||
*
|
||||
* install.js reads `loader.dir()`, which is resolved once at require time from
|
||||
* MODULES_DIR — so both have to be re-required per test, exactly as
|
||||
* moduleLifecycle.test.js does.
|
||||
*/
|
||||
function freshInstall(dir) {
|
||||
process.env.MODULES_DIR = dir
|
||||
delete require.cache[require.resolve('../src/modules/loader')]
|
||||
delete require.cache[require.resolve('../src/modules/install')]
|
||||
// eslint-disable-next-line global-require
|
||||
return require('../src/modules/install')
|
||||
}
|
||||
|
||||
// ── Building a bundle to serve ─────────────────────────────────────────────
|
||||
|
||||
const BLOCK = 512
|
||||
|
||||
function octal(value, width) {
|
||||
return Number(value).toString(8).padStart(width - 1, '0') + '\0'
|
||||
}
|
||||
|
||||
function member({ name, type = '0', body = '' }) {
|
||||
const header = Buffer.alloc(BLOCK, 0)
|
||||
const data = Buffer.from(body, 'utf8')
|
||||
header.write(name, 0, 100, 'utf8')
|
||||
header.write(octal(0o644, 8), 100, 8, 'ascii')
|
||||
header.write(octal(0, 8), 108, 8, 'ascii')
|
||||
header.write(octal(0, 8), 116, 8, 'ascii')
|
||||
header.write(octal(data.length, 12), 124, 12, 'ascii')
|
||||
header.write(octal(0, 12), 136, 12, 'ascii')
|
||||
header.write(' ', 148, 8, 'ascii')
|
||||
header.write(type, 156, 1, 'ascii')
|
||||
header.write('ustar\0', 257, 6, 'ascii')
|
||||
header.write('00', 263, 2, 'ascii')
|
||||
let sum = 0
|
||||
for (const byte of header) sum += byte
|
||||
header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148, 8, 'ascii')
|
||||
const padding = Buffer.alloc((BLOCK - (data.length % BLOCK)) % BLOCK, 0)
|
||||
return Buffer.concat([header, data, padding])
|
||||
}
|
||||
|
||||
/** A bundle tarball whose root directory is named the way a release names it. */
|
||||
function bundle({ id = 'uo', version = '1.0.0', extra = [] } = {}) {
|
||||
const root = `module-${id}-${version}`
|
||||
return zlib.gzipSync(Buffer.concat([
|
||||
member({ name: `${root}/`, type: '5' }),
|
||||
member({
|
||||
name: `${root}/module.json`,
|
||||
body: JSON.stringify({ id, name: 'Ultima Online', version, coreApi: '^1.0.0', server: 'server/index.js' }),
|
||||
}),
|
||||
member({ name: `${root}/server/`, type: '5' }),
|
||||
member({ name: `${root}/server/index.js`, body: 'module.exports = () => {}\n' }),
|
||||
...extra.map(member),
|
||||
Buffer.alloc(BLOCK * 2, 0),
|
||||
]))
|
||||
}
|
||||
|
||||
const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex')
|
||||
|
||||
/**
|
||||
* A fake transport serving one manifest and one artifact.
|
||||
*
|
||||
* `routes` maps an absolute URL to either a Buffer/string body or
|
||||
* `{ status, location }` for a redirect, so a test can describe exactly what the
|
||||
* remote host does without describing how it does it.
|
||||
*/
|
||||
function fakeFetch(routes) {
|
||||
const seen = []
|
||||
const impl = async (url) => {
|
||||
const href = String(url)
|
||||
seen.push(href)
|
||||
const route = routes[href]
|
||||
if (route === undefined) return new Response('not found', { status: 404 })
|
||||
if (route && route.status) {
|
||||
return new Response(null, {
|
||||
status: route.status,
|
||||
headers: route.location ? { location: route.location } : {},
|
||||
})
|
||||
}
|
||||
return new Response(route, { status: 200 })
|
||||
}
|
||||
impl.seen = seen
|
||||
return impl
|
||||
}
|
||||
|
||||
/** The manifest a release publishes, with whatever a test wants to change. */
|
||||
function manifestFor(tarball, overrides = {}) {
|
||||
return JSON.stringify({
|
||||
schema: 1,
|
||||
id: 'uo',
|
||||
name: 'Ultima Online',
|
||||
version: '1.0.0',
|
||||
coreApi: '^1.0.0',
|
||||
artifact: 'uo-1.0.0.tar.gz',
|
||||
url: 'https://releases.example.com/mod/uo-1.0.0.tar.gz',
|
||||
sha256: sha256(tarball),
|
||||
size: tarball.length,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/** The happy-path pair: a valid manifest and the artifact it describes. */
|
||||
function goodRoutes(options = {}) {
|
||||
const tarball = bundle(options)
|
||||
return {
|
||||
tarball,
|
||||
routes: {
|
||||
[MANIFEST_URL]: manifestFor(tarball, options.manifest || {}),
|
||||
'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-install-'))
|
||||
install = freshInstall(tmpRoot)
|
||||
})
|
||||
|
||||
// ── The allowlist ──────────────────────────────────────────────────────────
|
||||
|
||||
test('parseHosts accepts comma and whitespace separation, and lower-cases', () => {
|
||||
assert.deepEqual(install.parseHosts('a.com, B.com\nc.com'), ['a.com', 'b.com', 'c.com'])
|
||||
assert.deepEqual(install.parseHosts(''), [])
|
||||
assert.deepEqual(install.parseHosts(null), [])
|
||||
})
|
||||
|
||||
test('an empty allowlist forbids everything rather than allowing everything', () => {
|
||||
// The direction matters: a setting someone blanks by accident must stop
|
||||
// installs, not open the door to every host on the internet.
|
||||
assert.throws(() => install.checkUrl('https://releases.example.com/x.json', []), /no module source hosts are allowed/)
|
||||
})
|
||||
|
||||
test('only https may be installed from', () => {
|
||||
// The sha256 is no help against a plaintext fetch: whoever can rewrite the
|
||||
// artifact in flight can rewrite the manifest that declares its hash.
|
||||
assert.throws(() => install.checkUrl('http://releases.example.com/x.json', HOSTS), /only https/)
|
||||
assert.throws(() => install.checkUrl('file:///etc/passwd', HOSTS), /only https/)
|
||||
})
|
||||
|
||||
test('a host that is not on the allowlist is refused, and the message says which are', () => {
|
||||
assert.throws(
|
||||
() => install.checkUrl('https://evil.example.net/x.json', HOSTS),
|
||||
/"evil.example.net" is not an allowed module source host \(allowed: releases.example.com\)/,
|
||||
)
|
||||
})
|
||||
|
||||
test('the allowlist is matched on the host, not on a substring of the URL', () => {
|
||||
// `https://evil.com/?x=releases.example.com` must not pass, and neither must a
|
||||
// subdomain nobody listed.
|
||||
assert.throws(() => install.checkUrl('https://evil.com/?releases.example.com', HOSTS), /not an allowed/)
|
||||
assert.throws(() => install.checkUrl('https://sub.releases.example.com/x', HOSTS), /not an allowed/)
|
||||
assert.throws(() => install.checkUrl('https://releases.example.com.evil.net/x', HOSTS), /not an allowed/)
|
||||
})
|
||||
|
||||
// ── Redirects ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('a redirect is followed, and re-checked against the allowlist', async () => {
|
||||
const { tarball } = goodRoutes()
|
||||
const impl = fakeFetch({
|
||||
[MANIFEST_URL]: { status: 302, location: 'https://releases.example.com/cdn/uo-1.0.0.json' },
|
||||
'https://releases.example.com/cdn/uo-1.0.0.json': manifestFor(tarball),
|
||||
})
|
||||
|
||||
const manifest = await install.fetchManifest(MANIFEST_URL, HOSTS, impl)
|
||||
|
||||
assert.equal(manifest.id, 'uo')
|
||||
assert.deepEqual(impl.seen, [MANIFEST_URL, 'https://releases.example.com/cdn/uo-1.0.0.json'])
|
||||
})
|
||||
|
||||
test('a redirect to a host that is not allowed is refused', async () => {
|
||||
// The hole the allowlist exists to close. `fetch`'s own redirect following
|
||||
// would check the first hop and then go wherever it was pointed — which is
|
||||
// why get() follows them by hand.
|
||||
const impl = fakeFetch({
|
||||
[MANIFEST_URL]: { status: 302, location: 'http://169.254.169.254/latest/meta-data/' },
|
||||
})
|
||||
|
||||
await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /only https/)
|
||||
})
|
||||
|
||||
test('a redirect loop is bounded rather than followed forever', async () => {
|
||||
const impl = fakeFetch({
|
||||
[MANIFEST_URL]: { status: 302, location: MANIFEST_URL },
|
||||
})
|
||||
|
||||
await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /too many redirects/)
|
||||
})
|
||||
|
||||
// ── The install manifest ───────────────────────────────────────────────────
|
||||
|
||||
test('a manifest that is not JSON is refused with a readable reason', async () => {
|
||||
const impl = fakeFetch({ [MANIFEST_URL]: 'this is not json' })
|
||||
await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /not valid JSON/)
|
||||
})
|
||||
|
||||
test('a manifest with an invalid module id is refused', async () => {
|
||||
// The loader would refuse to scan a directory called `../etc`, but the point
|
||||
// is that one is never created: the id becomes a path segment.
|
||||
for (const id of ['../etc', 'UO', '', 'x', 'a'.repeat(40)]) {
|
||||
const impl = fakeFetch({ [MANIFEST_URL]: JSON.stringify({ id, name: 'n', version: '1', sha256: 'a'.repeat(64) }) })
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /invalid module id/, `id ${JSON.stringify(id)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('a manifest with no usable sha256 is refused', async () => {
|
||||
const impl = fakeFetch({
|
||||
[MANIFEST_URL]: JSON.stringify({ id: 'uo', name: 'n', version: '1.0.0', sha256: 'not-a-hash' }),
|
||||
})
|
||||
await assert.rejects(() => install.fetchManifest(MANIFEST_URL, HOSTS, impl), /no valid sha256/)
|
||||
})
|
||||
|
||||
test('the artifact URL is resolved relative to the manifest when it carries no absolute one', async () => {
|
||||
const { tarball } = goodRoutes()
|
||||
const impl = fakeFetch({ [MANIFEST_URL]: manifestFor(tarball, { url: undefined }) })
|
||||
|
||||
const manifest = await install.fetchManifest(MANIFEST_URL, HOSTS, impl)
|
||||
|
||||
// Release assets sit beside their manifest, so a manifest that travelled
|
||||
// without its absolute URL still resolves to the right place.
|
||||
assert.equal(manifest.artifactUrl, 'https://releases.example.com/mod/uo-1.0.0.tar.gz')
|
||||
})
|
||||
|
||||
// ── The install ────────────────────────────────────────────────────────────
|
||||
|
||||
test('a good bundle installs into modules/<id>, stripped of its wrapper', async () => {
|
||||
const { routes, tarball } = goodRoutes()
|
||||
|
||||
const result = await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) })
|
||||
|
||||
assert.equal(result.id, 'uo')
|
||||
assert.equal(result.version, '1.0.0')
|
||||
assert.equal(result.sha256, sha256(tarball))
|
||||
assert.equal(result.source, MANIFEST_URL)
|
||||
assert.equal(result.replaced, false)
|
||||
|
||||
// The directory is named for the module id, not for the archive's root — the
|
||||
// loader scans for the former and the publisher chose the latter.
|
||||
const dir = path.join(tmpRoot, 'uo')
|
||||
assert.ok(fs.existsSync(path.join(dir, 'module.json')))
|
||||
assert.ok(fs.existsSync(path.join(dir, 'server', 'index.js')))
|
||||
assert.ok(!fs.existsSync(path.join(tmpRoot, 'module-uo-1.0.0')))
|
||||
})
|
||||
|
||||
test('a hash that does not match refuses the install and writes nothing', async () => {
|
||||
const { routes } = goodRoutes()
|
||||
// The bytes are fine; the manifest lies about them. Which is the same thing an
|
||||
// artifact swapped after publication looks like.
|
||||
routes[MANIFEST_URL] = manifestFor(Buffer.from('different'), {})
|
||||
|
||||
await assert.rejects(
|
||||
() => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
|
||||
/does not match the sha256/,
|
||||
)
|
||||
assert.deepEqual(fs.readdirSync(tmpRoot), [], 'nothing may be left on the volume')
|
||||
})
|
||||
|
||||
test('a bundle whose module.json disagrees with the manifest is refused', async () => {
|
||||
// A manifest promising `uo` and delivering something else would otherwise be
|
||||
// installed into `modules/uo/` under a name it is not.
|
||||
const tarball = bundle({ id: 'rust', version: '1.0.0' })
|
||||
const routes = {
|
||||
[MANIFEST_URL]: manifestFor(tarball),
|
||||
'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
|
||||
/declares module id "rust" but the install manifest promised "uo"/,
|
||||
)
|
||||
assert.deepEqual(fs.readdirSync(tmpRoot), [])
|
||||
})
|
||||
|
||||
test('a bundle whose version disagrees with the manifest is refused', async () => {
|
||||
const tarball = bundle({ id: 'uo', version: '9.9.9' })
|
||||
const routes = {
|
||||
[MANIFEST_URL]: manifestFor(tarball),
|
||||
'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
|
||||
/declares version "9.9.9"/,
|
||||
)
|
||||
})
|
||||
|
||||
test('an artifact whose length differs from the declared size is refused', async () => {
|
||||
const tarball = bundle()
|
||||
const routes = {
|
||||
[MANIFEST_URL]: manifestFor(tarball, { size: tarball.length + 10 }),
|
||||
'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
|
||||
/but the manifest declares/,
|
||||
)
|
||||
})
|
||||
|
||||
test('a hostile archive is refused, and nothing of it reaches the volume', async () => {
|
||||
// The case node-tar alone does NOT cover: it throws on the escaping member,
|
||||
// but only once it reaches it — the members before it are already on disk.
|
||||
// archive.inspect() decides before extract() runs, and the unpack happens in a
|
||||
// scratch directory that is removed either way.
|
||||
const tarball = bundle({
|
||||
extra: [
|
||||
{ name: 'module-uo-1.0.0/../../ESCAPED.txt', body: 'escaped' },
|
||||
],
|
||||
})
|
||||
const routes = {
|
||||
[MANIFEST_URL]: manifestFor(tarball),
|
||||
'https://releases.example.com/mod/uo-1.0.0.tar.gz': tarball,
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) }),
|
||||
/escapes upward/,
|
||||
)
|
||||
assert.deepEqual(fs.readdirSync(tmpRoot), [], 'no scratch directory, no partial module, no escape')
|
||||
})
|
||||
|
||||
test('an upgrade replaces the directory and reports that it did', async () => {
|
||||
const first = goodRoutes()
|
||||
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
|
||||
fs.writeFileSync(path.join(tmpRoot, 'uo', 'STALE.txt'), 'from the old version')
|
||||
|
||||
const second = goodRoutes({ version: '2.0.0', manifest: { version: '2.0.0' } })
|
||||
second.routes[MANIFEST_URL] = manifestFor(second.tarball, { version: '2.0.0' })
|
||||
const result = await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(second.routes) })
|
||||
|
||||
assert.equal(result.replaced, true)
|
||||
assert.equal(result.version, '2.0.0')
|
||||
// A replace, not a merge: a file the previous version left behind must not
|
||||
// survive into the new one, or an upgrade quietly keeps dead code loadable.
|
||||
assert.ok(!fs.existsSync(path.join(tmpRoot, 'uo', 'STALE.txt')))
|
||||
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'], 'the aside copy is cleaned up')
|
||||
})
|
||||
|
||||
test('a failed upgrade leaves the previous version in place', async () => {
|
||||
const first = goodRoutes()
|
||||
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(first.routes) })
|
||||
|
||||
const badTarball = bundle({ id: 'uo', version: '2.0.0', extra: [{ name: 'evil/', type: '5' }] })
|
||||
await assert.rejects(() => install.install({
|
||||
url: MANIFEST_URL,
|
||||
hosts: HOSTS,
|
||||
fetchImpl: fakeFetch({
|
||||
[MANIFEST_URL]: manifestFor(badTarball, { version: '2.0.0' }),
|
||||
'https://releases.example.com/mod/uo-1.0.0.tar.gz': badTarball,
|
||||
}),
|
||||
}))
|
||||
|
||||
// The installed module is untouched: the swap is the last step, so a bundle
|
||||
// rejected before it never got near the live directory.
|
||||
assert.ok(fs.existsSync(path.join(tmpRoot, 'uo', 'module.json')))
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(tmpRoot, 'uo', 'module.json'), 'utf8')).version, '1.0.0')
|
||||
assert.deepEqual(fs.readdirSync(tmpRoot), ['uo'])
|
||||
})
|
||||
|
||||
// ── The volume ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('moduleDir refuses an id that is not one', () => {
|
||||
for (const id of ['../escape', 'a/b', '', 'UO', '.']) {
|
||||
assert.throws(() => install.moduleDir(id), /invalid module id/, `id ${JSON.stringify(id)}`)
|
||||
}
|
||||
assert.equal(install.moduleDir('uo'), path.join(tmpRoot, 'uo'))
|
||||
})
|
||||
|
||||
test('isInstalled and removeDir report what they did', async () => {
|
||||
const { routes } = goodRoutes()
|
||||
assert.equal(install.isInstalled('uo'), false)
|
||||
|
||||
await install.install({ url: MANIFEST_URL, hosts: HOSTS, fetchImpl: fakeFetch(routes) })
|
||||
assert.equal(install.isInstalled('uo'), true)
|
||||
|
||||
assert.equal(await install.removeDir('uo'), true)
|
||||
assert.equal(install.isInstalled('uo'), false)
|
||||
// Removing what is not there is not an error — an uninstall of a module whose
|
||||
// directory was already deleted by hand should still tidy up the row.
|
||||
assert.equal(await install.removeDir('uo'), false)
|
||||
})
|
||||
|
||||
test('purgeFile resolves the manifest declaration, and refuses one that escapes', async () => {
|
||||
const dir = path.join(tmpRoot, 'uo')
|
||||
fs.mkdirSync(path.join(dir, 'server', 'db'), { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, 'server', 'db', 'purge.sql'), 'DROP TABLE IF EXISTS x;')
|
||||
|
||||
const write = (purge) => fs.writeFileSync(
|
||||
path.join(dir, 'module.json'),
|
||||
JSON.stringify({ id: 'uo', name: 'UO', version: '1.0.0', purge }),
|
||||
)
|
||||
|
||||
write('server/db/purge.sql')
|
||||
assert.equal(install.purgeFile('uo'), path.join(dir, 'server', 'db', 'purge.sql'))
|
||||
|
||||
// The same containment rule the loader applies to client.entry: a manifest may
|
||||
// not point core at a file outside the module it belongs to.
|
||||
write('../../../../etc/passwd')
|
||||
assert.equal(install.purgeFile('uo'), null)
|
||||
|
||||
// Declared but absent, and not declared at all, are both "nothing to run".
|
||||
write('server/db/missing.sql')
|
||||
assert.equal(install.purgeFile('uo'), null)
|
||||
write(undefined)
|
||||
assert.equal(install.purgeFile('uo'), null)
|
||||
})
|
||||
|
||||
test('purgeFile is null for a module that is not on the volume', () => {
|
||||
assert.equal(install.purgeFile('nothere'), null)
|
||||
})
|
||||
Reference in New Issue
Block a user