mikser-io-drive 11.0.5 → 11.2.2
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.
- package/.github/workflows/publish.yml +9 -0
- package/README.md +40 -4
- package/index.js +57 -11
- package/lib/display-name.js +95 -0
- package/package.json +1 -1
- package/test/atomic-writes.test.js +13 -0
- package/test/derived.test.js +21 -1
- package/test/drive.test.js +13 -0
- package/test/protocol.test.js +68 -0
- package/test/real-client.test.js +13 -0
|
@@ -23,6 +23,15 @@ on:
|
|
|
23
23
|
jobs:
|
|
24
24
|
publish:
|
|
25
25
|
runs-on: ubuntu-latest
|
|
26
|
+
# A hung test must not cost six hours.
|
|
27
|
+
#
|
|
28
|
+
# Without this the job runs to GitHub's own limit, reporting nothing:
|
|
29
|
+
# a flaky teardown in one suite sat on `npm test` for seventeen
|
|
30
|
+
# minutes before anyone looked, and would have sat there all day.
|
|
31
|
+
# The longest suite in the family is a couple of minutes including
|
|
32
|
+
# install, so fifteen is generous and still fails while someone is
|
|
33
|
+
# still watching.
|
|
34
|
+
timeout-minutes: 15
|
|
26
35
|
permissions:
|
|
27
36
|
contents: read
|
|
28
37
|
# The whole mechanism. Without it npm has nothing to verify.
|
package/README.md
CHANGED
|
@@ -149,6 +149,37 @@ creates an empty file** (RFC 4918 §9.10.4). A client that locks before writing
|
|
|
149
149
|
— Finder's Save As does — leaves an empty document behind even if the write
|
|
150
150
|
never arrives.
|
|
151
151
|
|
|
152
|
+
## What the folder is called
|
|
153
|
+
|
|
154
|
+
The endpoint key is the name, and the mount reports it as `displayname`:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
drive({
|
|
158
|
+
endpoints: {
|
|
159
|
+
SkinCheck: { folder: 'skincheck' }, // shows as "SkinCheck"
|
|
160
|
+
reports: { folder: 'data', displayName: 'Q3 reports' },
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Without it the label is whatever the client derives from the URL, and that is
|
|
166
|
+
not controllable: Express matches routes case-insensitively, so
|
|
167
|
+
`/drive/SkinCheck` and `/drive/skincheck` reach the same mount and the casing
|
|
168
|
+
shown is whichever the client asked for first — an endpoint renamed to
|
|
169
|
+
`SkinCheck` kept reading `skincheck` in Explorer, and remapping did not change
|
|
170
|
+
it. `@nephele/adapter-file-system` does not implement `displayname` (it is
|
|
171
|
+
commented out of its live-property list with a TODO), so a PROPFIND for it
|
|
172
|
+
answered `404 The displayname property was not found` and the client had nothing
|
|
173
|
+
to go on.
|
|
174
|
+
|
|
175
|
+
The default is the **key**, not `summary`: `displayname` is a name — RFC 4918
|
|
176
|
+
§15.2, "suitable for presentation to a user" — and the key is already the URL
|
|
177
|
+
segment and what the mount log prints. `summary` is a sentence and reads badly
|
|
178
|
+
as a folder label. `displayName` overrides the key where it is not presentable.
|
|
179
|
+
|
|
180
|
+
Only the mount root is named. Everything below it is a real file or directory
|
|
181
|
+
whose name the client reads from the path.
|
|
182
|
+
|
|
152
183
|
## When a client refuses and the server is right
|
|
153
184
|
|
|
154
185
|
WebDAV clients cache their **discovery** verdict — not the file listing, the
|
|
@@ -173,10 +204,15 @@ afternoon was lost to exactly that: `curl` verified the server correct while
|
|
|
173
204
|
Explorer kept refusing.
|
|
174
205
|
|
|
175
206
|
The default adapter advertises `max-age=604800` here. Seven days is a strange
|
|
176
|
-
thing to promise about capabilities that are configuration
|
|
177
|
-
`
|
|
178
|
-
plugin overrides it. One cheap request per client
|
|
179
|
-
change a mount and be believed.
|
|
207
|
+
thing to promise about capabilities that are configuration: `locks: 'disallow'`
|
|
208
|
+
drops class 2 and `LOCK` from `Allow`, and a client can keep acting on the old
|
|
209
|
+
answer for a week. So this plugin overrides it. One cheap request per client
|
|
210
|
+
session buys the ability to change a mount and be believed.
|
|
211
|
+
|
|
212
|
+
`readOnly` is **not** an example of that, though it reads like one: measured, a
|
|
213
|
+
read-only mount reports the same `DAV: 1, 3, 2` and the same `Allow` list as a
|
|
214
|
+
writable one — PUT and DELETE included — and refuses the write when it arrives.
|
|
215
|
+
nephele's read-only plugin gates requests, not the discovery response.
|
|
180
216
|
|
|
181
217
|
**`OPTIONS /drive` and `OPTIONS /` answer a plain CORS 204 with no `DAV:`
|
|
182
218
|
header, and that is correct.** There is no resource at the base path: one
|
package/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { registerRoute, resolveAuth, reachabilityOf, registerJunk, registerCapab
|
|
|
4
4
|
import { MikserAuthenticator } from './lib/authenticator.js'
|
|
5
5
|
import { registerFileTools } from './lib/files.js'
|
|
6
6
|
import { withStagedWrites, stageWrites } from './lib/staged-writes.js'
|
|
7
|
+
import { withDisplayName } from './lib/display-name.js'
|
|
7
8
|
|
|
8
9
|
export { MikserAuthenticator, withStagedWrites, stageWrites }
|
|
9
10
|
|
|
@@ -43,6 +44,8 @@ export const writeCapability = (name) => `drive:${name}:write`
|
|
|
43
44
|
* content: { folder: 'documents' },
|
|
44
45
|
* media: { folder: 'files/media' },
|
|
45
46
|
* data: { folder: 'data', readOnly: true },
|
|
47
|
+
* Reports: { folder: 'reports' }, // shows as "Reports"
|
|
48
|
+
* q3: { folder: 'q3', displayName: 'Q3 reports' },
|
|
46
49
|
* },
|
|
47
50
|
* auth: identity,
|
|
48
51
|
* })
|
|
@@ -189,10 +192,15 @@ export function drive(options = {}) {
|
|
|
189
192
|
// log on either side, and `Restart-Service WebClient -Force`
|
|
190
193
|
// the only way out. That is not a remedy anyone guesses.
|
|
191
194
|
//
|
|
192
|
-
// And the capabilities here are CONFIGURATION.
|
|
193
|
-
// `
|
|
194
|
-
// list
|
|
195
|
-
// shape the mount no longer has.
|
|
195
|
+
// And the capabilities here are CONFIGURATION.
|
|
196
|
+
// `locks: 'disallow'` drops class 2 and LOCK from the Allow
|
|
197
|
+
// list — see test/protocol.test.js — so a week-long cache lets
|
|
198
|
+
// a client keep acting on a shape the mount no longer has.
|
|
199
|
+
//
|
|
200
|
+
// NOT `readOnly`, which was the example first given here and
|
|
201
|
+
// is wrong: measured, a read-only mount reports the same
|
|
202
|
+
// classes and the same Allow list as a writable one, and
|
|
203
|
+
// refuses the write per request instead.
|
|
196
204
|
//
|
|
197
205
|
// `no-cache` costs one cheap request per client session and
|
|
198
206
|
// buys the ability to change a mount and be believed. Set on
|
|
@@ -208,13 +216,33 @@ export function drive(options = {}) {
|
|
|
208
216
|
// watcher can import a half-written file, and an
|
|
209
217
|
// interrupted overwrite leaves the ORIGINAL destroyed.
|
|
210
218
|
// Both measured; see lib/staged-writes.js.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
// The mount answers to its own name.
|
|
220
|
+
//
|
|
221
|
+
// `displayname` is what a client labels the folder with,
|
|
222
|
+
// and the adapter does not implement it — so the label was
|
|
223
|
+
// whatever each client derived from the URL, which is not
|
|
224
|
+
// controllable: Express matches routes case-insensitively,
|
|
225
|
+
// so an endpoint renamed to `SkinCheck` went on reading
|
|
226
|
+
// `skincheck` in Explorer, from a casing the client had
|
|
227
|
+
// cached.
|
|
228
|
+
//
|
|
229
|
+
// Defaults to the endpoint KEY rather than `summary`.
|
|
230
|
+
// displayname is a NAME — RFC 4918 §15.2, "suitable for
|
|
231
|
+
// presentation to a user" — and the key is already the URL
|
|
232
|
+
// segment and the mount log label, so nothing new has to
|
|
233
|
+
// be written down. `summary` is a sentence, which reads
|
|
234
|
+
// badly as a folder label. `displayName` overrides it for
|
|
235
|
+
// a key that is not presentable.
|
|
236
|
+
adapter: withDisplayName(
|
|
237
|
+
(ep.atomicWrites === false)
|
|
238
|
+
? fsAdapter
|
|
239
|
+
: withStagedWrites(fsAdapter, {
|
|
240
|
+
onFailure: (err, file) => logger.warn(
|
|
241
|
+
'drive: upload of %s failed, original left intact — %s',
|
|
242
|
+
path.basename(file), err.message),
|
|
243
|
+
}),
|
|
244
|
+
ep.displayName ?? name,
|
|
245
|
+
),
|
|
218
246
|
authenticator,
|
|
219
247
|
// `readOnly: true` is a hard cap — "nobody writes here",
|
|
220
248
|
// which is a different statement from "you may not write
|
|
@@ -250,6 +278,24 @@ export function drive(options = {}) {
|
|
|
250
278
|
// Allow list including LOCK when class 2 is present. This
|
|
251
279
|
// list is what CORS advertises to browsers; the response
|
|
252
280
|
// Windows reads comes from nephele.
|
|
281
|
+
// No CORS headers on a DAV mount.
|
|
282
|
+
//
|
|
283
|
+
// They were inherited from the global default, which
|
|
284
|
+
// answers `access-control-allow-origin: *` — a claim about
|
|
285
|
+
// who may read this endpoint from a web page, made on an
|
|
286
|
+
// AUTHENTICATED mount, by nobody in particular. It is not
|
|
287
|
+
// an exposure today: the engine never configures
|
|
288
|
+
// `credentials: true`, so no browser attaches the Basic
|
|
289
|
+
// credentials cross-origin. It is a claim with no
|
|
290
|
+
// beneficiary either — this mount's clients are OS WebDAV
|
|
291
|
+
// redirectors, CLI tools and curl, none of which consult
|
|
292
|
+
// CORS at all.
|
|
293
|
+
//
|
|
294
|
+
// So it is stated rather than inherited. A project that
|
|
295
|
+
// genuinely wants a browser-side DAV client can put the
|
|
296
|
+
// endpoint behind its own app.
|
|
297
|
+
cors: false,
|
|
298
|
+
|
|
253
299
|
// Only the ENDPOINT path is a share. `OPTIONS /drive`
|
|
254
300
|
// and `OPTIONS /` answer a plain CORS 204 with no `DAV:`
|
|
255
301
|
// header, and that is right: there is no resource at the
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// The mount's own name, served as `displayname` on its root collection.
|
|
2
|
+
//
|
|
3
|
+
// A WebDAV client shows a folder label. WebDAV has a property for it —
|
|
4
|
+
// RFC 4918 §15.2, "a name for the resource suitable for presentation to a
|
|
5
|
+
// user" — and @nephele/adapter-file-system does not implement it: the name is
|
|
6
|
+
// commented out of its live-property list with a TODO, so a PROPFIND for it
|
|
7
|
+
// answers 404 and the client falls back to guessing from the URL.
|
|
8
|
+
//
|
|
9
|
+
// Guessing from the URL is not controllable. Express matches routes
|
|
10
|
+
// case-insensitively, so `/drive/SkinCheck`, `/drive/skincheck` and
|
|
11
|
+
// `/drive/SKINCHECK` all reach the same mount, and the casing a client ends up
|
|
12
|
+
// displaying is whichever one it happened to ask for first. An endpoint
|
|
13
|
+
// renamed to `SkinCheck` went on reading `skincheck` in Explorer.
|
|
14
|
+
//
|
|
15
|
+
// The name is already written down — it is the ENDPOINTS key, the URL segment
|
|
16
|
+
// and the mount log label — so the site can just answer the question instead.
|
|
17
|
+
//
|
|
18
|
+
// Applied to the root collection only. Every other resource is a real file or
|
|
19
|
+
// directory whose name the client reads from the path, and which the author
|
|
20
|
+
// did not choose in mikser's config.
|
|
21
|
+
|
|
22
|
+
// The root of a mount. `Resource.path` is relative to the adapter root with a
|
|
23
|
+
// trailing separator stripped, so the mount's own collection is the empty one.
|
|
24
|
+
const isMountRoot = (resource) => resource?.path === ''
|
|
25
|
+
|
|
26
|
+
// A Properties facade that answers one more question than the one it wraps.
|
|
27
|
+
//
|
|
28
|
+
// PROPFIND reaches properties through the BY-USER variants — listByUser,
|
|
29
|
+
// getAllByUser, getByUser — and the adapter's versions delegate to the plain
|
|
30
|
+
// ones on `this`. A Proxy binds methods to the TARGET, so wrapping only the
|
|
31
|
+
// plain three would leave PROPFIND reading straight past this. Both sets are
|
|
32
|
+
// wrapped, which is why the property actually appears.
|
|
33
|
+
function withDisplayNameProperty(properties, displayName) {
|
|
34
|
+
const answer = (name, fallback) =>
|
|
35
|
+
name === 'displayname' ? Promise.resolve(displayName) : fallback()
|
|
36
|
+
const including = async (listing) => {
|
|
37
|
+
const names = await listing()
|
|
38
|
+
return names.includes('displayname') ? names : ['displayname', ...names]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new Proxy(properties, {
|
|
42
|
+
get(target, prop, receiver) {
|
|
43
|
+
switch (prop) {
|
|
44
|
+
case 'get': return (name) => answer(name, () => target.get(name))
|
|
45
|
+
case 'getByUser': return (name, user) => answer(name, () => target.getByUser(name, user))
|
|
46
|
+
case 'list': return () => including(() => target.list())
|
|
47
|
+
case 'listByUser': return (user) => including(() => target.listByUser(user))
|
|
48
|
+
case 'listLive': return () => including(() => target.listLive())
|
|
49
|
+
case 'listLiveByUser': return (user) => including(() => target.listLiveByUser(user))
|
|
50
|
+
case 'getAll': return async () => ({ ...(await target.getAll()), displayname: displayName })
|
|
51
|
+
case 'getAllByUser': return async (user) => ({ ...(await target.getAllByUser(user)), displayname: displayName })
|
|
52
|
+
}
|
|
53
|
+
const value = Reflect.get(target, prop, receiver)
|
|
54
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function withDisplayNameResource(resource, displayName) {
|
|
60
|
+
return new Proxy(resource, {
|
|
61
|
+
get(target, prop, receiver) {
|
|
62
|
+
if (prop === 'getProperties') {
|
|
63
|
+
return async () => withDisplayNameProperty(await target.getProperties(), displayName)
|
|
64
|
+
}
|
|
65
|
+
const value = Reflect.get(target, prop, receiver)
|
|
66
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Decorate an adapter so its root collection reports `displayName`.
|
|
73
|
+
*
|
|
74
|
+
* Same shape as withStagedWrites, and composes with it: both wrap the three
|
|
75
|
+
* methods that hand back a resource, and each passes everything else through.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} adapter a nephele adapter
|
|
78
|
+
* @param {string} displayName what a client should call this mount
|
|
79
|
+
*/
|
|
80
|
+
export function withDisplayName(adapter, displayName) {
|
|
81
|
+
if (!displayName) return adapter
|
|
82
|
+
const wrap = (method) => async (...args) => {
|
|
83
|
+
const resource = await adapter[method](...args)
|
|
84
|
+
return isMountRoot(resource) ? withDisplayNameResource(resource, displayName) : resource
|
|
85
|
+
}
|
|
86
|
+
return new Proxy(adapter, {
|
|
87
|
+
get(target, prop, receiver) {
|
|
88
|
+
if (prop === 'getResource' || prop === 'newResource' || prop === 'newCollection') {
|
|
89
|
+
return wrap(prop)
|
|
90
|
+
}
|
|
91
|
+
const value = Reflect.get(target, prop, receiver)
|
|
92
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-drive",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.2.2",
|
|
4
4
|
"description": "WebDAV for mikser-io. Exposes working-folder directories as WebDAV endpoints via Nephele, authenticated through mikser-io-auth's htpasswd identity — so content can be edited from Finder, Explorer or any DAV client and the build picks the change up.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -51,6 +51,19 @@ before(async () => {
|
|
|
51
51
|
})
|
|
52
52
|
|
|
53
53
|
after(async () => {
|
|
54
|
+
// Destroy the sockets before closing, or the close never settles.
|
|
55
|
+
//
|
|
56
|
+
// `server.close()` waits for every connection to end, and these suites
|
|
57
|
+
// leave some that never will: fetch() pools keep-alives, and the
|
|
58
|
+
// interrupted-upload cases abort a request mid-body on purpose. The file's
|
|
59
|
+
// tests would all pass and the FILE would hang — "Promise resolution is
|
|
60
|
+
// still pending but the event loop has already resolved" — which in CI is
|
|
61
|
+
// a job that stops reporting and, before the 15-minute cap, ran until
|
|
62
|
+
// GitHub's own. It blocked two of today's releases.
|
|
63
|
+
//
|
|
64
|
+
// Reproducible with `--test-concurrency=2` (a CI runner's shape; a 16-core
|
|
65
|
+
// box hides it), and gone with this.
|
|
66
|
+
server?.closeAllConnections?.()
|
|
54
67
|
await new Promise(r => server?.close(r))
|
|
55
68
|
await rm(dir, { recursive: true, force: true })
|
|
56
69
|
})
|
package/test/derived.test.js
CHANGED
|
@@ -156,7 +156,27 @@ before(async (t) => {
|
|
|
156
156
|
})
|
|
157
157
|
|
|
158
158
|
after(async () => {
|
|
159
|
-
|
|
159
|
+
// WAIT for it to die, and escalate. `kill()` only sends the signal; the
|
|
160
|
+
// child's stdout/stderr are pipes we hold, so until it actually exits
|
|
161
|
+
// those handles keep THIS process alive and the run never ends. Every
|
|
162
|
+
// test passes and then node sits there — on CI that was fifteen minutes
|
|
163
|
+
// of nothing, ended by the job timeout and "Terminate orphan process".
|
|
164
|
+
//
|
|
165
|
+
// mikser handles SIGTERM itself (instance.js: close(); process.exit(0)),
|
|
166
|
+
// so the graceful path is the normal one; SIGKILL is the floor for when
|
|
167
|
+
// close() does not come back.
|
|
168
|
+
if (server && server.exitCode === null && server.signalCode === null) {
|
|
169
|
+
const exited = new Promise(resolve => server.once('exit', resolve))
|
|
170
|
+
server.kill()
|
|
171
|
+
const inTime = await Promise.race([
|
|
172
|
+
exited.then(() => true),
|
|
173
|
+
new Promise(resolve => setTimeout(() => resolve(false), 5000)),
|
|
174
|
+
])
|
|
175
|
+
if (!inTime) {
|
|
176
|
+
server.kill('SIGKILL')
|
|
177
|
+
await exited
|
|
178
|
+
}
|
|
179
|
+
}
|
|
160
180
|
if (dir) await rm(dir, { recursive: true, force: true })
|
|
161
181
|
})
|
|
162
182
|
|
package/test/drive.test.js
CHANGED
|
@@ -77,6 +77,19 @@ before(async () => {
|
|
|
77
77
|
})
|
|
78
78
|
|
|
79
79
|
after(async () => {
|
|
80
|
+
// Destroy the sockets before closing, or the close never settles.
|
|
81
|
+
//
|
|
82
|
+
// `server.close()` waits for every connection to end, and these suites
|
|
83
|
+
// leave some that never will: fetch() pools keep-alives, and the
|
|
84
|
+
// interrupted-upload cases abort a request mid-body on purpose. The file's
|
|
85
|
+
// tests would all pass and the FILE would hang — "Promise resolution is
|
|
86
|
+
// still pending but the event loop has already resolved" — which in CI is
|
|
87
|
+
// a job that stops reporting and, before the 15-minute cap, ran until
|
|
88
|
+
// GitHub's own. It blocked two of today's releases.
|
|
89
|
+
//
|
|
90
|
+
// Reproducible with `--test-concurrency=2` (a CI runner's shape; a 16-core
|
|
91
|
+
// box hides it), and gone with this.
|
|
92
|
+
server?.closeAllConnections?.()
|
|
80
93
|
await new Promise(r => server?.close(r))
|
|
81
94
|
await rm(dir, { recursive: true, force: true })
|
|
82
95
|
})
|
package/test/protocol.test.js
CHANGED
|
@@ -56,6 +56,19 @@ before(async () => {
|
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
after(async () => {
|
|
59
|
+
// Destroy the sockets before closing, or the close never settles.
|
|
60
|
+
//
|
|
61
|
+
// `server.close()` waits for every connection to end, and these suites
|
|
62
|
+
// leave some that never will: fetch() pools keep-alives, and the
|
|
63
|
+
// interrupted-upload cases abort a request mid-body on purpose. The file's
|
|
64
|
+
// tests would all pass and the FILE would hang — "Promise resolution is
|
|
65
|
+
// still pending but the event loop has already resolved" — which in CI is
|
|
66
|
+
// a job that stops reporting and, before the 15-minute cap, ran until
|
|
67
|
+
// GitHub's own. It blocked two of today's releases.
|
|
68
|
+
//
|
|
69
|
+
// Reproducible with `--test-concurrency=2` (a CI runner's shape; a 16-core
|
|
70
|
+
// box hides it), and gone with this.
|
|
71
|
+
server?.closeAllConnections?.()
|
|
59
72
|
await new Promise(r => server?.close(r))
|
|
60
73
|
await rm(dir, { recursive: true, force: true })
|
|
61
74
|
})
|
|
@@ -263,3 +276,58 @@ describe('known nephele non-compliance, pinned', () => {
|
|
|
263
276
|
assert.equal(res.status, 500, 'if this becomes 400, nephele fixed it')
|
|
264
277
|
})
|
|
265
278
|
})
|
|
279
|
+
|
|
280
|
+
describe('the mount answers to its own name', () => {
|
|
281
|
+
// A client labels the folder from `displayname`, and
|
|
282
|
+
// @nephele/adapter-file-system does not implement it — the name is
|
|
283
|
+
// commented out of its live-property list with a TODO — so a PROPFIND for
|
|
284
|
+
// it answered 404 and the client fell back to guessing from the URL.
|
|
285
|
+
//
|
|
286
|
+
// Guessing is not controllable: Express matches routes case-insensitively,
|
|
287
|
+
// so `/drive/SkinCheck` and `/drive/skincheck` reach the same mount and the
|
|
288
|
+
// casing shown is whichever the client asked for first. An endpoint renamed
|
|
289
|
+
// to `SkinCheck` went on reading `skincheck` in Explorer.
|
|
290
|
+
const propfind = (ep, body) => fetch(`http://127.0.0.1:${port}/drive/${ep}/`, {
|
|
291
|
+
method: 'PROPFIND',
|
|
292
|
+
headers: { authorization: AUTH, 'content-type': 'text/xml', depth: '0' },
|
|
293
|
+
body,
|
|
294
|
+
})
|
|
295
|
+
const NAMED = '<propfind xmlns="DAV:"><prop><displayname/></prop></propfind>'
|
|
296
|
+
const ALLPROP = '<propfind xmlns="DAV:"><allprop/></propfind>'
|
|
297
|
+
|
|
298
|
+
it('reports the endpoint key as displayname, asked for by name', async () => {
|
|
299
|
+
const res = await propfind('emulated', NAMED)
|
|
300
|
+
const xml = await res.text()
|
|
301
|
+
assert.equal(res.status, 207, xml)
|
|
302
|
+
assert.match(xml, /<[^>]*displayname[^>]*>emulated</,
|
|
303
|
+
`displayname must carry the endpoint key — got:\n${xml}`)
|
|
304
|
+
assert.doesNotMatch(xml, /displayname property was not found/, xml)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('includes it in allprop, which is how a client usually asks', async () => {
|
|
308
|
+
// PROPFIND reaches properties through the BY-USER variants, and the
|
|
309
|
+
// adapter's delegate to the plain ones on `this` — so a decorator that
|
|
310
|
+
// wrapped only the plain three would be read straight past. This is the
|
|
311
|
+
// case that catches that.
|
|
312
|
+
const xml = await (await propfind('emulated', ALLPROP)).text()
|
|
313
|
+
assert.match(xml, /<[^>]*displayname[^>]*>emulated</, `allprop must include it — got:\n${xml}`)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('names each endpoint separately, since each is its own mount', async () => {
|
|
317
|
+
const xml = await (await propfind('readonly', NAMED)).text()
|
|
318
|
+
assert.match(xml, /<[^>]*displayname[^>]*>readonly</, xml)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('leaves the files inside it alone', async () => {
|
|
322
|
+
// Only the mount ROOT is named in config. Everything below is a real
|
|
323
|
+
// file whose name the client reads from the path.
|
|
324
|
+
const res = await fetch(`http://127.0.0.1:${port}/drive/emulated/page.md`, {
|
|
325
|
+
method: 'PROPFIND',
|
|
326
|
+
headers: { authorization: AUTH, 'content-type': 'text/xml', depth: '0' },
|
|
327
|
+
body: NAMED,
|
|
328
|
+
})
|
|
329
|
+
const xml = await res.text()
|
|
330
|
+
assert.doesNotMatch(xml, /<[^>]*displayname[^>]*>emulated</,
|
|
331
|
+
`a file must not inherit the mount's name — got:\n${xml}`)
|
|
332
|
+
})
|
|
333
|
+
})
|
package/test/real-client.test.js
CHANGED
|
@@ -67,6 +67,19 @@ before(async () => {
|
|
|
67
67
|
})
|
|
68
68
|
|
|
69
69
|
after(async () => {
|
|
70
|
+
// Destroy the sockets before closing, or the close never settles.
|
|
71
|
+
//
|
|
72
|
+
// `server.close()` waits for every connection to end, and these suites
|
|
73
|
+
// leave some that never will: fetch() pools keep-alives, and the
|
|
74
|
+
// interrupted-upload cases abort a request mid-body on purpose. The file's
|
|
75
|
+
// tests would all pass and the FILE would hang — "Promise resolution is
|
|
76
|
+
// still pending but the event loop has already resolved" — which in CI is
|
|
77
|
+
// a job that stops reporting and, before the 15-minute cap, ran until
|
|
78
|
+
// GitHub's own. It blocked two of today's releases.
|
|
79
|
+
//
|
|
80
|
+
// Reproducible with `--test-concurrency=2` (a CI runner's shape; a 16-core
|
|
81
|
+
// box hides it), and gone with this.
|
|
82
|
+
server?.closeAllConnections?.()
|
|
70
83
|
await new Promise(r => server?.close(r))
|
|
71
84
|
await rm(dir, { recursive: true, force: true })
|
|
72
85
|
})
|