Files
lunarfront-app/packages/backend/__tests__/routes/webdav/webdav.test.ts
Ryan Moon 51ca2ca683 Add folder permissions UI and WebDAV protocol support
Permissions UI:
- FolderPermissionsDialog component with public/private toggle,
  role/user permission management, and access level badges
- Integrated into file manager toolbar (visible for folder admins)
- Backend returns accessLevel in folder detail endpoint

WebDAV server:
- Full WebDAV protocol at /webdav/ with Basic Auth (existing credentials)
- PROPFIND, GET, PUT, DELETE, MKCOL, COPY, MOVE, LOCK/UNLOCK support
- Permission-checked against existing folder access model
- In-memory lock stubs for Windows client compatibility
- 22 API integration tests covering all operations

Also fixes canAccess to check folder creator (was missing).
2026-03-29 17:38:57 -05:00

295 lines
8.6 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test'
import type { FastifyInstance } from 'fastify'
import { createTestApp, cleanDb, seedTestCompany, registerAndLogin } from '../../../src/test/helpers.js'
describe('WebDAV', () => {
let app: FastifyInstance
let basicAuth: string
beforeAll(async () => {
app = await createTestApp()
})
afterAll(async () => {
await app.close()
})
beforeEach(async () => {
await cleanDb(app)
await seedTestCompany(app)
const { user } = await registerAndLogin(app, {
email: 'webdav@forte.dev',
password: 'webdavpass1234',
})
basicAuth = 'Basic ' + Buffer.from('webdav@forte.dev:webdavpass1234').toString('base64')
})
describe('OPTIONS', () => {
it('returns DAV headers on root', async () => {
const res = await app.inject({ method: 'OPTIONS', url: '/webdav/' })
expect(res.statusCode).toBe(200)
expect(res.headers['dav']).toContain('1')
expect(res.headers['allow']).toContain('PROPFIND')
expect(res.headers['allow']).toContain('GET')
expect(res.headers['allow']).toContain('PUT')
})
it('returns DAV headers on wildcard path', async () => {
const res = await app.inject({ method: 'OPTIONS', url: '/webdav/some/path' })
expect(res.statusCode).toBe(200)
expect(res.headers['dav']).toContain('1')
})
})
describe('Authentication', () => {
it('returns 401 without credentials', async () => {
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
})
expect(res.statusCode).toBe(401)
expect(res.headers['www-authenticate']).toContain('Basic')
})
it('returns 401 with wrong password', async () => {
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: 'Basic ' + Buffer.from('webdav@forte.dev:wrongpass').toString('base64') },
})
expect(res.statusCode).toBe(401)
})
it('succeeds with correct credentials', async () => {
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: basicAuth, depth: '0' },
})
expect(res.statusCode).toBe(207)
})
})
describe('PROPFIND', () => {
it('lists root with depth 0', async () => {
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: basicAuth, depth: '0' },
})
expect(res.statusCode).toBe(207)
expect(res.headers['content-type']).toContain('application/xml')
expect(res.body).toContain('<D:multistatus')
expect(res.body).toContain('<D:collection/')
})
it('lists root folders with depth 1', async () => {
// Create a folder first via MKCOL
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Test%20Folder',
headers: { authorization: basicAuth },
})
const res = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: basicAuth, depth: '1' },
})
expect(res.statusCode).toBe(207)
expect(res.body).toContain('Test Folder')
})
})
describe('MKCOL', () => {
it('creates a folder', async () => {
const res = await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Documents',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(201)
// Verify it appears in PROPFIND
const listing = await app.inject({
method: 'PROPFIND' as any,
url: '/webdav/',
headers: { authorization: basicAuth, depth: '1' },
})
expect(listing.body).toContain('Documents')
})
it('returns 405 if folder already exists', async () => {
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Documents',
headers: { authorization: basicAuth },
})
const res = await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Documents',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(405)
})
})
describe('PUT / GET / DELETE', () => {
it('uploads, downloads, and deletes a file', async () => {
// Create parent folder
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Uploads',
headers: { authorization: basicAuth },
})
// PUT a file
const fileContent = 'Hello, WebDAV!'
const putRes = await app.inject({
method: 'PUT',
url: '/webdav/Uploads/test.txt',
headers: {
authorization: basicAuth,
'content-type': 'text/plain',
},
body: fileContent,
})
expect(putRes.statusCode).toBe(201)
expect(putRes.headers['etag']).toBeDefined()
// GET the file
const getRes = await app.inject({
method: 'GET',
url: '/webdav/Uploads/test.txt',
headers: { authorization: basicAuth },
})
expect(getRes.statusCode).toBe(200)
expect(getRes.body).toBe(fileContent)
expect(getRes.headers['content-type']).toContain('text/plain')
// DELETE the file
const delRes = await app.inject({
method: 'DELETE',
url: '/webdav/Uploads/test.txt',
headers: { authorization: basicAuth },
})
expect(delRes.statusCode).toBe(204)
// Verify it's gone
const getRes2 = await app.inject({
method: 'GET',
url: '/webdav/Uploads/test.txt',
headers: { authorization: basicAuth },
})
expect(getRes2.statusCode).toBe(404)
})
it('overwrites an existing file with PUT', async () => {
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/Overwrite',
headers: { authorization: basicAuth },
})
// Upload original
await app.inject({
method: 'PUT',
url: '/webdav/Overwrite/doc.txt',
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
body: 'version 1',
})
// Overwrite
const putRes = await app.inject({
method: 'PUT',
url: '/webdav/Overwrite/doc.txt',
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
body: 'version 2',
})
expect(putRes.statusCode).toBe(204)
// Verify new content
const getRes = await app.inject({
method: 'GET',
url: '/webdav/Overwrite/doc.txt',
headers: { authorization: basicAuth },
})
expect(getRes.body).toBe('version 2')
})
})
describe('DELETE folder', () => {
it('deletes a folder', async () => {
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/ToDelete',
headers: { authorization: basicAuth },
})
const res = await app.inject({
method: 'DELETE',
url: '/webdav/ToDelete',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(204)
})
})
describe('LOCK / UNLOCK', () => {
it('returns a lock token', async () => {
const res = await app.inject({
method: 'LOCK' as any,
url: '/webdav/some-resource',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(200)
expect(res.headers['lock-token']).toContain('opaquelocktoken:')
expect(res.body).toContain('<D:lockdiscovery')
})
it('unlocks a resource', async () => {
const lockRes = await app.inject({
method: 'LOCK' as any,
url: '/webdav/some-resource',
headers: { authorization: basicAuth },
})
const lockToken = lockRes.headers['lock-token'] as string
const unlockRes = await app.inject({
method: 'UNLOCK' as any,
url: '/webdav/some-resource',
headers: {
authorization: basicAuth,
'lock-token': lockToken,
},
})
expect(unlockRes.statusCode).toBe(204)
})
})
describe('HEAD', () => {
it('returns headers for a file', async () => {
await app.inject({
method: 'MKCOL' as any,
url: '/webdav/HeadTest',
headers: { authorization: basicAuth },
})
await app.inject({
method: 'PUT',
url: '/webdav/HeadTest/file.txt',
headers: { authorization: basicAuth, 'content-type': 'text/plain' },
body: 'test content',
})
const res = await app.inject({
method: 'HEAD',
url: '/webdav/HeadTest/file.txt',
headers: { authorization: basicAuth },
})
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toContain('text/plain')
expect(res.headers['etag']).toBeDefined()
})
})
})