mikser-io-drive 11.5.0 → 11.6.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/README.md +56 -0
- package/clients/windows/rclone-outbox.vbs +79 -0
- package/index.js +22 -0
- package/lib/authenticator.js +9 -3
- package/lib/root-listing.js +3 -3
- package/package.json +4 -4
- package/test/derived.test.js +33 -4
- package/test/drive.test.js +5 -2
- package/test/root-denial.test.js +21 -0
- package/test/root-listing.test.js +15 -0
package/README.md
CHANGED
|
@@ -238,6 +238,23 @@ drive({ host: 'drive.example.com', anonymousDiscovery: true, … })
|
|
|
238
238
|
|
|
239
239
|
That relaxes discovery only. `PROPFIND /` still requires credentials.
|
|
240
240
|
|
|
241
|
+
### The Basic challenge follows your auth configuration
|
|
242
|
+
|
|
243
|
+
The drive writes its own `WWW-Authenticate: Basic` header rather than
|
|
244
|
+
delegating to the verifier — a composite verifier challenges with whichever
|
|
245
|
+
member can be satisfied interactively, and where OAuth discovery is configured
|
|
246
|
+
that is Bearer, which Explorer and Finder do not speak.
|
|
247
|
+
|
|
248
|
+
The `charset="UTF-8"` parameter is not a decision this package makes. It reads
|
|
249
|
+
`basicCharset` from the auth plugin, so a deployment sets it once:
|
|
250
|
+
|
|
251
|
+
```js
|
|
252
|
+
auth({ basicCharset: false }) // on by default
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
It briefly defaulted off on a theory about the Windows redirector that did not
|
|
256
|
+
survive measurement. See the auth package's README.
|
|
257
|
+
|
|
241
258
|
### Compliance classes at the root
|
|
242
259
|
|
|
243
260
|
The root advertises `DAV: 1, 2, 3` — the same classes as the endpoints
|
|
@@ -257,6 +274,45 @@ a LOCK nothing sends, since clients lock the files they edit and those live
|
|
|
257
274
|
under the endpoints, which support locking for real. `Allow` stays truthful, so
|
|
258
275
|
a client that asks what the root itself permits gets the right answer.
|
|
259
276
|
|
|
277
|
+
### When Windows cannot keep the mount
|
|
278
|
+
|
|
279
|
+
Everything above gets Explorer to mount the drive. Keeping it mounted across a
|
|
280
|
+
restart is a separate problem, and on a machine that has to run unattended it
|
|
281
|
+
is not solvable from this side.
|
|
282
|
+
|
|
283
|
+
The redirector reads saved credentials from the **Domain Password** store.
|
|
284
|
+
Basic credentials cannot be persisted there — [KB 2673544][kb] documents this
|
|
285
|
+
as by design, because WinHTTP cannot retrieve a saved Basic credential — so
|
|
286
|
+
"remember my credentials" quietly stores nothing the reconnect can use. It
|
|
287
|
+
looks like a server fault and is not one. Measured against a live endpoint,
|
|
288
|
+
a reconnect sent four requests, **none** of them carrying an `Authorization`
|
|
289
|
+
header, and gave up; the credential was sitting in Credential Manager the whole
|
|
290
|
+
time, in the `Generic` store that `cmdkey /generic:` writes and the redirector
|
|
291
|
+
never reads. Apache `mod_dav` on the same host, with the same credential,
|
|
292
|
+
failed identically — which is the useful part of the measurement.
|
|
293
|
+
|
|
294
|
+
So for a machine that exports documents on its own, do not mount the drive.
|
|
295
|
+
Have the application write to a local folder and push that folder up:
|
|
296
|
+
|
|
297
|
+
```
|
|
298
|
+
wscript rclone-outbox.vbs "C:\SkinCheck" SkinCheck:Janus 120
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
[`clients/windows/rclone-outbox.vbs`](clients/windows/rclone-outbox.vbs) is
|
|
302
|
+
that loop — an [rclone][rclone] `move` on an interval, run by `wscript.exe` so
|
|
303
|
+
no console window appears, and started by a shortcut in `shell:startup` so it
|
|
304
|
+
needs no elevation and no Scheduled Task. rclone authenticates from its own
|
|
305
|
+
config on every request and has no reconnect to lose, which is the whole reason
|
|
306
|
+
it works where the mount does not.
|
|
307
|
+
|
|
308
|
+
This trades a drive letter for a folder, and it is worth being clear about what
|
|
309
|
+
that costs: nobody browses the server from the machine any more. In exchange an
|
|
310
|
+
export can no longer fail because a mount dropped, and a server that is
|
|
311
|
+
unreachable delays the upload instead of losing the document.
|
|
312
|
+
|
|
313
|
+
[kb]: https://support.microsoft.com/help/2673544
|
|
314
|
+
[rclone]: https://rclone.org/webdav/
|
|
315
|
+
|
|
260
316
|
## What the folder is called
|
|
261
317
|
|
|
262
318
|
The endpoint key is the name, and the mount reports it as `displayname`:
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
' rclone-outbox.vbs - hidden, looping uploader for a mikser-io-drive endpoint.
|
|
2
|
+
'
|
|
3
|
+
' Windows cannot mount this drive unattended. The WebDAV redirector reads its
|
|
4
|
+
' credentials from the Domain Password store and refuses to persist Basic
|
|
5
|
+
' credentials there at all (KB 2673544), so a mapped drive re-prompts after
|
|
6
|
+
' every restart and an export silently lands on a dead letter. Measured against
|
|
7
|
+
' a live endpoint: four redirector requests during a reconnect, none of them
|
|
8
|
+
' carrying an Authorization header, while rclone authenticated on its first.
|
|
9
|
+
'
|
|
10
|
+
' So the export never touches the network. The exporting application writes to
|
|
11
|
+
' a local folder and this moves the folder's contents up, which means an export
|
|
12
|
+
' cannot fail because a mount dropped, and an unreachable server only delays
|
|
13
|
+
' the upload instead of losing the file.
|
|
14
|
+
'
|
|
15
|
+
' This is a .vbs and not a .cmd because wscript.exe runs with no console at
|
|
16
|
+
' all. A .cmd on a two-minute timer flashes a window on the desktop every two
|
|
17
|
+
' minutes, and on a shared machine someone eventually turns it off. The other
|
|
18
|
+
' way to run a console tool quietly is a Scheduled Task, which needs elevation
|
|
19
|
+
' to register in the root task folder - this needs none: put a shortcut to it
|
|
20
|
+
' in shell:startup and it begins with the session.
|
|
21
|
+
'
|
|
22
|
+
' Usage:
|
|
23
|
+
' wscript rclone-outbox.vbs <outbox folder> <rclone remote> [interval seconds]
|
|
24
|
+
'
|
|
25
|
+
' For example, against an endpoint declared as `SkinCheck: { folder: ... }`:
|
|
26
|
+
' wscript rclone-outbox.vbs "C:\SkinCheck" SkinCheck:Janus 120
|
|
27
|
+
|
|
28
|
+
Option Explicit
|
|
29
|
+
|
|
30
|
+
Dim shell, arguments, outbox, remote, interval, rclone, logFile, command
|
|
31
|
+
|
|
32
|
+
Set shell = CreateObject("WScript.Shell")
|
|
33
|
+
Set arguments = WScript.Arguments
|
|
34
|
+
|
|
35
|
+
If arguments.Count < 2 Then
|
|
36
|
+
WScript.Echo "usage: wscript rclone-outbox.vbs <outbox folder> <remote> [interval seconds]"
|
|
37
|
+
WScript.Quit 1
|
|
38
|
+
End If
|
|
39
|
+
|
|
40
|
+
outbox = arguments(0)
|
|
41
|
+
remote = arguments(1)
|
|
42
|
+
If arguments.Count > 2 Then
|
|
43
|
+
interval = CLng(arguments(2))
|
|
44
|
+
Else
|
|
45
|
+
interval = 120
|
|
46
|
+
End If
|
|
47
|
+
|
|
48
|
+
rclone = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%\rclone\rclone.exe")
|
|
49
|
+
logFile = shell.ExpandEnvironmentStrings("%LOCALAPPDATA%\rclone\outbox.log")
|
|
50
|
+
|
|
51
|
+
' move, not copy: the server is the system of record, the same as the mapped
|
|
52
|
+
' drive it replaces, and it keeps the exported documents off a shared desktop.
|
|
53
|
+
' It also gives whoever works at the machine a correct indicator that needs no
|
|
54
|
+
' explaining - an empty outbox means everything is up, and a failing upload
|
|
55
|
+
' makes files visibly pile up instead of failing where nobody looks.
|
|
56
|
+
'
|
|
57
|
+
' --min-age keeps a file that is still being written out of the transfer. The
|
|
58
|
+
' exporting application is long finished with it by the time it ages in, and a
|
|
59
|
+
' half-written document uploaded and then deleted locally is unrecoverable.
|
|
60
|
+
' --ignore-times because the server reports no modification-time support
|
|
61
|
+
' (rclone prints a Precision of 100 years for it), which leaves size as the
|
|
62
|
+
' only thing rclone can compare. A re-export under the same name and the same
|
|
63
|
+
' size - a corrected document for the same patient and date - would otherwise
|
|
64
|
+
' be treated as already uploaded, skipped, and deleted locally. Measured: a
|
|
65
|
+
' 3.773 MiB export was removed with "There was nothing to transfer".
|
|
66
|
+
'
|
|
67
|
+
' It costs nothing here. move empties the outbox, so a document is offered
|
|
68
|
+
' once and uploaded once; this only removes a skip decision that cannot be
|
|
69
|
+
' made safely.
|
|
70
|
+
command = """" & rclone & """ move """ & outbox & """ " & remote & _
|
|
71
|
+
" --min-age 1m --ignore-times --transfers 2" & _
|
|
72
|
+
" --log-level INFO --log-file """ & logFile & """"
|
|
73
|
+
|
|
74
|
+
Do
|
|
75
|
+
' 0 hides the window; True waits for the run to finish, so a slow upload
|
|
76
|
+
' delays the next pass instead of overlapping with it.
|
|
77
|
+
shell.Run command, 0, True
|
|
78
|
+
WScript.Sleep interval * 1000
|
|
79
|
+
Loop
|
package/index.js
CHANGED
|
@@ -68,6 +68,22 @@ export const writeCapability = (name) => `drive:${name}:write`
|
|
|
68
68
|
* handler (server.js), so an endpoint at `/content` would silently shadow a
|
|
69
69
|
* real page at /content/ in the built site.
|
|
70
70
|
*/
|
|
71
|
+
// Does this deployment's auth want charset="UTF-8" on the Basic challenge?
|
|
72
|
+
//
|
|
73
|
+
// Asked of the auth plugin rather than configured here, so the decision is
|
|
74
|
+
// made once per deployment. mikser-io-auth exposes `basicCharset` and
|
|
75
|
+
// defaults it ON; anything else — a bare static token, no auth at all — has
|
|
76
|
+
// no opinion, and falls back to the same default rather than to silence.
|
|
77
|
+
//
|
|
78
|
+
// Not delegated to the verifier's own `challenge()`, which would be the
|
|
79
|
+
// tidier shape: a composite verifier challenges with whichever member can be
|
|
80
|
+
// satisfied interactively, and where OAuth discovery is configured that is
|
|
81
|
+
// Bearer. Explorer and Finder speak Basic and nothing else, so the scheme
|
|
82
|
+
// here is not negotiable — only the parameter is.
|
|
83
|
+
function basicCharsetOf(auth) {
|
|
84
|
+
return auth?.basicCharset ?? true
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
export function drive(options = {}) {
|
|
72
88
|
const {
|
|
73
89
|
base = '/drive',
|
|
@@ -87,6 +103,10 @@ export function drive(options = {}) {
|
|
|
87
103
|
host,
|
|
88
104
|
auth,
|
|
89
105
|
realm = 'mikser',
|
|
106
|
+
// `charset="UTF-8"` on the Basic challenge, RFC 7617 §2.1, is not a
|
|
107
|
+
// knob of this package's own: it follows whatever the auth plugin was
|
|
108
|
+
// configured with, so a deployment decides it once. See
|
|
109
|
+
// basicChallenge in mikser-io for why the default is on.
|
|
90
110
|
// Nephele defaults both to 'meta-files', which writes sidecars INTO
|
|
91
111
|
// the folder being served. The sidecars are filtered out of the
|
|
92
112
|
// catalog now (see registerJunk above), so the remaining reason to
|
|
@@ -225,6 +245,7 @@ export function drive(options = {}) {
|
|
|
225
245
|
// ReadOnlyPlugin refuses every mutation regardless.
|
|
226
246
|
writeCapability: readOnly ? null : writeCapability(name),
|
|
227
247
|
realm,
|
|
248
|
+
charset: basicCharsetOf(auth),
|
|
228
249
|
logger,
|
|
229
250
|
})
|
|
230
251
|
|
|
@@ -409,6 +430,7 @@ export function drive(options = {}) {
|
|
|
409
430
|
allowRemote: true,
|
|
410
431
|
trustLoopback: !auth,
|
|
411
432
|
realm,
|
|
433
|
+
charset: basicCharsetOf(auth),
|
|
412
434
|
},
|
|
413
435
|
logger,
|
|
414
436
|
anonymousDiscovery,
|
package/lib/authenticator.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { UnauthorizedError, ForbiddenError } from 'nephele'
|
|
2
|
-
import { authorize } from 'mikser-io'
|
|
2
|
+
import { authorize, basicChallenge } from 'mikser-io'
|
|
3
3
|
// The one implementation of "does this principal hold it", including the
|
|
4
4
|
// `*` wildcard — checked here rather than re-derived, because two gates in
|
|
5
5
|
// this file and one in mikser-io-auth each had their own `.includes` and a
|
|
@@ -39,11 +39,15 @@ export class MikserAuthenticator {
|
|
|
39
39
|
#capability
|
|
40
40
|
#writeCapability
|
|
41
41
|
#realm
|
|
42
|
+
// Whether the Basic challenge carries charset="UTF-8". Follows the
|
|
43
|
+
// deployment's auth configuration rather than being decided here — see
|
|
44
|
+
// basicChallenge in mikser-io.
|
|
45
|
+
#charset
|
|
42
46
|
#logger
|
|
43
47
|
|
|
44
48
|
constructor({
|
|
45
49
|
verifier, trustLoopback = false, allowRemote = false,
|
|
46
|
-
capability, writeCapability, realm = 'mikser', logger,
|
|
50
|
+
capability, writeCapability, realm = 'mikser', charset = false, logger,
|
|
47
51
|
} = {}) {
|
|
48
52
|
this.#verifier = verifier
|
|
49
53
|
this.#trustLoopback = trustLoopback
|
|
@@ -51,6 +55,7 @@ export class MikserAuthenticator {
|
|
|
51
55
|
this.#capability = capability
|
|
52
56
|
this.#writeCapability = writeCapability
|
|
53
57
|
this.#realm = realm
|
|
58
|
+
this.#charset = charset
|
|
54
59
|
this.#logger = logger
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -72,7 +77,8 @@ export class MikserAuthenticator {
|
|
|
72
77
|
// challenge has to be set here — and it must offer Basic, because
|
|
73
78
|
// that is the only scheme Finder and Explorer speak.
|
|
74
79
|
if (outcome.status === 401) {
|
|
75
|
-
response.set('WWW-Authenticate',
|
|
80
|
+
response.set('WWW-Authenticate',
|
|
81
|
+
basicChallenge({ realm: this.#realm, charset: this.#charset }))
|
|
76
82
|
throw new UnauthorizedError(outcome.error)
|
|
77
83
|
}
|
|
78
84
|
// 403: the credential was never the problem — the caller's origin
|
package/lib/root-listing.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// server with its own authenticator — so per-endpoint auth stays exactly where
|
|
17
17
|
// it was rather than hanging off a shared root.
|
|
18
18
|
|
|
19
|
-
import { authorize, hasCapability } from 'mikser-io'
|
|
19
|
+
import { authorize, hasCapability, basicChallenge } from 'mikser-io'
|
|
20
20
|
|
|
21
21
|
const escape = (text) => String(text)
|
|
22
22
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
@@ -128,7 +128,7 @@ export function rootListing({ entries, rootName, auth, logger, anonymousDiscover
|
|
|
128
128
|
} catch (err) {
|
|
129
129
|
logger?.error?.('drive: verifier threw at the root — %s', err.message)
|
|
130
130
|
return res.status(401)
|
|
131
|
-
.set('WWW-Authenticate',
|
|
131
|
+
.set('WWW-Authenticate', basicChallenge({ realm: auth.realm, charset: auth.charset }))
|
|
132
132
|
.end()
|
|
133
133
|
}
|
|
134
134
|
|
|
@@ -137,7 +137,7 @@ export function rootListing({ entries, rootName, auth, logger, anonymousDiscover
|
|
|
137
137
|
// and without the challenge a DAV client never prompts.
|
|
138
138
|
if (outcome.status === 401) {
|
|
139
139
|
return res.status(401)
|
|
140
|
-
.set('WWW-Authenticate',
|
|
140
|
+
.set('WWW-Authenticate', basicChallenge({ realm: auth.realm, charset: auth.charset }))
|
|
141
141
|
.end()
|
|
142
142
|
}
|
|
143
143
|
// Authenticated and not permitted. NEVER 401 here: a client that
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mikser-io-drive",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.6.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",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"better-sqlite3": true
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node --no-warnings --test --test-reporter=spec 'test/**/*.test.js'"
|
|
11
|
+
"test": "node --no-warnings --test --test-timeout=120000 --test-reporter=spec 'test/**/*.test.js'"
|
|
12
12
|
},
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://github.com/almero-digital-marketing/mikser-io-drive#readme",
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"mikser-io": "^11.
|
|
31
|
+
"mikser-io": "^11.11.1",
|
|
32
32
|
"zod": "^4.0.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"bcryptjs": "^3.0.0",
|
|
42
42
|
"express": "^5.2.1",
|
|
43
43
|
"globby": "^15.0.0",
|
|
44
|
-
"mikser-io": "^11.
|
|
44
|
+
"mikser-io": "^11.11.1",
|
|
45
45
|
"mikser-io-auth": "^11.0.1",
|
|
46
46
|
"mikser-io-mcp": "^11.0.1",
|
|
47
47
|
"webdav": "^5.10.0",
|
package/test/derived.test.js
CHANGED
|
@@ -124,11 +124,17 @@ before(async (t) => {
|
|
|
124
124
|
+ ' ],\n'
|
|
125
125
|
+ '})\n')
|
|
126
126
|
|
|
127
|
+
// `detached` so this gets its own process GROUP, which is what the
|
|
128
|
+
// teardown kills. mikser under --watch --server runs render workers, and
|
|
129
|
+
// they inherit the pipes below: signalling the parent alone can leave a
|
|
130
|
+
// worker holding the write end open, which keeps THIS process's read
|
|
131
|
+
// handles alive and the test run never ends. Killing the group takes the
|
|
132
|
+
// whole tree.
|
|
127
133
|
server = spawn(process.execPath, [
|
|
128
134
|
path.join(dir, 'node_modules/mikser-io/app.js'),
|
|
129
135
|
'--working-folder', dir, '--output-folder', 'out', '--runtime-folder', 'runtime',
|
|
130
136
|
'--watch', '--server', String(PORT), '--url', `http://127.0.0.1:${PORT}`,
|
|
131
|
-
], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
137
|
+
], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
|
|
132
138
|
|
|
133
139
|
// Kept so a server that never comes up says WHY. Throwing this away left
|
|
134
140
|
// the failure as a bare ECONNREFUSED, which names the symptom and hides
|
|
@@ -155,6 +161,15 @@ before(async (t) => {
|
|
|
155
161
|
await new Promise(r => setTimeout(r, 3000))
|
|
156
162
|
})
|
|
157
163
|
|
|
164
|
+
// Signal the whole process GROUP, not just the child.
|
|
165
|
+
//
|
|
166
|
+
// `detached: true` above put the server in its own group, so a negative pid
|
|
167
|
+
// reaches it and everything it started. Falls back to the child alone if the
|
|
168
|
+
// group is already gone, which is the ordinary case and not an error.
|
|
169
|
+
function killTree(child, signal) {
|
|
170
|
+
try { process.kill(-child.pid, signal) } catch { try { child.kill(signal) } catch { /* already gone */ } }
|
|
171
|
+
}
|
|
172
|
+
|
|
158
173
|
after(async () => {
|
|
159
174
|
// WAIT for it to die, and escalate. `kill()` only sends the signal; the
|
|
160
175
|
// child's stdout/stderr are pipes we hold, so until it actually exits
|
|
@@ -167,16 +182,30 @@ after(async () => {
|
|
|
167
182
|
// close() does not come back.
|
|
168
183
|
if (server && server.exitCode === null && server.signalCode === null) {
|
|
169
184
|
const exited = new Promise(resolve => server.once('exit', resolve))
|
|
170
|
-
server
|
|
185
|
+
killTree(server, 'SIGTERM')
|
|
171
186
|
const inTime = await Promise.race([
|
|
172
187
|
exited.then(() => true),
|
|
173
188
|
new Promise(resolve => setTimeout(() => resolve(false), 5000)),
|
|
174
189
|
])
|
|
175
190
|
if (!inTime) {
|
|
176
|
-
server
|
|
177
|
-
await exited
|
|
191
|
+
killTree(server, 'SIGKILL')
|
|
192
|
+
// Bounded. `await exited` alone assumes the child is the only
|
|
193
|
+
// thing that can hold this up, and the whole failure being fixed
|
|
194
|
+
// here is that it is not.
|
|
195
|
+
await Promise.race([
|
|
196
|
+
exited,
|
|
197
|
+
new Promise(resolve => setTimeout(resolve, 5000)),
|
|
198
|
+
])
|
|
178
199
|
}
|
|
179
200
|
}
|
|
201
|
+
// The pipes, explicitly, and this is the actual fix rather than belt and
|
|
202
|
+
// braces. A worker that inherited the write end keeps this side readable
|
|
203
|
+
// even once the server is gone, and a readable stream handle is enough to
|
|
204
|
+
// keep node alive: every test passes, nothing is left to run, and the
|
|
205
|
+
// process simply sits there. On CI that was fifteen minutes of silence
|
|
206
|
+
// ended by the job timeout and "Terminate orphan process", twice.
|
|
207
|
+
server?.stdout?.destroy()
|
|
208
|
+
server?.stderr?.destroy()
|
|
180
209
|
if (dir) await rm(dir, { recursive: true, force: true })
|
|
181
210
|
})
|
|
182
211
|
|
package/test/drive.test.js
CHANGED
|
@@ -101,8 +101,11 @@ describe('auth', () => {
|
|
|
101
101
|
it('challenges an anonymous request with Basic, so a DAV client prompts', async () => {
|
|
102
102
|
const res = await dav('PROPFIND', '/drive/content/', { headers: { depth: '0' } })
|
|
103
103
|
assert.equal(res.status, 401)
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
// Exact bytes: the drive follows the deployment's auth configuration
|
|
105
|
+
// rather than hardcoding the parameter, and this fixture leaves it at
|
|
106
|
+
// the default. See basicChallenge in mikser-io.
|
|
107
|
+
assert.equal(res.headers.get('www-authenticate'),
|
|
108
|
+
'Basic realm="mikser", charset="UTF-8"')
|
|
106
109
|
})
|
|
107
110
|
|
|
108
111
|
it('refuses a wrong password', async () => {
|
package/test/root-denial.test.js
CHANGED
|
@@ -48,6 +48,27 @@ const insufficient = {
|
|
|
48
48
|
// Presented a credential, rejected, no refinement — the ordinary 401.
|
|
49
49
|
const invalid = { verify: async () => false }
|
|
50
50
|
|
|
51
|
+
// A verifier that throws — a store that cannot be read, a hash library that
|
|
52
|
+
// blew up. The root answers 401 rather than 500, because a client that can
|
|
53
|
+
// retry with credentials is more use than a stack trace, and this branch has
|
|
54
|
+
// its own `res.set` call that a mutation can change independently of the
|
|
55
|
+
// ordinary one.
|
|
56
|
+
const throwing = { verify: async () => { throw new Error('store unreadable') } }
|
|
57
|
+
|
|
58
|
+
describe('when the verifier itself fails', () => {
|
|
59
|
+
for (const method of ['OPTIONS', 'PROPFIND']) {
|
|
60
|
+
it(`challenges with the configured bytes on ${method}`, async () => {
|
|
61
|
+
const res = fakeResponse()
|
|
62
|
+
await middleware(throwing)(request(method), res, () => {
|
|
63
|
+
assert.fail('the root must answer, not fall through')
|
|
64
|
+
})
|
|
65
|
+
assert.equal(res.statusCode, 401)
|
|
66
|
+
assert.equal(res.headers['www-authenticate'], 'Basic realm="mikser", charset="UTF-8"',
|
|
67
|
+
'the error path must emit what the happy path emits, not its own header')
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
51
72
|
describe('an authenticated caller who is not permitted', () => {
|
|
52
73
|
for (const method of ['OPTIONS', 'PROPFIND']) {
|
|
53
74
|
it(`gets 403 and no challenge on ${method}`, async () => {
|
|
@@ -209,6 +209,21 @@ describe('the drive root on its own host', () => {
|
|
|
209
209
|
assert.equal(res.status, 401)
|
|
210
210
|
})
|
|
211
211
|
|
|
212
|
+
it('challenges with the exact bytes this deployment configured', async () => {
|
|
213
|
+
// Exact, because the parameter that is NOT here is the finding:
|
|
214
|
+
// `charset="UTF-8"` is advisory (RFC 7617 §2.1) and the Windows
|
|
215
|
+
// WebDAV redirector appears not to parse it — a mapped drive carrying
|
|
216
|
+
// it never reconnects from stored credentials. The root follows the
|
|
217
|
+
// auth plugin's choice rather than carrying a knob of its own, and
|
|
218
|
+
// this fixture leaves it at the default.
|
|
219
|
+
for (const method of ['OPTIONS', 'PROPFIND']) {
|
|
220
|
+
const res = await request(method, '/', { headers: { depth: '1' } })
|
|
221
|
+
assert.equal(res.status, 401, `${method} must challenge`)
|
|
222
|
+
assert.equal(res.headers['www-authenticate'], 'Basic realm="mikser", charset="UTF-8"',
|
|
223
|
+
`${method} must carry what this deployment's auth configured`)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
212
227
|
it('accepts a bearer-carrying client at the root', async () => {
|
|
213
228
|
// rclone and curl carry a token rather than Basic. Challenging
|
|
214
229
|
// OPTIONS must not turn them away — the verifier already accepts
|