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={}) {