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).
This commit is contained in:
294
packages/backend/__tests__/routes/webdav/webdav.test.ts
Normal file
294
packages/backend/__tests__/routes/webdav/webdav.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user