@samitouri / QOSami-HFS / commits / 07c72853

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 +}