mikser-io-drive 0.9.0

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.
@@ -0,0 +1,233 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, writeFile, rm, mkdir, readFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+ import bcrypt from 'bcryptjs'
7
+
8
+ import { runtime } from 'mikser-io'
9
+ import { auth } from 'mikser-io-auth'
10
+ import { drive } from '../index.js'
11
+
12
+ // A real Nephele server over a real directory, authenticated through a real
13
+ // htpasswd file. WebDAV is almost entirely I/O and protocol; a mocked
14
+ // version of it would prove nothing.
15
+ let server, port, dir, content
16
+
17
+ const b64 = (s) => Buffer.from(s, 'utf8').toString('base64')
18
+ const as = (user) => ({ authorization: `Basic ${b64(`${user}:${user}-pw`)}` })
19
+
20
+ before(async () => {
21
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-drive-'))
22
+ content = path.join(dir, 'documents')
23
+ await mkdir(content, { recursive: true })
24
+ await mkdir(path.join(dir, 'runtime'), { recursive: true })
25
+ await writeFile(path.join(content, 'page.md'), '# hello\n')
26
+
27
+ await writeFile(path.join(dir, 'users.htpasswd'), ['alice', 'bob', 'carol']
28
+ .map(u => `${u}:${bcrypt.hashSync(`${u}-pw`, 10)}`).join('\n') + '\n')
29
+ await writeFile(path.join(dir, 'groups.htgroup'),
30
+ 'editors: alice\nreviewers: bob\n')
31
+
32
+ const { default: express } = await import('express')
33
+ const app = express()
34
+
35
+ runtime.options = { ...runtime.options, app, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
36
+ runtime.config = { ...runtime.config }
37
+ runtime.engine = { ...runtime.engine, logger: { info(){}, warn(){}, error(){}, debug(){}, trace(){}, fatal(){} } }
38
+
39
+ const identity = auth({
40
+ capabilities: {
41
+ // Capability names are derived from the endpoint name, so an
42
+ // endpoint you were never granted is invisible to you — which is
43
+ // why `editors` has to name `drive:locked` too.
44
+ editors: ['drive:content', 'drive:content:write', 'drive:locked'],
45
+ reviewers: ['drive:content'], // read, no write
46
+ },
47
+ })
48
+
49
+ const plugin = drive({
50
+ endpoints: {
51
+ content: { folder: 'documents' },
52
+ locked: { folder: 'documents', readOnly: true },
53
+ },
54
+ auth: identity,
55
+ })
56
+
57
+ const load = [], loaded = []
58
+ const core = {
59
+ runtime,
60
+ onLoad: (cb) => load.push(cb),
61
+ onLoaded: (cb) => loaded.push(cb),
62
+ useLogger: () => ({ info(){}, warn(){}, error(){}, debug(){}, trace(){} }),
63
+ }
64
+ identity(core)
65
+ plugin(core)
66
+ for (const cb of load) await cb()
67
+ for (const cb of loaded) await cb()
68
+
69
+ server = await new Promise(resolve => {
70
+ const s = app.listen(0, () => resolve(s))
71
+ })
72
+ port = server.address().port
73
+ })
74
+
75
+ after(async () => {
76
+ await new Promise(r => server?.close(r))
77
+ await rm(dir, { recursive: true, force: true })
78
+ })
79
+
80
+ const dav = (method, p, { headers = {}, body } = {}) =>
81
+ fetch(`http://127.0.0.1:${port}${p}`, { method, headers, body })
82
+
83
+ describe('auth', () => {
84
+ it('challenges an anonymous request with Basic, so a DAV client prompts', async () => {
85
+ const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0' } })
86
+ assert.equal(res.status, 401)
87
+ assert.match(res.headers.get('www-authenticate'), /^Basic realm="mikser"/)
88
+ assert.match(res.headers.get('www-authenticate'), /charset="UTF-8"/)
89
+ })
90
+
91
+ it('refuses a wrong password', async () => {
92
+ const res = await dav('PROPFIND', '/drive/content/', {
93
+ headers: { depth: '0', authorization: `Basic ${b64('alice:wrong')}` },
94
+ })
95
+ assert.equal(res.status, 401)
96
+ })
97
+
98
+ it('lets a granted user list the collection', async () => {
99
+ const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '1', ...as('alice') } })
100
+ assert.equal(res.status, 207)
101
+ const xml = await res.text()
102
+ assert.match(xml, /page\.md/)
103
+ })
104
+
105
+ it('403s an authenticated user who lacks the endpoint capability', async () => {
106
+ // carol is in no group, so with a capability map configured she holds
107
+ // nothing — authenticated, but not for this endpoint.
108
+ const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0', ...as('carol') } })
109
+ assert.equal(res.status, 403)
110
+ })
111
+ })
112
+
113
+ describe('read and write', () => {
114
+ it('GETs a file', async () => {
115
+ const res = await dav('GET', '/drive/content/page.md', { headers: as('alice') })
116
+ assert.equal(res.status, 200)
117
+ assert.equal(await res.text(), '# hello\n')
118
+ })
119
+
120
+ it('PUTs a file, and it lands in the working folder where the build will see it', async () => {
121
+ const res = await dav('PUT', '/drive/content/new.md', {
122
+ headers: { ...as('alice'), 'content-type': 'text/markdown' },
123
+ body: '# written over dav\n',
124
+ })
125
+ assert.ok([201, 204].includes(res.status), `unexpected ${res.status}`)
126
+ assert.equal(await readFile(path.join(content, 'new.md'), 'utf8'), '# written over dav\n')
127
+ })
128
+
129
+ it('DELETEs a file', async () => {
130
+ await dav('PUT', '/drive/content/gone.md', { headers: as('alice'), body: 'x' })
131
+ const res = await dav('DELETE', '/drive/content/gone.md', { headers: as('alice') })
132
+ assert.ok([200, 204].includes(res.status), `unexpected ${res.status}`)
133
+ await assert.rejects(() => readFile(path.join(content, 'gone.md')))
134
+ })
135
+ })
136
+
137
+ describe('write capability is per request, not per mount', () => {
138
+ it('a user with read but not write gets a read-only view of the SAME endpoint', async () => {
139
+ // bob is a reviewer: holds drive:content, not drive:content:write.
140
+ const list = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0', ...as('bob') } })
141
+ assert.equal(list.status, 207, 'bob can read')
142
+
143
+ const write = await dav('PUT', '/drive/content/bob.md', { headers: as('bob'), body: 'nope' })
144
+ assert.ok(write.status >= 400, `bob should not write, got ${write.status}`)
145
+ await assert.rejects(() => readFile(path.join(content, 'bob.md')),
146
+ 'nothing should have been written')
147
+ })
148
+
149
+ it('and the same URL is writable for someone who holds the capability', async () => {
150
+ const write = await dav('PUT', '/drive/content/alice.md', { headers: as('alice'), body: 'yes' })
151
+ assert.ok([201, 204].includes(write.status))
152
+ })
153
+ })
154
+
155
+ describe('an endpoint you were not granted is refused outright', () => {
156
+ it('403s a user who holds no capability for it', async () => {
157
+ // bob is a reviewer: drive:content only, nothing for `locked`.
158
+ const res = await dav('PROPFIND', '/drive/locked/', { headers: { depth: '0', ...as('bob') } })
159
+ assert.equal(res.status, 403)
160
+ })
161
+ })
162
+
163
+ describe('readOnly is a hard cap', () => {
164
+ it('refuses a write even from a user who holds every capability', async () => {
165
+ // alice holds drive:content:write, but this endpoint is readOnly —
166
+ // "nobody writes here" is a different statement from "you may not".
167
+ const res = await dav('PUT', '/drive/locked/hard.md', { headers: as('alice'), body: 'x' })
168
+ assert.ok(res.status >= 400, `expected a refusal, got ${res.status}`)
169
+ await assert.rejects(() => readFile(path.join(content, 'hard.md')))
170
+ })
171
+
172
+ it('still allows reading', async () => {
173
+ const res = await dav('GET', '/drive/locked/page.md', { headers: as('alice') })
174
+ assert.equal(res.status, 200)
175
+ })
176
+ })
177
+
178
+ describe('no .nephelemeta sidecars in a watched source folder', () => {
179
+ it('a PROPPATCH reports success without writing meta files', async () => {
180
+ // The default 'meta-files' strategy would drop .nephelemeta into the
181
+ // documents folder, where mikser's sources would pick it up as an
182
+ // entity — and a rebuild that writes could trip the watcher again.
183
+ const res = await dav('PROPPATCH', '/drive/content/page.md', {
184
+ headers: { ...as('alice'), 'content-type': 'text/xml' },
185
+ body: `<?xml version="1.0" encoding="utf-8"?>
186
+ <D:propertyupdate xmlns:D="DAV:"><D:set><D:prop><customprop xmlns="X:">v</customprop></D:prop></D:set></D:propertyupdate>`,
187
+ })
188
+ assert.ok(res.status < 500, `unexpected ${res.status}`)
189
+ const { readdir } = await import('node:fs/promises')
190
+ const files = await readdir(content)
191
+ assert.deepEqual(files.filter(f => f.includes('nephelemeta')), [],
192
+ 'no sidecar files may appear in a source folder')
193
+ })
194
+ })
195
+
196
+ describe('the write gate covers every mutating method', () => {
197
+ // A gate that only knew about PUT would leave MKCOL, MOVE, COPY and
198
+ // PROPPATCH wide open — each of which changes the working folder, which
199
+ // is what the build reads.
200
+ const cases = [
201
+ ['PUT', '/drive/content/x.md', { body: 'x' }],
202
+ ['DELETE', '/drive/content/page.md', {}],
203
+ ['MKCOL', '/drive/content/newdir/', {}],
204
+ ['MOVE', '/drive/content/page.md', { headers: { destination: '/drive/content/moved.md' } }],
205
+ ['COPY', '/drive/content/page.md', { headers: { destination: '/drive/content/copied.md' } }],
206
+ ['PROPPATCH', '/drive/content/page.md', {
207
+ headers: { 'content-type': 'text/xml' },
208
+ body: `<?xml version="1.0" encoding="utf-8"?>
209
+ <D:propertyupdate xmlns:D="DAV:"><D:set><D:prop><p xmlns="X:">v</p></D:prop></D:set></D:propertyupdate>`,
210
+ }],
211
+ ['LOCK', '/drive/content/page.md', {
212
+ headers: { 'content-type': 'text/xml' },
213
+ body: `<?xml version="1.0" encoding="utf-8"?>
214
+ <D:lockinfo xmlns:D="DAV:"><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockinfo>`,
215
+ }],
216
+ ]
217
+
218
+ for (const [method, url, opts] of cases) {
219
+ it(`refuses ${method} for a reader`, async () => {
220
+ const res = await dav(method, url, {
221
+ ...opts,
222
+ headers: { ...(opts.headers ?? {}), ...as('bob') },
223
+ })
224
+ assert.equal(res.status, 403, `${method} should be 403 for bob, got ${res.status}`)
225
+ })
226
+ }
227
+
228
+ it('and still lets a reader read', async () => {
229
+ assert.equal((await dav('GET', '/drive/content/page.md', { headers: as('bob') })).status, 200)
230
+ assert.equal((await dav('PROPFIND', '/drive/content/', { headers: { depth: '1', ...as('bob') } })).status, 207)
231
+ assert.equal((await dav('OPTIONS', '/drive/content/', { headers: as('bob') })).status < 300, true)
232
+ })
233
+ })
@@ -0,0 +1,269 @@
1
+ import { describe, it, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtemp, rm, mkdir, writeFile, readFile, stat } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import path from 'node:path'
6
+
7
+ import { runtime } from 'mikser-io'
8
+ import { registerFileTools } from '../lib/files.js'
9
+ import { readCapability, writeCapability } from '../index.js'
10
+
11
+ // These carry bytes through the tool, for a caller with no route to the host.
12
+ // What a stored file BECOMES is the pipeline's business, and the whole point of
13
+ // the response is reporting that rather than assuming it — so the parts that
14
+ // depend on a catalog say so rather than being faked here.
15
+
16
+ let dir, mcp, tools
17
+
18
+ function fakeSubstrate(principal) {
19
+ const registry = new Map()
20
+ return {
21
+ principal: () => principal,
22
+ registerTool: (name, def, handler) => registry.set(name, handler),
23
+ call: (name, args) => registry.get(name)(args ?? {}),
24
+ names: () => [...registry.keys()],
25
+ }
26
+ }
27
+
28
+ const b64 = (s) => Buffer.from(s).toString('base64')
29
+ // A 1x1 PNG, so the viewable path has something real to return.
30
+ const PNG = Buffer.from(
31
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
32
+ 'base64')
33
+
34
+ const body = (r) => JSON.parse(r.content.find(c => c.type === 'text').text)
35
+ const call = (name, args) => mcp.call(name, args)
36
+
37
+ before(async () => {
38
+ dir = await mkdtemp(path.join(tmpdir(), 'mikser-files-'))
39
+ await mkdir(path.join(dir, 'media'), { recursive: true })
40
+ await mkdir(path.join(dir, 'locked'), { recursive: true })
41
+ runtime.options = { ...runtime.options, workingFolder: dir, runtimeFolder: path.join(dir, 'runtime') }
42
+
43
+ mcp = fakeSubstrate({ subject: 'alice', capabilities: ['drive:media', 'drive:media:write'] })
44
+ registerFileTools({
45
+ runtime: { options: { ...runtime.options, mcp }, refs: runtime.refs },
46
+ endpoints: { media: { folder: 'media' }, locked: { folder: 'locked', readOnly: true } },
47
+ capabilityOf: readCapability, writeCapabilityOf: writeCapability,
48
+ })
49
+ tools = mcp.names()
50
+ })
51
+
52
+ after(async () => { if (dir) await rm(dir, { recursive: true, force: true }) })
53
+
54
+ describe('registration', () => {
55
+ it('registers exactly the four file tools', () => {
56
+ assert.deepEqual(tools.sort(),
57
+ ['mikser_drive_add', 'mikser_drive_delete', 'mikser_drive_move', 'mikser_drive_read'])
58
+ })
59
+ })
60
+
61
+ describe('add', () => {
62
+ it('stores a batch in one call, under one cycle', async () => {
63
+ // Ten files, one cycleId — not one rebuild per file.
64
+ const files = Array.from({ length: 10 }, (_, i) =>
65
+ ({ name: `shot-${i}.png`, base64: PNG.toString('base64'), mime: 'image/png' }))
66
+ const r = body(await call('mikser_drive_add', { endpoint: 'media', folder: 'batch', files }))
67
+ assert.equal(r.stored, 10)
68
+ assert.equal(typeof r.cycleId, 'number')
69
+ for (const f of r.files) {
70
+ assert.equal(f.size, PNG.length)
71
+ assert.equal(f.mime, 'image/png')
72
+ }
73
+ assert.ok(await stat(path.join(dir, 'media/batch/shot-0.png')))
74
+ assert.ok(await stat(path.join(dir, 'media/batch/shot-9.png')))
75
+ })
76
+
77
+ it('refuses an existing file rather than replacing it silently', async () => {
78
+ const one = [{ name: 'once.txt', base64: b64('first') }]
79
+ assert.equal(body(await call('mikser_drive_add', { endpoint: 'media', files: one })).stored, 1)
80
+
81
+ const again = await call('mikser_drive_add',
82
+ { endpoint: 'media', files: [{ name: 'once.txt', base64: b64('second') }] })
83
+ assert.equal(again.isError, true)
84
+ assert.match(again.content[0].text, /already exist/)
85
+ assert.equal(await readFile(path.join(dir, 'media/once.txt'), 'utf8'), 'first',
86
+ 'the original must be untouched')
87
+ })
88
+
89
+ it('replaces when told to, and says it replaced', async () => {
90
+ const r = body(await call('mikser_drive_add',
91
+ { endpoint: 'media', overwrite: true, files: [{ name: 'once.txt', base64: b64('third') }] }))
92
+ assert.equal(r.files[0].replaced, true)
93
+ assert.equal(await readFile(path.join(dir, 'media/once.txt'), 'utf8'), 'third')
94
+ })
95
+
96
+ it('writes NOTHING when any file in the batch is bad', async () => {
97
+ // Decoded and checked before a single write. A batch that fails halfway
98
+ // leaves the caller unable to say what landed.
99
+ const r = await call('mikser_drive_add', { endpoint: 'media', folder: 'atomic', files: [
100
+ { name: 'good.txt', base64: b64('fine') },
101
+ { name: 'bad.txt', base64: '' },
102
+ ] })
103
+ assert.equal(r.isError, true)
104
+ await assert.rejects(() => stat(path.join(dir, 'media/atomic/good.txt')))
105
+ })
106
+
107
+ it('refuses a name that would escape the folder', async () => {
108
+ for (const name of ['../escape.txt', 'a/../../escape.txt', '/abs.txt', '.hidden']) {
109
+ const r = await call('mikser_drive_add', { endpoint: 'media', files: [{ name, base64: b64('x') }] })
110
+ assert.equal(r.isError, true, `${name} must be refused`)
111
+ }
112
+ await assert.rejects(() => stat(path.join(dir, 'escape.txt')))
113
+ })
114
+
115
+ it('refuses a file over the per-file cap, and points somewhere useful', async () => {
116
+ const big = Buffer.alloc(3 * 1024 * 1024, 1).toString('base64')
117
+ const r = await call('mikser_drive_add', { endpoint: 'media', files: [{ name: 'big.bin', base64: big }] })
118
+ assert.equal(r.isError, true)
119
+ assert.match(r.content[0].text, /per-file cap/)
120
+ assert.match(r.content[0].text, /WebDAV mount/)
121
+ assert.match(r.content[0].text, /Nothing was written/)
122
+ })
123
+
124
+ it('refuses a readOnly endpoint, and names the real ones for a wrong guess', async () => {
125
+ const ro = await call('mikser_drive_add', { endpoint: 'locked', files: [{ name: 'x.txt', base64: b64('x') }] })
126
+ assert.equal(ro.isError, true)
127
+ assert.match(ro.content[0].text, /readOnly/)
128
+
129
+ const nope = await call('mikser_drive_add', { endpoint: 'nope', files: [{ name: 'x.txt', base64: b64('x') }] })
130
+ assert.equal(nope.isError, true)
131
+ assert.match(nope.content[0].text, /media, locked/)
132
+ })
133
+
134
+ it('dryRun reports the cost and writes nothing', async () => {
135
+ const r = body(await call('mikser_drive_add', { endpoint: 'media', dryRun: true, folder: 'dry', files: [
136
+ { name: 'a.txt', base64: b64('hello') },
137
+ ] }))
138
+ assert.equal(r.dryRun, true)
139
+ assert.equal(r.totalBytes, 5)
140
+ assert.equal(typeof r.approxTokens, 'number')
141
+ assert.equal(r.files[0].wouldOverwrite, false)
142
+ await assert.rejects(() => stat(path.join(dir, 'media/dry/a.txt')))
143
+ })
144
+ })
145
+
146
+ describe('read', () => {
147
+ it('returns an image as an image, with the facts beside it', async () => {
148
+ await call('mikser_drive_add', { endpoint: 'media', files: [
149
+ { name: 'look.png', base64: PNG.toString('base64'), mime: 'image/png' } ] })
150
+ const r = await call('mikser_drive_read', { path: 'media/look.png' })
151
+ const image = r.content.find(c => c.type === 'image')
152
+ assert.ok(image, 'a model must be able to look at it, not be refused')
153
+ assert.equal(image.mimeType, 'image/png')
154
+ assert.equal(body(r).size, PNG.length)
155
+ })
156
+
157
+ it('returns text as text', async () => {
158
+ await call('mikser_drive_add', { endpoint: 'media', files: [{ name: 'note.txt', base64: b64('readable') }] })
159
+ assert.equal(body(await call('mikser_drive_read', { path: 'media/note.txt' })).content, 'readable')
160
+ })
161
+
162
+ it('describes a binary it cannot show rather than returning garbage', async () => {
163
+ await call('mikser_drive_add', { endpoint: 'media', files: [{ name: 'font.woff2', base64: b64(' ') }] })
164
+ const r = body(await call('mikser_drive_read', { path: 'media/font.woff2' }))
165
+ assert.equal(r.viewed, false)
166
+ assert.equal(r.content, undefined)
167
+ assert.match(r.note, /convincing garbage/)
168
+ })
169
+
170
+ it('says so when there is nothing there', async () => {
171
+ const r = await call('mikser_drive_read', { path: 'media/absent.png' })
172
+ assert.equal(r.isError, true)
173
+ })
174
+ })
175
+
176
+ describe('delete', () => {
177
+ it('moves to trash rather than unlinking, and can be undone', async () => {
178
+ await call('mikser_drive_add', { endpoint: 'media', files: [{ name: 'bye.txt', base64: b64('content') }] })
179
+ const r = body(await call('mikser_drive_delete', { path: 'media/bye.txt' }))
180
+ assert.equal(r.removed, true)
181
+ await assert.rejects(() => stat(path.join(dir, 'media/bye.txt')), 'gone from the folder')
182
+ // Still on disk, so a wrong delete is a move back and not a restore.
183
+ assert.ok(await stat(path.join(dir, r.trash)))
184
+ assert.match(r.restore, /Move it back/)
185
+ })
186
+
187
+ it('puts trash OUTSIDE the served folder', async () => {
188
+ // Inside it, the file would be republished at a new URL and, where git
189
+ // sync is on, committed.
190
+ await call('mikser_drive_add', { endpoint: 'media', files: [{ name: 'gone.txt', base64: b64('x') }] })
191
+ const r = body(await call('mikser_drive_delete', { path: 'media/gone.txt' }))
192
+ assert.equal(r.trash.startsWith('media/'), false, `trash must not be inside the endpoint: ${r.trash}`)
193
+ })
194
+
195
+ it('refuses a readOnly endpoint', async () => {
196
+ await writeFile(path.join(dir, 'locked/x.txt'), 'x')
197
+ const r = await call('mikser_drive_delete', { path: 'locked/x.txt' })
198
+ assert.equal(r.isError, true)
199
+ assert.match(r.content[0].text, /readOnly/)
200
+ })
201
+
202
+ it('dryRun removes nothing', async () => {
203
+ await call('mikser_drive_add', { endpoint: 'media', files: [{ name: 'keep.txt', base64: b64('x') }] })
204
+ const r = body(await call('mikser_drive_delete', { path: 'media/keep.txt', dryRun: true }))
205
+ assert.equal(r.dryRun, true)
206
+ assert.ok(await stat(path.join(dir, 'media/keep.txt')))
207
+ })
208
+
209
+ it('always states what its reference check does NOT cover', async () => {
210
+ // An empty list means "nothing of the kind I look at", not "nothing".
211
+ await call('mikser_drive_add', { endpoint: 'media', files: [{ name: 'cov.txt', base64: b64('x') }] })
212
+ const r = body(await call('mikser_drive_delete', { path: 'media/cov.txt', dryRun: true }))
213
+ assert.match(r.coverage, /NOT body text/)
214
+ })
215
+ })
216
+
217
+ describe('move', () => {
218
+ const put = (name, text) => call('mikser_drive_add',
219
+ { endpoint: 'media', files: [{ name, base64: b64(text) }] })
220
+
221
+ it('moves a file that nothing references', async () => {
222
+ await put('loose.txt', 'x')
223
+ const r = body(await call('mikser_drive_move', { from: 'media/loose.txt', to: 'moved/loose.txt' }))
224
+ assert.equal(r.moved, true)
225
+ assert.ok(await stat(path.join(dir, 'media/moved/loose.txt')))
226
+ await assert.rejects(() => stat(path.join(dir, 'media/loose.txt')))
227
+ })
228
+
229
+ it('refuses to land on something that already exists', async () => {
230
+ await put('a.txt', 'a')
231
+ await put('b.txt', 'b')
232
+ const r = await call('mikser_drive_move', { from: 'media/a.txt', to: 'b.txt' })
233
+ assert.equal(r.isError, true)
234
+ assert.match(r.content[0].text, /already exists/)
235
+ // Neither file moved.
236
+ assert.equal(await readFile(path.join(dir, 'media/a.txt'), 'utf8'), 'a')
237
+ assert.equal(await readFile(path.join(dir, 'media/b.txt'), 'utf8'), 'b')
238
+ })
239
+
240
+ it('refuses a destination that would escape the endpoint', async () => {
241
+ await put('stay.txt', 'x')
242
+ const r = await call('mikser_drive_move', { from: 'media/stay.txt', to: '../../escaped.txt' })
243
+ assert.equal(r.isError, true)
244
+ assert.ok(await stat(path.join(dir, 'media/stay.txt')))
245
+ })
246
+
247
+ it('refuses a no-op move rather than pretending it did something', async () => {
248
+ await put('same.txt', 'x')
249
+ const r = await call('mikser_drive_move', { from: 'media/same.txt', to: 'same.txt' })
250
+ assert.equal(r.isError, true)
251
+ assert.match(r.content[0].text, /same path/)
252
+ })
253
+
254
+ it('states what its reference check does not cover', async () => {
255
+ await put('cover.txt', 'x')
256
+ const r = body(await call('mikser_drive_move',
257
+ { from: 'media/cover.txt', to: 'elsewhere.txt', dryRun: true }))
258
+ assert.equal(r.dryRun, true)
259
+ assert.match(r.coverage, /NOT body text/)
260
+ assert.ok(await stat(path.join(dir, 'media/cover.txt')), 'dryRun moves nothing')
261
+ })
262
+
263
+ it('refuses a readOnly endpoint', async () => {
264
+ await writeFile(path.join(dir, 'locked/ro.txt'), 'x')
265
+ const r = await call('mikser_drive_move', { from: 'locked/ro.txt', to: 'other.txt' })
266
+ assert.equal(r.isError, true)
267
+ assert.match(r.content[0].text, /readOnly/)
268
+ })
269
+ })