import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { checkSignedUrl, type SchulcloudClient } from '../src/core/client.ts'; import { childRef, FileManager, FileManagerMarkupError, FsError, pageFor, parseFileListing, splitPath, } from '../src/core/legacy-files.ts'; /** * Fixtures follow the legacy client's templates as served (files/files.hbs and * files/files-grid.hbs, client 33.40): a folder is an `openfolder` button whose * name sits unescaped after an icon; a file is a `card file` div carrying its * name, size and id as data attributes, with the viewer type further inside. */ const folder = (id: string, name: string) => `
`; const file = (id: string, name: string, size: number, type = 'application/pdf', blocked = false) => `
`; const page = (folders: string[], files: string[]) => `
${folders.length ? `
${folders.join('')}
` : ''}
${files.join('')}
`; const id = (n: number) => n.toString(16).padStart(24, '0'); describe('parseFileListing', () => { it('reads folders and files with their ids, sizes and types', () => { const listing = parseFileListing( page([folder(id(1), 'Handlungssituation')], [file(id(2), '13_AB_Wareneingang.pdf', 171941)]), ); assert.deepEqual(listing.directories, [{ id: id(1), name: 'Handlungssituation' }]); assert.deepEqual(listing.files, [ { id: id(2), name: '13_AB_Wareneingang.pdf', size: 171941, mimeType: 'application/pdf', blocked: false }, ]); }); it('decodes entities in both folder and file names', () => { const listing = parseFileListing( page([folder(id(1), 'Lager & Logistik')], [file(id(2), 'A&B "final".pdf', 10)]), ); assert.equal(listing.directories[0]?.name, 'Lager & Logistik'); assert.equal(listing.files[0]?.name, 'A&B "final".pdf'); }); it('marks a file the virus scanner blocked, which also has no viewer type', () => { const listing = parseFileListing(page([], [file(id(3), 'bad.exe', 5, undefined, true), file(id(4), 'ok.png', 7, 'image/png')])); assert.equal(listing.files[0]?.blocked, true); assert.equal(listing.files[0]?.mimeType, undefined); // The next card's type must not leak into the blocked one. assert.equal(listing.files[1]?.mimeType, 'image/png'); }); it('returns an empty listing for an empty folder', () => { assert.deepEqual(parseFileListing(page([], [])), { directories: [], files: [] }); }); it('refuses a page that is not a file-manager listing rather than reporting 0 files', () => { assert.throws(() => parseFileListing('Anmelden'), FileManagerMarkupError); }); }); describe('pageFor and childRef', () => { it('builds each listing route', () => { assert.equal(pageFor({}), undefined); assert.equal(pageFor({ area: 'my' }), '/files/my/'); assert.equal(pageFor({ area: 'my', folderId: id(1) }), `/files/my/${id(1)}`); assert.equal(pageFor({ area: 'courses' }), '/files/courses/'); assert.equal(pageFor({ area: 'courses', ownerId: id(2) }), `/files/courses/${id(2)}`); assert.equal(pageFor({ area: 'teams', ownerId: id(2), folderId: id(3) }), `/files/teams/${id(2)}/${id(3)}`); assert.equal(pageFor({ area: 'shared' }), '/files/shared/'); }); it('addresses a nested folder by its own id, whatever its depth', () => { const course = childRef({ area: 'courses' }, { id: id(2), name: 'Kurs' }); const top = childRef(course, { id: id(3), name: 'A' }); const deep = childRef(top, { id: id(4), name: 'B' }); assert.deepEqual(deep, { area: 'courses', ownerId: id(2), folderId: id(4) }); }); it('refuses to open a shared folder, which the file manager cannot do either', () => { assert.throws( () => childRef({ area: 'shared' }, { id: id(5), name: 'Ordner' }), (error: unknown) => error instanceof FsError && error.code === 'not_navigable', ); }); }); describe('splitPath', () => { it('drops empty segments and surrounding spaces', () => { assert.deepEqual(splitPath('//courses/ Kurs A /x.pdf/'), ['courses', 'Kurs A', 'x.pdf']); assert.deepEqual(splitPath('/'), []); }); }); /** A client that serves canned listing pages and records what was asked for. */ function fakeClient(pages: Record) { const requested: string[] = []; const client = { getFileManagerPage: async (path: string) => { requested.push(path); const html = pages[path]; if (html === undefined) throw new Error(`no fixture for ${path}`); return html; }, } as unknown as SchulcloudClient; return { client, requested }; } describe('FileManager.resolve', () => { const course = id(10); const slashCourse = id(11); const sub = id(12); const pdf = id(13); const pages = { '/files/courses/': page([folder(course, 'FIA24B - LF2 (Rh)'), folder(slashCourse, 'LF07 - FIA24A/B - Sb/Ha')], []), [`/files/courses/${course}`]: page([folder(sub, 'Handlungssituation')], [file(pdf, 'Mahnwesen.pdf', 100)]), [`/files/courses/${course}/${sub}`]: page([], [file(id(14), 'Lager.pdf', 50)]), [`/files/courses/${slashCourse}`]: page([], [file(id(15), 'MQTT.docx', 70, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')]), '/files/my/': page([], []), }; it('resolves the root and each area without a request for the root', async () => { const { client, requested } = fakeClient(pages); const fs = new FileManager(client); assert.deepEqual(await fs.resolve('/'), { kind: 'directory', path: '/', ref: {}, name: '/' }); const area = await fs.resolve('/Kurs-Dateien'); assert.equal(area.kind, 'directory'); assert.deepEqual(area.kind === 'directory' && area.ref, { area: 'courses' }); assert.deepEqual(requested, []); }); it('walks names down to a file in a nested folder', async () => { const { client } = fakeClient(pages); const node = await new FileManager(client).resolve('/courses/FIA24B - LF2 (Rh)/Handlungssituation/Lager.pdf'); assert.equal(node.kind, 'file'); assert.equal(node.kind === 'file' && node.file.id, id(14)); assert.equal(node.path, '/courses/FIA24B - LF2 (Rh)/Handlungssituation/Lager.pdf'); }); it('resolves a course whose name contains slashes, typed plainly', async () => { const { client } = fakeClient(pages); const node = await new FileManager(client).resolve('/courses/LF07 - FIA24A/B - Sb/Ha/MQTT.docx'); assert.equal(node.kind === 'file' && node.file.id, id(15)); }); it('accepts ids as path segments', async () => { const { client } = fakeClient(pages); const node = await new FileManager(client).resolve(`/courses/${course}/${pdf}`); assert.equal(node.kind === 'file' && node.file.name, 'Mahnwesen.pdf'); }); it('falls back to a case-insensitive match', async () => { const { client } = fakeClient(pages); const node = await new FileManager(client).resolve('/courses/fia24b - lf2 (rh)/handlungssituation'); assert.equal(node.kind, 'directory'); }); it('says what is there when a name is not found', async () => { const { client } = fakeClient(pages); await assert.rejects( new FileManager(client).resolve('/courses/FIA24B - LF2 (Rh)/Handlung'), (error: unknown) => error instanceof FsError && error.code === 'not_found' && /Handlungssituation/.test(error.message), ); }); it('refuses to treat a file as a folder', async () => { const { client } = fakeClient(pages); await assert.rejects( new FileManager(client).resolve('/courses/FIA24B - LF2 (Rh)/Mahnwesen.pdf/more'), (error: unknown) => error instanceof FsError && error.code === 'not_a_directory', ); }); it('reports an ambiguous name with the ids to use instead', async () => { const twins = { '/files/my/': page([folder(id(20), 'Kopie'), folder(id(21), 'Kopie')], []), }; const { client } = fakeClient(twins); await assert.rejects( new FileManager(client).resolve('/my/Kopie'), (error: unknown) => error instanceof FsError && error.code === 'ambiguous' && error.message.includes(id(20)) && error.message.includes(id(21)), ); }); it('rejects an unknown area and names the real ones', async () => { const { client } = fakeClient(pages); await assert.rejects( new FileManager(client).resolve('/Downloads/x'), (error: unknown) => error instanceof FsError && /\/courses/.test(error.message), ); }); it('caches a listing, so ls followed by read costs no second request', async () => { const { client, requested } = fakeClient(pages); const fs = new FileManager(client); await fs.resolve('/courses/FIA24B - LF2 (Rh)/Mahnwesen.pdf'); await fs.resolve('/courses/FIA24B - LF2 (Rh)/Mahnwesen.pdf'); assert.equal(requested.filter((path) => path === '/files/courses/').length, 1); }); }); describe('FileManager.walk', () => { it('stays within its folder budget and says so', async () => { const course = id(30); const pages = { '/files/courses/': page([folder(course, 'Kurs')], []), [`/files/courses/${course}`]: page([folder(id(31), 'A'), folder(id(32), 'B')], []), [`/files/courses/${course}/${id(31)}`]: page([], [file(id(33), 'a.pdf', 1)]), [`/files/courses/${course}/${id(32)}`]: page([], [file(id(34), 'b.pdf', 1)]), }; const { client } = fakeClient(pages); const result = await new FileManager(client).walk( { path: '/courses', ref: { area: 'courses' } }, { maxDepth: 5, maxDirectories: 2 }, ); assert.equal(result.visited, 2); assert.equal(result.truncated, true); }); it('records a folder it could not read instead of dropping it', async () => { const course = id(40); const pages = { '/files/courses/': page([folder(course, 'Kurs')], []) }; const { client } = fakeClient(pages); const result = await new FileManager(client).walk( { path: '/courses', ref: { area: 'courses' } }, { maxDepth: 3, maxDirectories: 10 }, ); assert.equal(result.failures.length, 1); assert.equal(result.failures[0]?.path, '/courses/Kurs'); }); }); describe('the file-manager allowlist', () => { // Some legacy GET routes write. The client must refuse them before any request. const client = { config: { baseUrl: 'https://example.org', jwt: 'x', requestTimeoutMs: 1000, maxDownloadBytes: 10 }, }; it('refuses the routes that write despite being GETs', async () => { const { SchulcloudClient } = await import('../src/core/client.ts'); const real = new SchulcloudClient(client.config as never); for (const path of ['/files/share/?file=' + id(1), `/files/file?file=${id(1)}&share=abc`, `/files/fileModel/${id(1)}/proxy`, '/files/search/?q=x', '/files/permittedDirectories/']) { await assert.rejects(real.getFileManagerPage(path), /refusing file-manager path/, path); } }); }); describe('checkSignedUrl', () => { it('accepts an https storage url from an https instance', () => { assert.equal(checkSignedUrl('https://s3.example.com/bucket/key?X-Amz-Signature=1', 'https://schulcloud.example').host, 's3.example.com'); }); it('refuses plaintext from an https instance, and credentials in the url', () => { assert.throws(() => checkSignedUrl('http://10.0.0.1/x', 'https://schulcloud.example'), /refusing a http:/); assert.throws(() => checkSignedUrl('https://user:pw@s3.example.com/x', 'https://schulcloud.example'), /credentials/); assert.throws(() => checkSignedUrl('file:///etc/passwd', 'http://localhost:4400'), /refusing a file:/); }); it('allows http when the instance itself is http, as the local one is', () => { assert.equal(checkSignedUrl('http://localhost:9900/x', 'http://localhost:4400').protocol, 'http:'); }); });