test: added a few backend tests
Massimo Melina committed
Jan 15, 2026 at 11:36 UTC
07c72853695c62c149bf78d96739a54785a9e090
3 files changed
+92
-13
AGENTS.md
+3
-2
@@ -8,14 +8,15 @@ Core server logic sits in `src/` (TypeScript, Koa middleware, plugins). Shared R
8
- `npm run watch-server-full` — boot both React apps plus the API server for integrated development.
9
- `npm run build-server` / `npm run build-frontend` / `npm run build-admin` — compile individual targets into `dist/`.
10
- `npm run build-all` — audit dependencies, rebuild everything, run API tests, and kick off frontend/admin builds in parallel.
11
-- `npm test` — execute the Node test suite (`tests/test.ts`).
12
-- `npm run test-with-ui` — launch Playwright’s UI runner; use `npm run test-ui` for headless CI.
11
+- `npm run test-with-server` — execute the Node test suite (`tests/test.ts`) with related server. May require escalated permissions in sandboxed environments to bind to port 81.
12
+- `npm run test-ui` — launch Playwright’s headless CI.
13
14
## Coding Style & Naming Conventions
15
Use TypeScript/ES2022 with 4-space indentation and single quotes except when JSON compatibility is needed. Prefer async/await over promise chains and keep streaming utilities (e.g., `AsapJStream`) in dedicated modules. Name files by responsibility (`*.ts` for services, `*.spec.ts` for tests) and React components in PascalCase. Run `tsc` implicitly via the build scripts; no separate lint step exists, so keep code self-explanatory and add succinct comments only for non-obvious flows.
16
17
## Testing Guidelines
18
Node-side tests rely on the built-in `node --test` runner via `npm test`. Place fixtures under `tests/work` where scripts already expect them. UI coverage uses Playwright (`tests-ui`/`frontend` suites); target critical upload/download flows and plugin management. Name new tests after the behavior they assert (e.g., `plugin-disable.test.ts`). Before pushing, run at least `npm test` and, when touching UI, the relevant Playwright suite.
19
+For long-running commands like tests/builds, use a 30s timeout unless a different value is requested.
20
21
## Commit & Pull Request Guidelines
22
Recent history favors short, component-scoped subjects (`admin/plugins: faster list on get-more`). Follow that pattern: `<area>: <concise change>`. Keep commits focused and self-contained. Pull requests should describe motivation, summarize key changes, call out affected packages (`src`, `frontend`, etc.), and mention how to reproduce or verify. Include screenshots for UI changes and note which commands/tests were run. Reference GitHub issues when applicable and flag any follow-up work.
package.json
+1
-1
@@ -20,7 +20,7 @@
20
"server-for-test": "node dist/src --cwd tests/work --config tests --debug",
21
"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",
22
"test": "node --import tsx --test tests/test.ts",
23
- "test-with-server": "rm -rf tests/work && (node dist/src --cwd tests/work --config tests & echo $! > .server_pid) && sleep 2 && node --import tsx --test tests/test.ts; _exit=$?; SERVER_PID=$(cat ./.server_pid); kill \"$SERVER_PID\" 2>/dev/null || true; rm -f ./.server_pid; exit $_exit",
23
+ "test-with-server": "tsc && rm -rf tests/work && (node dist/src --cwd tests/work --config tests & echo $! > .server_pid) && sleep 2 && node --import tsx --test tests/test.ts; _exit=$?; SERVER_PID=$(cat ./.server_pid); kill \"$SERVER_PID\" 2>/dev/null || true; rm -f ./.server_pid; exit $_exit",
24
"test-ui": "npx playwright test frontend && npx playwright test serial",
25
"test-with-ui": "npx playwright test --ui",
26
"pub": "cd dist && npm publish",
tests/test.ts
+88
-10
@@ -8,7 +8,7 @@ import _ from 'lodash'
8
import { findDefined, randomId, try_, tryJson, wait } from '../src/cross'
9
import { httpStream, stream2string, XRequestOptions } from '../src/util-http'
10
import { ThrottledStream, ThrottleGroup } from '../src/ThrottledStream'
11
-import { access, rm, writeFile } from 'fs/promises'
11
+import { access, mkdir, rm, writeFile } from 'fs/promises'
12
import { Readable } from 'stream'
13
/*
14
import { PORT, srv } from '../src'
@@ -54,6 +54,20 @@ describe('basics', () => {
54
test('search', reqList('f1', { inList:['f2/'], outList:['page'] }, { search:'2' }))
55
test('search root', reqList('/', { inList:['cantListPage/'], outList:['cantListPage/page/'] }, { search:'page' }))
56
test('download.mime', req('/f1/f2/alfa.txt', { re:/abcd/, mime:'text/plain' }))
57
+ test('download.not modified', async () => {
58
+ let lm = ''
59
+ await req('/f1/f2/alfa.txt', (_data, res) => lm = res.headers?.['last-modified'])()
60
+ if (!lm)
61
+ throw "last-modified"
62
+ await req('/f1/f2/alfa.txt', { status: 304, empty: true }, { headers: { 'If-Modified-Since': lm } })()
63
+ })
64
+ test('download.if-range', async () => {
65
+ let etag = ''
66
+ await req('/f1/f2/alfa.txt', (_data, res) => etag = res.headers?.etag)()
67
+ if (!etag)
68
+ throw "missing etag"
69
+ await req('/f1/f2/alfa.txt', /a[^d]+$/, { headers: { Range: 'bytes=0-2', 'If-Range': etag } })() // only "abc" is expected
70
+ })
71
test('download.partial', req('/f1/f2/alfa.txt', /a[^d]+$/, { // only "abc" is expected
72
headers: { Range: 'bytes=0-2' }
73
}))
@@ -63,12 +77,25 @@ describe('basics', () => {
77
test('roots', req('/f2/alfa.txt', 200, { baseUrl: BASE_URL_127 })) // host 127.0.0.1 is rooted in /f1
78
test('website', req('/f1/page/', { re:/This is a test/, mime:'text/html' }))
79
test('traversal', req('/f1/page/.%2e/.%2e/README.md', 418))
80
+ test('traversal.double-encoded', req('/f1/page/%252e%252e/%252e%252e/README.md', 404))
81
+ test('traversal.encoded-slash', req('/f1/page/%2e%2e%2f%2e%2e%2fREADME.md', 404))
82
+ test('traversal.backslash', req('/f1/page/..%5c..%5cREADME.md', 418))
83
+ test('traversal.to-admin', req('/f1/page/%2e%2e/%2e%2e/for-admins/alfa.txt', 418))
84
+ test('traversal.mixed-dots', req('/f1/page/.%2e/%2e./README.md', 418))
85
+ test('traversal.overlong-utf8', req('/f1/page/%c0%ae%c0%ae/%c0%ae%c0%ae/README.md', 418))
86
test('custom mime from above', req('/tests/page/index.html', { status: 200, mime:'text/plain' }))
87
test('name encoding', req('/x%25%23x', 200))
88
89
test('missing perm', reqList('/for-admins/', 401))
90
test('missing perm.file', req('/for-admins/alfa.txt', 401))
71
-
91
+ test('missing anti-csrf', reqApi('rename', { uri: '/f1', dest: 'x' }, 418, { headers: {} })) // overriding anti-csrf
92
+ test('malformed body', reqApi('rename', { uri: '/f1', dest: 'x' }, { status: 400 }, {
93
+ headers: { 'x-hfs-anti-csrf': '1', 'content-type': 'application/json' },
94
+ body: '{'
95
+ }))
96
+ test('file_details.missing', reqApi('get_file_details', { uris: ['/missing'] }, res => res?.details?.[0] === false))
97
+ test('file_list.traversal', reqApi('get_file_list', { uri: '/f1/%2e%2e/for-admins' }, 404))
98
+ test('file_details.traversal', reqApi('get_file_details', { uris: ['/f1/%2e%2e/for-admins/alfa.txt'] }, res => res?.details?.[0] === false))
99
test('forbidden list', req('/cantListPage/page/', 403))
100
test('forbidden list.api', reqList('/cantListPage/page/', 403))
101
test('forbidden list.cant see', reqList('/cantListPage/', { outList:['page/'] }))
@@ -143,14 +170,15 @@ describe('basics', () => {
170
throw "unexpected size for " + fn
171
}))
172
test('create_folder', reqApi('create_folder', { uri: UPLOAD_ROOT, name: 'temp' }, 401))
173
+ test('create_folder.bad type', reqApi('create_folder', { uri: UPLOAD_ROOT, name: 123 }, { status: 400, re: /name/ }))
174
test('delete.no perm', req('/for-admins/', 405, { method: 'delete' }))
175
test('delete.need account', req(UPLOAD_ROOT + 'alfa.txt', 401, { method: 'delete'}))
176
test('rename.no perm', reqApi('rename', { uri: '/for-admins', dest: 'any' }, 401))
149
- test('of_disabled.cantLogin', () => login('of_disabled').then(() => { throw Error('logged in') }, () => {}))
177
+ test('of_disabled.cantLogin', () => login('of_disabled').then(() => { throw "in" }, () => {}))
178
test('allow_net.canLogin', () => login(username))
179
test('allow_net.cantLogin', () => {
180
defaultBaseUrl = BASE_URL_127 // 127.0.0.1 is not allowed for this account
153
- return login(username).then(() => { throw Error('logged in') }, () => {})
181
+ return login(username).then(() => { throw "in" }, () => {})
182
.finally(() => defaultBaseUrl = BASE_URL)
183
})
184
@@ -188,6 +216,40 @@ describe('after-login', () => {
216
test('inherit.disabled', reqList('/for-disabled/', 401))
217
test('upload.never', reqUpload('/random', 403))
218
test('upload.ok', reqUpload(UPLOAD_DEST, 200))
219
+ test('file_details.admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, res => {
220
+ const u = res?.details?.[0]?.upload
221
+ const err = !u?.ip ? 'ip' : u?.username !== username ? 'username' : ''
222
+ if (err)
223
+ throw err
224
+ }))
225
+ test('file_details.non-admin', reqApi('get_file_details', { uris: [UPLOAD_DEST] }, res => {
226
+ const u = res?.details?.[0]?.upload
227
+ const err = !u ? 'missing upload' : u?.ip ? 'ip' : u?.username !== username ? 'username' : ''
228
+ if (err)
229
+ throw err
230
+ }, { jar: {} }))
231
+ test('upload but not delete', async () => {
232
+ const name = `cant-delete`
233
+ await mkdir(resolve(ROOT, name), { recursive: true })
234
+ await reqApi('add_vfs', { parent: UPLOAD_ROOT, source: `../${name}`, name, can_upload: ['admins'], can_delete: false }, 200)()
235
+ try {
236
+ const dest = `${UPLOAD_ROOT}${name}/no-delete.txt`
237
+ await reqUpload(dest, 200)()
238
+ await req(dest, 403, { method: 'delete' })()
239
+ }
240
+ finally {
241
+ await reqApi('del_vfs', { uris: [UPLOAD_ROOT + name] }, 200)().catch(() => {})
242
+ await rm(resolve(ROOT, name), { recursive: true, force: true }).catch(() => {})
243
+ }
244
+ })
245
+ test('upload.existing.skip', async () => {
246
+ const filePath = resolve(__dirname, UPLOAD_RELATIVE)
247
+ const before = statSync(filePath).size
248
+ await reqUpload(UPLOAD_DEST + '?existing=skip', 409)()
249
+ const after = statSync(filePath).size
250
+ if (after !== before)
251
+ throw "size changed"
252
+ })
253
test('upload.crossing', reqUpload(UPLOAD_DEST.replace('temp', '../..'), 418))
254
test('upload.overlap', async () => {
255
const ms = 300
@@ -215,19 +277,19 @@ describe('after-login', () => {
277
const getTempSize = () => try_(() => statSync(fn)?.size)
278
const size = getTempSize()
279
if (!size) // temp file is left, not empty
218
- throw Error("missing temp file")
280
+ throw "missing temp file"
281
await reqUpload(UPLOAD_DEST + '?resume=0!', 412)()
282
await makeAbortedRequest(timeFirstRequest * 1.5) // upload more than r1
283
if (!(size < getTempSize()!)) // should be increased, as secondary temp file got bigger and replaced primary one
222
- throw Error(`temp file not enlarged, it was ${size} and now it's ${getTempSize()}`)
284
+ throw `temp file not enlarged, it was ${size} and now it's ${getTempSize()}`
285
await reqUpload(UPLOAD_DEST, 200, makeReadableThatTakes(0))() // quickly complete the upload, and check for final size
286
if (getTempSize())
225
- throw Error("temp file should be cleared")
287
+ throw "temp file should be cleared"
288
// test resume
289
await makeAbortedRequest(timeFirstRequest)
290
const partial = getTempSize()
291
if (!partial)
230
- throw Error("partial file missing")
292
+ throw "partial file missing"
293
await reqUpload(UPLOAD_DEST, 200, Readable.from(BIG_CONTENT.slice(partial)), BIG_CONTENT.length, partial)()
294
})
295
const renameTo = 'z'
@@ -267,6 +329,11 @@ describe('after-login', () => {
329
await reqUpload(uri, 200, BIG_CONTENT)()
330
await testMaxDl(uri, 2, 1)
331
})
332
+ test('logout', async () => {
333
+ await reqApi('get_accounts', {}, 200)() // we're admin
334
+ await reqApi('logout', {}, 401)()
335
+ await reqApi('get_accounts', {}, 401)() // no more
336
+ })
337
after(() => rm(resolve(__dirname, 'temp'), { recursive: true }).catch(() => 0))
338
})
339
@@ -282,6 +349,17 @@ describe('admin', () => {
349
await reqApi('del_vfs', { uris: ['/'+name] }, 200, { auth })() // remove
350
}
351
})
352
+ test('plugins.missing', reqApi('set_plugin', { id: 'missing-plugin', enabled: true }, { status: 400, re: /miss/ }, { auth }))
353
+ test('plugins.update.missing', reqApi('update_plugin', { id: 'missing-plugin' }, 404, { auth }))
354
+ test('plugins.start_stop', async () => {
355
+ const id = 'download-counter'
356
+ await reqApi('stop_plugin', { id }, 200, { auth })()
357
+ await reqApi('start_plugin', { id }, 200, { auth })()
358
+ await reqApi('stop_plugin', { id }, res => {
359
+ if (res?.msg === 'already stopped')
360
+ throw "plugin didn't start"
361
+ }, { auth })()
362
+ })
363
})
364
365
function login(usr: string, pwd=password) {
@@ -302,7 +380,7 @@ function reqUpload(dest: string, tester: Tester, body?: string | Readable, size?
380
if (!stats)
381
throw Error("uploaded file not found: " + fn)
382
if (size !== stats.size)
305
- throw Error(`uploaded file wrong size: ${fn} = ${stats.size.toLocaleString()} expected ${size?.toLocaleString()}`)
383
+ throw `uploaded file wrong size: ${fn} = ${stats.size.toLocaleString()} expected ${size?.toLocaleString()}`
384
return true
385
}
386
}
@@ -420,4 +498,4 @@ function reqList(uri:string, tester:Tester, params?: object) {
498
499
function isInList(res:any, name:string) {
500
return Array.isArray(res?.list) && Boolean((res.list as any[]).find(x => x.n===name))
423
-}
\ No newline at end of file
501
+}