mikser-io-drive 11.3.0 → 11.4.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.
- package/README.md +64 -6
- package/index.js +48 -14
- package/lib/emulated-locks.js +147 -0
- package/package.json +1 -1
- package/test/protocol.test.js +51 -9
- package/test/root-listing.test.js +26 -0
package/README.md
CHANGED
|
@@ -137,12 +137,31 @@ Three protocol facts worth knowing, all asserted in `test/protocol.test.js`:
|
|
|
137
137
|
| --- | --- |
|
|
138
138
|
| default (`emulate`) | `DAV: 1, 3, 2` — **class 2**, which macOS requires for a read-write mount |
|
|
139
139
|
| `locks: 'disallow'` | drops class 2 and `LOCK` from `Allow`. **Finder will refuse a read-write mount.** A trap, because it is invisible until someone tries |
|
|
140
|
-
| `emulate` vs `meta-files` | both
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
140
|
+
| `emulate` vs `meta-files` | both are real locking now (11.4.0): the `<activelock>` is in the LOCK body and in a later PROPFIND, and a held resource answers `423`. They differ in where the lock lives — memory, or a sidecar |
|
|
141
|
+
|
|
142
|
+
**Until 11.4.0 `emulate` stored no lock at all** — a token in the header and an
|
|
143
|
+
empty `<lockdiscovery/>`, which this table used to describe as survivable
|
|
144
|
+
because "clients read the header". The Windows WebDAV redirector does not: it
|
|
145
|
+
needs the `<activelock>` RFC 4918 §9.10.1 requires, and without it a mapped
|
|
146
|
+
drive lists and reads perfectly and **cannot be written to**. Every save fails
|
|
147
|
+
with "The parameter is incorrect" and leaves a zero-byte file, because Windows
|
|
148
|
+
PUTs an empty file, LOCKs it, PROPFINDs it, finds no lock, and loops until it
|
|
149
|
+
gives up. It reads like a permissions problem and is not.
|
|
150
|
+
|
|
151
|
+
`emulate` now keeps locks in memory, so the folder stays free of sidecars and
|
|
152
|
+
the locks are real. They do not survive a restart — the same observable as
|
|
153
|
+
every lock timing out at once, which is why locks carry timeouts — and there is
|
|
154
|
+
no second process to share them with, since one mikser instance owns a working
|
|
155
|
+
folder.
|
|
156
|
+
|
|
157
|
+
Choose `meta-files` if you need **dead properties** to persist. That is the one
|
|
158
|
+
thing `emulate` still does not do, deliberately: a PROPPATCH answers `207` and
|
|
159
|
+
the property is gone by the next PROPFIND (measured). Properties are meant to
|
|
160
|
+
last, and an in-memory store would lose them on restart while a same-session
|
|
161
|
+
read made them look durable — not storing is the honest answer. In practice
|
|
162
|
+
what Windows PROPPATCHes is its Win32 timestamps, so on `emulate` a file keeps
|
|
163
|
+
the server's mtime rather than the client's. It does not affect the write: the
|
|
164
|
+
PUT has already succeeded by then.
|
|
146
165
|
|
|
147
166
|
One more, because it surprises people: **`LOCK` on a path that does not exist
|
|
148
167
|
creates an empty file** (RFC 4918 §9.10.4). A client that locks before writing
|
|
@@ -353,6 +372,45 @@ class of problem from Nephele's four known gaps, and it carries 65 open issues
|
|
|
353
372
|
against Nephele's zero. Nephele is also Apache-2.0 and load-bearing for its
|
|
354
373
|
author's own product, which is the maintenance signal that matters most.
|
|
355
374
|
|
|
375
|
+
**Writing it here was considered and is not close.** RFC 4918 is about 4,650
|
|
376
|
+
lines in Nephele — 2,994 of method handlers and 1,662 of machinery — and the
|
|
377
|
+
weight is not where it looks:
|
|
378
|
+
|
|
379
|
+
| | lines | |
|
|
380
|
+
| --- | --- | --- |
|
|
381
|
+
| `Method.js` | 930 | the base class every method inherits |
|
|
382
|
+
| — `checkIfHeader` | 234 | the `If:` header alone |
|
|
383
|
+
| — `checkConditionalHeaders` | 77 | |
|
|
384
|
+
| `LOCK` + `UNLOCK` | 442 | plus 81 lock references threaded through `Method.js` |
|
|
385
|
+
| `MOVE` + `COPY` | 438 | `Depth` and `Overwrite` semantics |
|
|
386
|
+
| `PROPFIND` | 276 | |
|
|
387
|
+
| `GET`/`HEAD` | 286 | ranges, etags |
|
|
388
|
+
|
|
389
|
+
Listing files is the cheap part. `If:` is a grammar rather than a value —
|
|
390
|
+
tagged and untagged lists, `Not`, lock tokens and etags mixed, evaluated
|
|
391
|
+
against resources other than the request target — and getting it wrong means
|
|
392
|
+
Finder's Save As either walks over somebody's lock or refuses forever. Locking
|
|
393
|
+
is not isolated in `LOCK.js` either: provisional locks, timeouts, per-user lock
|
|
394
|
+
permission and token extraction run on every write method.
|
|
395
|
+
|
|
396
|
+
And the litmus column above is what would be given up: 95 of 104 on
|
|
397
|
+
`meta-files`, with four known gaps named and pinned. A hand-written server
|
|
398
|
+
starts at zero and earns each point by a client breaking in someone's hands.
|
|
399
|
+
|
|
400
|
+
The read-only case is genuinely different — `OPTIONS` + `PROPFIND` + `GET` with
|
|
401
|
+
no locking and no conditionals is a few hundred lines, and the drive root in
|
|
402
|
+
`lib/root-listing.js` is already that shape. It is not the case anyone is
|
|
403
|
+
asking for.
|
|
404
|
+
|
|
405
|
+
**The sharper version of the question is the ADAPTER, not the engine.** Three
|
|
406
|
+
things have been worked around here, and all three are in
|
|
407
|
+
`@nephele/adapter-file-system` rather than in the protocol: `displayname`
|
|
408
|
+
commented out of its live-property list with a TODO, a flat `max-age=604800` on
|
|
409
|
+
the OPTIONS response, and a read-only mount advertising `PUT` in `Allow`. The
|
|
410
|
+
adapter answers "what is at this path and what are its properties", which is
|
|
411
|
+
the thin half. If it keeps costing, replace it and keep the 4,650 lines that
|
|
412
|
+
matter.
|
|
413
|
+
|
|
356
414
|
Staying on Nephele. If its gaps ever become the binding constraint, the
|
|
357
415
|
serious alternative is not another npm package — it is **Apache `mod_dav`**,
|
|
358
416
|
the implementation litmus was written to test, which reads `htpasswd` and
|
package/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { registerFileTools } from './lib/files.js'
|
|
|
6
6
|
import { withStagedWrites, stageWrites } from './lib/staged-writes.js'
|
|
7
7
|
import { withDisplayName } from './lib/display-name.js'
|
|
8
8
|
import { rootListing } from './lib/root-listing.js'
|
|
9
|
+
import { withEmulatedLockStore } from './lib/emulated-locks.js'
|
|
9
10
|
|
|
10
11
|
export { MikserAuthenticator, withStagedWrites, stageWrites }
|
|
11
12
|
|
|
@@ -47,9 +48,12 @@ export const writeCapability = (name) => `drive:${name}:write`
|
|
|
47
48
|
* data: { folder: 'data', readOnly: true },
|
|
48
49
|
* Reports: { folder: 'reports' }, // shows as "Reports"
|
|
49
50
|
* q3: { folder: 'q3', displayName: 'Q3 reports' },
|
|
50
|
-
* share: { folder: 'share', host: 'drive.example.com' },
|
|
51
51
|
* },
|
|
52
52
|
* auth: identity,
|
|
53
|
+
* // A domain of its own, where `/` lists the endpoints the caller
|
|
54
|
+
* // may read. Belongs to the DRIVE, not to one endpoint: the whole
|
|
55
|
+
* // point is that several endpoints share it.
|
|
56
|
+
* host: 'drive.example.com',
|
|
53
57
|
* })
|
|
54
58
|
*
|
|
55
59
|
* One Nephele server per endpoint, mounted at `<base>/<name>` — the same
|
|
@@ -90,12 +94,29 @@ export function drive(options = {}) {
|
|
|
90
94
|
// browse and commit should not fill up with page.md.nephelemeta
|
|
91
95
|
// files.
|
|
92
96
|
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
97
|
+
// `locks: 'emulate'` is a real locking implementation now — see
|
|
98
|
+
// lib/emulated-locks.js. It stored nothing until 11.4.0, answering an
|
|
99
|
+
// empty <lockdiscovery/>, and the comment here called that cost
|
|
100
|
+
// "measurable and small", affecting "a client that parses the body for
|
|
101
|
+
// the token". That client is the Windows WebDAV redirector, and the
|
|
102
|
+
// cost was that the drive could not be WRITTEN to from Explorer at
|
|
103
|
+
// all: every save failed with "The parameter is incorrect" and left a
|
|
104
|
+
// zero-byte file. Listing and reading worked, which is what made it
|
|
105
|
+
// look like a permissions problem for weeks.
|
|
106
|
+
//
|
|
107
|
+
// `properties: 'emulate'` still reports success for a PROPPATCH and
|
|
108
|
+
// stores nothing — measured: 207 back, and the property is gone on the
|
|
109
|
+
// next PROPFIND. That is deliberate where the lock case was not.
|
|
110
|
+
// Locks carry timeouts, so keeping them in memory loses nothing a
|
|
111
|
+
// client does not already handle; dead properties are meant to
|
|
112
|
+
// persist, and an in-memory store would lose them on restart while a
|
|
113
|
+
// same-session read made them look durable. Not storing is the honest
|
|
114
|
+
// answer; 'meta-files' is the one that stores them.
|
|
115
|
+
//
|
|
116
|
+
// What Windows PROPPATCHes after a write is its Win32 timestamps, so
|
|
117
|
+
// on 'emulate' a file keeps the server's mtime rather than the
|
|
118
|
+
// client's. It does not block the write — the PUT is already done by
|
|
119
|
+
// then.
|
|
99
120
|
//
|
|
100
121
|
// Do NOT choose 'disallow' if macOS clients matter: it drops DAV
|
|
101
122
|
// compliance class 2 from the OPTIONS response, and Finder refuses a
|
|
@@ -145,6 +166,18 @@ export function drive(options = {}) {
|
|
|
145
166
|
if (!ep.folder) {
|
|
146
167
|
throw new Error(`drive: endpoint ${JSON.stringify(name)} declares no folder`)
|
|
147
168
|
}
|
|
169
|
+
// `host` belongs to the drive, not to an endpoint — several
|
|
170
|
+
// endpoints share one domain, and `/` on it lists them. Said
|
|
171
|
+
// out loud because the alternative is a config that reads as
|
|
172
|
+
// though it works and does nothing: this option was
|
|
173
|
+
// per-endpoint while it was being designed, and the example in
|
|
174
|
+
// this file's own JSDoc showed it that way after it moved.
|
|
175
|
+
if (ep.host) {
|
|
176
|
+
logger.warn(
|
|
177
|
+
'drive: endpoint %j sets `host`, which is a plugin-level option and is ignored here. '
|
|
178
|
+
+ 'Move it beside `endpoints`: drive({ host: %j, endpoints: { … } }).',
|
|
179
|
+
name, ep.host)
|
|
180
|
+
}
|
|
148
181
|
const root = resolve(ep.folder)
|
|
149
182
|
const mountPath = `${base}/${name}`
|
|
150
183
|
|
|
@@ -252,13 +285,14 @@ export function drive(options = {}) {
|
|
|
252
285
|
// badly as a folder label. `displayName` overrides it for
|
|
253
286
|
// a key that is not presentable.
|
|
254
287
|
adapter: withDisplayName(
|
|
255
|
-
(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
288
|
+
withEmulatedLockStore(
|
|
289
|
+
(ep.atomicWrites === false)
|
|
290
|
+
? fsAdapter
|
|
291
|
+
: withStagedWrites(fsAdapter, {
|
|
292
|
+
onFailure: (err, file) => logger.warn(
|
|
293
|
+
'drive: upload of %s failed, original left intact — %s',
|
|
294
|
+
path.basename(file), err.message),
|
|
295
|
+
})),
|
|
262
296
|
ep.displayName ?? name,
|
|
263
297
|
),
|
|
264
298
|
authenticator,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Real locks, without sidecar files.
|
|
2
|
+
//
|
|
3
|
+
// `locks: 'emulate'` kept the content folder clean by not storing locks at
|
|
4
|
+
// all: the adapter's readMetadataFile returns `{}` unless locks are
|
|
5
|
+
// 'meta-files', so `Lock.save()` writes nowhere and `getLocks()` answers `[]`
|
|
6
|
+
// the moment the response is sent. The token in the `Lock-Token` header is the
|
|
7
|
+
// only trace the lock ever existed.
|
|
8
|
+
//
|
|
9
|
+
// That is not a cosmetic gap. The Microsoft WebDAV redirector needs the
|
|
10
|
+
// `<activelock>` that RFC 4918 §9.10.1 requires in the LOCK body, and without
|
|
11
|
+
// it Windows cannot WRITE to the drive at all — it PUTs an empty file, LOCKs
|
|
12
|
+
// it, PROPFINDs it, sees no lock, and loops until it gives up with
|
|
13
|
+
// ERROR_INVALID_PARAMETER. Listing and reading work, so the share looks
|
|
14
|
+
// mounted and healthy while every save fails with "The parameter is
|
|
15
|
+
// incorrect". Captured on the wire against
|
|
16
|
+
// Microsoft-WebDAV-MiniRedir/10.0.22631.
|
|
17
|
+
//
|
|
18
|
+
// So locks are kept in memory instead. The whole lock lifecycle — save,
|
|
19
|
+
// delete, getLocks, and the lockdiscovery PROPFIND reads — goes through those
|
|
20
|
+
// two methods on the RESOURCE, which makes them the seam: route the `locks`
|
|
21
|
+
// half of the metadata through a Map and leave the `props` half exactly as it
|
|
22
|
+
// was.
|
|
23
|
+
//
|
|
24
|
+
// In memory is the right scope here rather than a compromise. One mikser
|
|
25
|
+
// instance owns a working folder — the engine enforces it, forwarding any
|
|
26
|
+
// second invocation over a unix socket rather than opening the folder twice —
|
|
27
|
+
// so there is no other process to share a lock with. What it does mean is that
|
|
28
|
+
// locks do not survive a restart, which is the same observable as every lock
|
|
29
|
+
// timing out at once, and is why they carry timeouts.
|
|
30
|
+
|
|
31
|
+
// path -> { token -> entry }, shared by every resource from one adapter.
|
|
32
|
+
// Keyed by absolute path because that is what identifies a resource across the
|
|
33
|
+
// several Resource objects a single request can produce.
|
|
34
|
+
const storeFor = new WeakMap()
|
|
35
|
+
|
|
36
|
+
function locksFor(adapter) {
|
|
37
|
+
if (!storeFor.has(adapter)) storeFor.set(adapter, new Map())
|
|
38
|
+
return storeFor.get(adapter)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The lock methods themselves, not the metadata underneath them.
|
|
42
|
+
//
|
|
43
|
+
// Intercepting readMetadataFile looks like the smaller seam and does nothing:
|
|
44
|
+
// a Proxy binds methods to the TARGET, so when `getLocks()` runs it is the raw
|
|
45
|
+
// resource's `this.readMetadataFile()` that answers, and the interception is
|
|
46
|
+
// never reached. The same shape cost an afternoon on `displayname`, where
|
|
47
|
+
// PROPFIND reads through the by-user variants and those delegate to the plain
|
|
48
|
+
// ones on `this`.
|
|
49
|
+
//
|
|
50
|
+
// So the three methods that make up the lock lifecycle are replaced outright,
|
|
51
|
+
// and the metadata file is left alone.
|
|
52
|
+
function withEmulatedLocks(resource, locks) {
|
|
53
|
+
const key = resource.absolutePath
|
|
54
|
+
const held = () => locks.get(key) ?? {}
|
|
55
|
+
|
|
56
|
+
// A Lock that saves to the store instead of to a sidecar. Built from the
|
|
57
|
+
// adapter's own, so it is the adapter's Lock class with the adapter's
|
|
58
|
+
// defaults — only the two persistence methods differ.
|
|
59
|
+
const stored = (lock) => new Proxy(lock, {
|
|
60
|
+
get(target, prop, receiver) {
|
|
61
|
+
if (prop === 'save') {
|
|
62
|
+
return async () => {
|
|
63
|
+
locks.set(key, {
|
|
64
|
+
...held(),
|
|
65
|
+
[target.token]: {
|
|
66
|
+
username: target.username,
|
|
67
|
+
date: target.date.getTime(),
|
|
68
|
+
timeout: target.timeout,
|
|
69
|
+
scope: target.scope,
|
|
70
|
+
depth: target.depth,
|
|
71
|
+
provisional: target.provisional,
|
|
72
|
+
owner: target.owner,
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (prop === 'delete') {
|
|
78
|
+
return async () => {
|
|
79
|
+
const rest = { ...held() }
|
|
80
|
+
delete rest[target.token]
|
|
81
|
+
if (Object.keys(rest).length) locks.set(key, rest)
|
|
82
|
+
else locks.delete(key)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const value = Reflect.get(target, prop, receiver)
|
|
86
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const revive = async (token, entry) => {
|
|
91
|
+
const lock = await resource.createLockForUser({ username: entry.username })
|
|
92
|
+
lock.token = token
|
|
93
|
+
lock.date = new Date(entry.date)
|
|
94
|
+
lock.timeout = entry.timeout
|
|
95
|
+
lock.scope = entry.scope
|
|
96
|
+
lock.depth = entry.depth
|
|
97
|
+
lock.provisional = entry.provisional
|
|
98
|
+
lock.owner = entry.owner
|
|
99
|
+
return stored(lock)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return new Proxy(resource, {
|
|
103
|
+
get(target, prop, receiver) {
|
|
104
|
+
if (prop === 'getLocks') {
|
|
105
|
+
return async () => Promise.all(
|
|
106
|
+
Object.entries(held()).map(([token, entry]) => revive(token, entry)))
|
|
107
|
+
}
|
|
108
|
+
if (prop === 'getLocksByUser') {
|
|
109
|
+
return async (user) => Promise.all(
|
|
110
|
+
Object.entries(held())
|
|
111
|
+
.filter(([, entry]) => entry.username === user?.username)
|
|
112
|
+
.map(([token, entry]) => revive(token, entry)))
|
|
113
|
+
}
|
|
114
|
+
if (prop === 'createLockForUser') {
|
|
115
|
+
return async (user) => stored(await target.createLockForUser(user))
|
|
116
|
+
}
|
|
117
|
+
const value = Reflect.get(target, prop, receiver)
|
|
118
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Give an adapter's resources a lock store that outlives the response.
|
|
125
|
+
*
|
|
126
|
+
* Same shape as withStagedWrites and withDisplayName, and composes with both:
|
|
127
|
+
* each wraps the three methods that hand back a resource and passes the rest
|
|
128
|
+
* through.
|
|
129
|
+
*
|
|
130
|
+
* @param {object} adapter a nephele adapter
|
|
131
|
+
*/
|
|
132
|
+
export function withEmulatedLockStore(adapter) {
|
|
133
|
+
const locks = locksFor(adapter)
|
|
134
|
+
const wrap = (method) => async (...args) => {
|
|
135
|
+
const resource = await adapter[method](...args)
|
|
136
|
+
return resource ? withEmulatedLocks(resource, locks) : resource
|
|
137
|
+
}
|
|
138
|
+
return new Proxy(adapter, {
|
|
139
|
+
get(target, prop, receiver) {
|
|
140
|
+
if (prop === 'getResource' || prop === 'newResource' || prop === 'newCollection') {
|
|
141
|
+
return wrap(prop)
|
|
142
|
+
}
|
|
143
|
+
const value = Reflect.get(target, prop, receiver)
|
|
144
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-drive",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.4.0",
|
|
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",
|
package/test/protocol.test.js
CHANGED
|
@@ -142,19 +142,61 @@ describe('DAV compliance class — what decides whether Finder will mount', () =
|
|
|
142
142
|
}
|
|
143
143
|
})
|
|
144
144
|
|
|
145
|
-
it(
|
|
146
|
-
// RFC 4918 puts the activelock in the body
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
it('every mode that advertises class 2 returns a real activelock, and reports it again', async () => {
|
|
146
|
+
// RFC 4918 §9.10.1 puts the activelock in the LOCK body, and the
|
|
147
|
+
// Microsoft WebDAV redirector needs it. Without it Windows cannot
|
|
148
|
+
// WRITE to the drive at all: it PUTs an empty file, LOCKs it,
|
|
149
|
+
// PROPFINDs it, finds no lock, and loops until it gives up with
|
|
150
|
+
// ERROR_INVALID_PARAMETER — so the share lists and reads perfectly
|
|
151
|
+
// while every save fails with "The parameter is incorrect" and leaves
|
|
152
|
+
// a zero-byte file behind.
|
|
153
|
+
//
|
|
154
|
+
// `emulate` used to answer an empty <lockdiscovery/>, because it
|
|
155
|
+
// stored no lock at all — the token in the header was the only trace
|
|
156
|
+
// the lock had ever existed. It keeps them in memory now.
|
|
157
|
+
//
|
|
158
|
+
// Asserted for EVERY mode that claims class 2 in OPTIONS, because
|
|
159
|
+
// claiming it is the promise this checks. A status-only assertion is
|
|
160
|
+
// what let the gap ship.
|
|
161
|
+
for (const ep of ['emulated', 'meta']) {
|
|
162
|
+
const res = await lock(ep, `activelock-${ep}.md`)
|
|
163
|
+
const body = await res.text()
|
|
164
|
+
const token = res.headers.get('lock-token')
|
|
165
|
+
|
|
166
|
+
assert.match(body, /<activelock>/, `${ep}: no activelock in the LOCK body\n${body}`)
|
|
167
|
+
assert.match(body, /<locktoken>/, `${ep}: no locktoken\n${body}`)
|
|
168
|
+
// The token in the body must BE the token in the header. A body
|
|
169
|
+
// carrying a different one is worse than an empty body.
|
|
170
|
+
const inBody = body.match(/<locktoken>\s*<href>([^<]+)<\/href>/)?.[1]
|
|
171
|
+
assert.equal(`<${inBody}>`, token, `${ep}: body token and header disagree\n${body}`)
|
|
172
|
+
|
|
173
|
+
// And it has to be discoverable afterwards, which is the half a
|
|
174
|
+
// client uses to find out whether its own lock still holds.
|
|
175
|
+
const found = await fetch(`http://127.0.0.1:${port}/drive/${ep}/activelock-${ep}.md`, {
|
|
176
|
+
method: 'PROPFIND',
|
|
177
|
+
headers: { authorization: AUTH, 'content-type': 'text/xml', depth: '0' },
|
|
178
|
+
body: '<?xml version="1.0"?><propfind xmlns="DAV:"><prop><lockdiscovery/></prop></propfind>',
|
|
179
|
+
})
|
|
180
|
+
const discovered = await found.text()
|
|
181
|
+
assert.match(discovered, /<activelock>/,
|
|
182
|
+
`${ep}: PROPFIND does not report the lock\n${discovered}`)
|
|
183
|
+
assert.ok(discovered.includes(inBody),
|
|
184
|
+
`${ep}: PROPFIND reports a different token\n${discovered}`)
|
|
185
|
+
}
|
|
154
186
|
})
|
|
155
187
|
})
|
|
156
188
|
|
|
157
189
|
describe('a persisted lock is honoured', () => {
|
|
190
|
+
it("'emulate' refuses a second LOCK too, which is what makes it real", async () => {
|
|
191
|
+
// The other half of storing locks. A mode that hands out a token to
|
|
192
|
+
// anyone who asks is a polite fiction; refusing the second caller is
|
|
193
|
+
// what a client is relying on when it takes one.
|
|
194
|
+
const first = await lock('emulated', 'contended-emulated.md')
|
|
195
|
+
assert.ok([200, 201].includes(first.status), `first lock: ${first.status}`)
|
|
196
|
+
const second = await lock('emulated', 'contended-emulated.md')
|
|
197
|
+
assert.equal(second.status, 423, `a held resource must answer 423 Locked, got ${second.status}`)
|
|
198
|
+
})
|
|
199
|
+
|
|
158
200
|
it('a second LOCK on an already-locked resource is refused', async () => {
|
|
159
201
|
// Only meaningful where locks are persisted. Worth asserting because
|
|
160
202
|
// it is what makes 'meta-files' a real locking implementation rather
|
|
@@ -113,6 +113,32 @@ after(async () => {
|
|
|
113
113
|
await rm(dir, { recursive: true, force: true })
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
describe('host in the wrong place', () => {
|
|
117
|
+
// It was per-endpoint while this was being designed, and moved to the
|
|
118
|
+
// plugin when the root listing arrived — several endpoints share one
|
|
119
|
+
// domain, and `/` on it lists them. This file's own JSDoc went on showing
|
|
120
|
+
// the old shape, which is a config that reads as though it works and does
|
|
121
|
+
// nothing at all. So it says so.
|
|
122
|
+
it('warns instead of silently ignoring it', async () => {
|
|
123
|
+
const said = []
|
|
124
|
+
const core = {
|
|
125
|
+
runtime,
|
|
126
|
+
onLoad: () => {},
|
|
127
|
+
onLoaded: (cb) => said.push(cb),
|
|
128
|
+
useLogger: () => ({
|
|
129
|
+
info(){}, error(){}, debug(){}, trace(){},
|
|
130
|
+
warn: (...args) => said.push(args.join(' ')),
|
|
131
|
+
}),
|
|
132
|
+
}
|
|
133
|
+
drive({ endpoints: { Share: { folder: 'documents', host: 'drive.example.test' } } })(core)
|
|
134
|
+
for (const entry of [...said]) if (typeof entry === 'function') await entry()
|
|
135
|
+
|
|
136
|
+
const warning = said.find(entry =>
|
|
137
|
+
typeof entry === 'string' && entry.includes('plugin-level option'))
|
|
138
|
+
assert.ok(warning, `no warning about a misplaced host:\n${said.filter(e => typeof e === 'string').join('\n')}`)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
116
142
|
describe('the drive root on its own host', () => {
|
|
117
143
|
it('announces itself as WebDAV, which is what the redirector asks first', async () => {
|
|
118
144
|
// Answered without credentials on purpose: this is discovery, and it
|