dx: tests could fail after a run was interrupted during an upload
Massimo Melina committed
Jul 23, 2026 at 11:19 UTC
0a0655ffae492c84c9d83daa0e1d528f3d0278c4
4 files changed
+35
-31
.gitignore
+2
-1
@@ -8,6 +8,7 @@ scripts/sec
8
#produced by running
9
tests/work
10
tests/work2
11
+tests/tmp
12
tests/config.yaml.bak
13
14
.ci/
@@ -15,4 +16,4 @@ tests/config.yaml.bak
16
/playwright-report/
17
/blob-report/
18
/playwright/.cache/
18
-e2e/frontend.spec.ts-snapshots*
\ No newline at end of file
19
+e2e/frontend.spec.ts-snapshots*
package.json
+5
-4
@@ -18,12 +18,13 @@
18
"build-server": "rm -rf dist/src dist/plugins && npm i && tsc && touch package.json && cp -v -r package.json central.json README* LICENSE* hfs.ico plugins dist && find dist -name .DS_Store -o -name storage -exec rm -rf {} + && node scripts/afterbuild.js",
19
"build-frontend": "npm run build --workspace=frontend",
20
"build-admin": "npm run build --workspace=admin",
21
- "server-for-test": "mkdir -p tests/work && cp tests/config.yaml tests/work/config.yaml && node dist/src --cwd tests/work --config tests/work/config.yaml --debug",
22
- "server-for-test-dev": "cross-env DEV=1 FRONTEND_PROXY=3005 ADMIN_PROXY=3006 nodemon --ignore tests/ --watch src -e ts,tsx --exec tsx src -- --cwd tests/work --config tests",
21
+ "prepare-test": "mkdir -p tests/work tests/tmp && cp tests/config.yaml tests/work/config.yaml && cp tests/alfa.txt tests/tmp/alfa.txt",
22
+ "server-for-test": "npm run prepare-test && node dist/src --cwd tests/work --config tests/work/config.yaml --debug",
23
+ "server-for-test-dev": "npm run prepare-test && cross-env DEV=1 FRONTEND_PROXY=3005 ADMIN_PROXY=3006 nodemon --ignore tests/ --watch src -e ts,tsx --exec tsx src -- --cwd tests/work --config tests",
24
"test": "sh -c 'npm run port-is-free >/dev/null && { echo \"no server\"; exit 1; }; node --import tsx --test \"$@\" tests/test.ts' --",
24
- "test-with-server": "sh -c 'npm run port-is-free && tsc && rm -rf tests/work tests/tmp && mkdir -p tests/work && cp tests/config.yaml tests/work/config.yaml && (node dist/src --cwd tests/work --config tests/work/config.yaml & echo $! > .server_pid) && sleep 2 && node --import tsx --test \"$@\" tests/test.ts; _exit=$?; if [ -f ./.server_pid ]; then SERVER_PID=$(cat ./.server_pid); kill \"$SERVER_PID\" 2>/dev/null || true; rm -f ./.server_pid; fi; exit $_exit' --",
25
+ "test-with-server": "sh -c 'npm run port-is-free && tsc && rm -rf tests/work tests/tmp && npm run prepare-test && (node dist/src --cwd tests/work --config tests/work/config.yaml & echo $! > .server_pid) && sleep 2 && node --import tsx --test \"$@\" tests/test.ts; _exit=$?; if [ -f ./.server_pid ]; then SERVER_PID=$(cat ./.server_pid); kill \"$SERVER_PID\" 2>/dev/null || true; rm -f ./.server_pid; fi; exit $_exit' --",
26
"port-is-free": "node -e \"const port=process.argv[1]||8081;process.exit(await fetch('http://localhost:'+port).then(() => console.log('BUSY')||1, () => 0))\" --",
26
- "test-ui": "npm run port-is-free -- 8081 && rm -rf tests/work tests/work2 && npx playwright test frontend && npx playwright test serial && npx playwright test admin-vfs",
27
+ "test-ui": "npm run port-is-free -- 8081 && rm -rf tests/work tests/work2 tests/tmp && npx playwright test frontend && npx playwright test serial && npx playwright test admin-vfs",
28
"test-with-ui": "sh -c 'npm run port-is-free -- 3005 && npm run start-frontend & npm run port-is-free -- 3006 && npm run start-admin & cross-env TEST_WITH_UI=1 npx playwright test --ui \"$@\"' --",
29
"pub": "cd dist && npm publish",
30
"dist": "STASHED=; STASH_BEFORE=$(git rev-parse -q --verify refs/stash || true); git update-index -q --refresh; if ! git diff-index --quiet HEAD --; then git stash push -m 'dist' || exit $?; STASH_AFTER=$(git rev-parse -q --verify refs/stash || true); [ \"$STASH_AFTER\" != \"$STASH_BEFORE\" ] && STASHED=1; fi; npm audit --omit=dev --audit-level=moderate && CI=1 FORCE_COLOR=1 npm run dist-uncommitted; EXIT_CODE=$?; if [ -n \"$STASHED\" ]; then git stash pop || exit $?; fi; exit $EXIT_CODE",
tests/config.yaml
+1
-1
@@ -50,7 +50,7 @@ vfs:
50
can_see: can_read
51
children:
52
- name: upload
53
- source: ..
53
+ source: ../tmp
54
can_upload:
55
- admins
56
can_delete:
tests/test.ts
+27
-25
@@ -31,6 +31,8 @@ const TEST_PORT = Number(yaml.parse(readFileSync(resolve(__dirname, 'config.yaml
31
const BASE_URL = `http://[::1]:${TEST_PORT}`
32
const BASE_URL_127 = `http://127.0.0.1:${TEST_PORT}`
33
const UPLOAD_ROOT = '/for-admins/upload/'
34
+// keep generated uploads under the directory reset by test runners
35
+const UPLOAD_DISK_ROOT = resolve(__dirname, 'tmp')
36
const VIRTUAL_UPLOAD_ROOT = '/renameChild/'
37
const FUNNY_NAME = 'x%25#x'
38
const FUNNY_NAME_ENCODED = '/x%2525%23x'
@@ -305,7 +307,7 @@ describe('basics', () => {
307
const output = await execP(`curl -u ${auth} -F upload=@${SAMPLE_FILE_PATH} ${BASE_URL}${UPLOAD_ROOT}`)
308
const uri = tryJson(output)?.uris?.[0]
309
if (!uri) throw "unexpected output " + output
308
- const fn = resolve(__dirname, basename(decodeURI(uri)))
310
+ const fn = uploadUriToPath(uri)
311
const stats = statSync(fn)
312
rm(fn).catch(() => {}) // clear
313
if (stats?.size !== statSync(SAMPLE_FILE_PATH).size)
@@ -370,7 +372,7 @@ describe('basics', () => {
372
373
describe('webdav', () => {
374
const jar = {}
373
- after(() => rmAny(resolve(__dirname, UPLOAD_DIR)))
375
+ after(() => rmAny(resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR)))
376
test('webdav force login.scope propfind', req('/f1/', 401, { method: 'PROPFIND', headers: { depth: '0' }, jar }))
377
test('webdav force login.scope options', req('/f1/', (_data, res) =>
378
res.statusCode === 401 && res.headers?.['www-authenticate'] === BASIC_AUTHENTICATE_HEADER, {
@@ -689,7 +691,7 @@ describe('webdav', () => {
691
const traversal = `../../${escapedName}` // climbs above the upload node's source
692
// encode as a single path segment so dirname(dest) still matches dirname(path) and we hit the rename branch
693
const destination = `${BASE_URL}${UPLOAD_ROOT}${UPLOAD_DIR}/${encodeURIComponent(traversal)}`
692
- const escapedDiskPath = resolve(ROOT, UPLOAD_DIR, traversal)
694
+ const escapedDiskPath = resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR, traversal)
695
let destPath = ''
696
try {
697
destPath = await webdavUpload(uri, x => x?.uri === uri, 'test')()
@@ -955,7 +957,7 @@ describe('after-login', () => {
957
test('create_folder.empty name', reqApi('create_folder', { uri: UPLOAD_ROOT, name: '' }, 400))
958
test('create_folder.tricky chars', async () => {
959
await reqApi('create_folder', { uri: UPLOAD_ROOT, name: trickyChars }, 200)()
958
- const dest = resolve(__dirname, trickyChars)
960
+ const dest = resolve(UPLOAD_DISK_ROOT, trickyChars)
961
await access(dest)
962
await rm(dest, { recursive: true })
963
})
@@ -1017,7 +1019,7 @@ describe('after-login', () => {
1019
}
1020
finally {
1021
await req(percentUri, 200, { method: 'delete' })().catch(() => {})
1020
- await rmAny(resolve(__dirname, percentName))
1022
+ await rmAny(resolve(UPLOAD_DISK_ROOT, percentName))
1023
}
1024
})
1025
@@ -1034,7 +1036,7 @@ describe('after-login', () => {
1036
}
1037
finally {
1038
await req(renamedUri, 200, { method: 'delete' })().catch(() => {})
1037
- await rmAny(resolve(__dirname, renameName))
1039
+ await rmAny(resolve(UPLOAD_DISK_ROOT, renameName))
1040
}
1041
})
1042
@@ -1055,16 +1057,16 @@ describe('after-login', () => {
1057
finally {
1058
await req(percentUri, 200, { method: 'delete' })().catch(() => {})
1059
await req(movedUri, 200, { method: 'delete' })().catch(() => {})
1058
- await rmAny(resolve(__dirname, percentName))
1059
- await rmAny(resolve(__dirname, folderName, percentName))
1060
- await rmAny(resolve(__dirname, folderName))
1060
+ await rmAny(resolve(UPLOAD_DISK_ROOT, percentName))
1061
+ await rmAny(resolve(UPLOAD_DISK_ROOT, folderName, percentName))
1062
+ await rmAny(resolve(UPLOAD_DISK_ROOT, folderName))
1063
}
1064
})
1065
test('zip.no-list but archive', req('/zipNoList/?get=zip', 403, { jar: {} }))
1066
test('upload but not delete', async () => {
1067
const name = `cant-delete`
1066
- await mkdir(resolve(__dirname, name), { recursive: true })
1067
- await reqApi('add_vfs', { parent: UPLOAD_ROOT, source: `../${name}`, name, can_upload: ['admins'], can_delete: false }, 200)()
1068
+ await mkdir(resolve(UPLOAD_DISK_ROOT, name), { recursive: true })
1069
+ await reqApi('add_vfs', { parent: UPLOAD_ROOT, source: `../tmp/${name}`, name, can_upload: ['admins'], can_delete: false }, 200)()
1070
try {
1071
const dest = `${UPLOAD_ROOT}${name}/no-delete.txt`
1072
await reqUpload(dest, 200)()
@@ -1072,7 +1074,7 @@ describe('after-login', () => {
1074
}
1075
finally {
1076
await reqApi('del_vfs', { uris: [UPLOAD_ROOT + name] }, 200)().catch(() => {})
1075
- await rmAny(resolve(__dirname, name))
1077
+ await rmAny(resolve(UPLOAD_DISK_ROOT, name))
1078
}
1079
})
1080
test('move.overwrite needs delete', async () => {
@@ -1090,13 +1092,13 @@ describe('after-login', () => {
1092
throw "file overwritten"
1093
}
1094
finally {
1093
- await rmAny(resolve(__dirname, UPLOAD_DIR, destFile))
1095
+ await rmAny(resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR, destFile))
1096
await rmAny(destDir)
1097
}
1098
})
1099
test('upload.path bypass', async () => {
1100
const name = 'no-upload'
1099
- const targetDir = resolve(__dirname, 'tmp', name)
1101
+ const targetDir = resolve(UPLOAD_DISK_ROOT, name)
1102
try {
1103
await execP(`curl -g -s -u ${auth} -F "upload=@${SAMPLE_FILE_PATH};filename=${name}/evil.txt" ${BASE_URL}${UPLOAD_ROOT}`)
1104
if (existsSync(resolve(targetDir, 'evil.txt')))
@@ -1107,7 +1109,7 @@ describe('after-login', () => {
1109
}
1110
})
1111
test('upload.existing.skip', async () => {
1110
- const filePath = resolve(__dirname, UPLOAD_RELATIVE)
1112
+ const filePath = resolve(UPLOAD_DISK_ROOT, UPLOAD_RELATIVE)
1113
const before = statSync(filePath).size
1114
await reqUpload(UPLOAD_DEST + '?existing=skip', 409)()
1115
const after = statSync(filePath).size
@@ -1127,7 +1129,7 @@ describe('after-login', () => {
1129
..._.range(3).map(i => reqUpload(UPLOAD_DEST + i, 200, new StringRepeaterStream(BIG_CONTENT, 50))()) // 3 x 100MB
1130
]).then(() => {}))
1131
test('upload.interrupted', async () => {
1130
- const fn = resolve(__dirname, UPLOAD_RELATIVE.replace('/', '/hfs$upload-'))
1132
+ const fn = resolve(UPLOAD_DISK_ROOT, UPLOAD_RELATIVE.replace('/', '/hfs$upload-'))
1133
await rm(fn, {force: true})
1134
const neededTime = 600
1135
const makeAbortedRequest = (afterMs: number) => {
@@ -1158,14 +1160,14 @@ describe('after-login', () => {
1160
})
1161
test('rename.backslash', async () => {
1162
await reqApi('rename', { uri: UPLOAD_DEST, dest: 'sub\\file' }, process.platform === 'win32' ? 403 : 200)()
1161
- const d = resolve(__dirname, UPLOAD_DIR)
1163
+ const d = resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR)
1164
await rename(resolve(d, 'sub\\file'), resolve(d, basename(UPLOAD_DEST))).catch(() => {})
1165
})
1166
const renameTo = 'z'
1167
test('rename.ok', reqApi('rename', { uri: UPLOAD_DEST, dest: renameTo }, 200))
1168
test('delete.miss renamed', req(UPLOAD_DEST, 404, { method: 'delete' }))
1169
test('delete.ok', async () => {
1168
- const fn = resolve(__dirname, dirname(UPLOAD_RELATIVE), renameTo)
1170
+ const fn = resolve(UPLOAD_DISK_ROOT, dirname(UPLOAD_RELATIVE), renameTo)
1171
if (!existsSync(fn))
1172
throw "missing file"
1173
await req(dirname(UPLOAD_DEST) + '/' + renameTo, 200, { method: 'delete' })()
@@ -1177,8 +1179,8 @@ describe('after-login', () => {
1179
test('delete.miss deleted', req(UPLOAD_DEST, 404, { method: 'delete' }))
1180
test('rename.tricky chars', async () => {
1181
const dest = trickyChars
1180
- await mkdir(resolve(__dirname, UPLOAD_DIR), { recursive: true })
1181
- const fn = resolve(__dirname, UPLOAD_RELATIVE)
1182
+ await mkdir(resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR), { recursive: true })
1183
+ const fn = resolve(UPLOAD_DISK_ROOT, UPLOAD_RELATIVE)
1184
await writeFile(fn, 'z')
1185
try {
1186
await reqApi('rename', { uri: UPLOAD_DEST, dest }, 200)() // dest is not encoded
@@ -1191,7 +1193,7 @@ describe('after-login', () => {
1193
if (res.statusCode === 400) return // status 400 is caused by nodejs itself, intercepting the mismatch, but it's probably an unreliable race condition
1194
if (res.statusCode !== 200) // it happened sometimes that node didn't block (can't replicate). In such case we should get a 200 with a file the size of declaredSize.
1195
throw `expected 200, got ${res.statusCode}`
1194
- const size = try_(() => statSync(resolve(__dirname, UPLOAD_RELATIVE)).size)
1196
+ const size = try_(() => statSync(resolve(UPLOAD_DISK_ROOT, UPLOAD_RELATIVE)).size)
1197
if (size !== declaredSize)
1198
throw `expected ${declaredSize}, got ${size}`
1199
}, BIG_CONTENT, declaredSize))
@@ -1216,7 +1218,7 @@ describe('after-login', () => {
1218
await reqApi('logout', {}, 401)()
1219
await reqApi('get_accounts', {}, 401)() // no more
1220
})
1219
- after(() => rmAny(resolve(__dirname, UPLOAD_DIR)))
1221
+ after(() => rmAny(resolve(UPLOAD_DISK_ROOT, UPLOAD_DIR)))
1222
})
1223
1224
describe('admin', () => {
@@ -1284,7 +1286,7 @@ describe('admin', () => {
1286
if (res.status !== 200)
1287
throw `unexpected status ${res.status}`
1288
}
1287
- finally { await rmAny(resolve(__dirname, rawName)) }
1289
+ finally { await rmAny(resolve(UPLOAD_DISK_ROOT, rawName)) }
1290
})
1291
test('monitor.connections upload path decodes colon folder', async () => {
1292
const body = makeReadableThatTakes(700)
@@ -1303,7 +1305,7 @@ describe('admin', () => {
1305
if (res.data.includes(encodedPath))
1306
throw Error('upload path still encoded: ' + res.data)
1307
}
1306
- finally { await rmAny(resolve(__dirname, folderName)) }
1308
+ finally { await rmAny(resolve(UPLOAD_DISK_ROOT, folderName)) }
1309
})
1310
test('plugins.start_stop', async () => {
1311
const id = 'download-counter'
@@ -1529,7 +1531,7 @@ function reqUpload(dest: string, tester: Tester, body?: string | Readable, size?
1531
}
1532
1533
function uploadUriToPath(uri: string) {
1532
- return ROOT + decodeURI(uri).replace(UPLOAD_ROOT, '')
1534
+ return resolve(UPLOAD_DISK_ROOT, decodeURI(uri).replace(UPLOAD_ROOT, ''))
1535
}
1536
1537
async function testMaxDl(uri: string, good: number, bad: number, reqOptions: ReqOptions={}) {