mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-25 08:09:15 +00:00
3068 lines
115 KiB
TypeScript
3068 lines
115 KiB
TypeScript
// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus
|
||
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
|
||
import type { Request, RequestHandler, Response } from 'express';
|
||
import { Readable, Writable } from 'node:stream';
|
||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import { hash as bcryptHash } from 'bcrypt';
|
||
import { TOTP } from 'otpauth';
|
||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||
import type { RouteDescriptor } from '../../core/http/types.js';
|
||
import { PuterServer } from '../../server.js';
|
||
import { setupTestServer } from '../../testUtil.js';
|
||
import { makeActor } from '../../core/actor.js';
|
||
import { runWithContext } from '../../core/context.js';
|
||
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
|
||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
|
||
import type { WebDAVController } from './WebDAVController.js';
|
||
|
||
let server: PuterServer;
|
||
let controller: WebDAVController;
|
||
let routes: RouteDescriptor[];
|
||
|
||
beforeAll(async () => {
|
||
server = await setupTestServer();
|
||
controller = server.controllers.webdav as unknown as WebDAVController;
|
||
|
||
const router = new PuterRouter();
|
||
controller.registerRoutes(router);
|
||
routes = router.routes;
|
||
});
|
||
|
||
/** Run one handler; true when it called `next` instead of answering. */
|
||
const runHandler = async (
|
||
handler: RequestHandler,
|
||
req: Request,
|
||
res: Response,
|
||
): Promise<boolean> => {
|
||
let advanced = false;
|
||
await handler(req, res, (() => {
|
||
advanced = true;
|
||
}) as never);
|
||
return advanced;
|
||
};
|
||
|
||
/**
|
||
* Stand in for express's router: walk the controller's collected routes in
|
||
* order and run the first one that matches the request's method, plus any
|
||
* `middleware` it declares.
|
||
*
|
||
* The options the real server materializes into gates — subdomain, rate limit,
|
||
* concurrency — are deliberately not applied here; those belong to the
|
||
* materializer's own tests, and reproducing them would make every case below
|
||
* share one DAV budget. `server.test.ts` covers the wiring on a real port.
|
||
*/
|
||
const dispatchMiddleware = async (
|
||
req: Request,
|
||
res: Response,
|
||
next: () => void,
|
||
): Promise<void> => {
|
||
const method = req.method.toLowerCase();
|
||
for (const route of routes) {
|
||
if (
|
||
route.method !== 'use' &&
|
||
route.method !== 'all' &&
|
||
route.method !== method
|
||
) {
|
||
continue;
|
||
}
|
||
let advanced = true;
|
||
for (const handler of [
|
||
...(route.options.middleware ?? []),
|
||
route.handler,
|
||
]) {
|
||
advanced = await runHandler(handler, req, res);
|
||
if (!advanced) break;
|
||
}
|
||
// A chain that stopped short of `next` answered the request.
|
||
if (!advanced) return;
|
||
}
|
||
next();
|
||
};
|
||
|
||
afterAll(async () => {
|
||
await server?.shutdown();
|
||
});
|
||
|
||
interface CapturedResponse {
|
||
statusCode: number;
|
||
body: unknown;
|
||
headers: Record<string, string>;
|
||
ended: boolean;
|
||
}
|
||
|
||
const makeReq = (init: {
|
||
method: string;
|
||
path?: string;
|
||
body?: unknown;
|
||
headers?: Record<string, string>;
|
||
actor?: unknown;
|
||
socket?: unknown;
|
||
ip?: string;
|
||
}): Request => {
|
||
return {
|
||
method: init.method,
|
||
path: init.path ?? '/',
|
||
body: init.body ?? {},
|
||
query: {},
|
||
headers: init.headers ?? {},
|
||
actor: init.actor,
|
||
socket: init.socket ?? {},
|
||
ip: init.ip,
|
||
} as unknown as Request;
|
||
};
|
||
|
||
const makeRes = () => {
|
||
const captured: CapturedResponse = {
|
||
statusCode: 200,
|
||
body: undefined,
|
||
headers: {},
|
||
ended: false,
|
||
};
|
||
const listeners: Record<string, Array<() => void>> = {};
|
||
const res = {
|
||
// The DAV mount holds a concurrency slot for the life of the request
|
||
// and releases it on `finish` / `close`, so the stub has to behave
|
||
// like an emitter or every dispatch throws.
|
||
once: vi.fn((event: string, fn: () => void) => {
|
||
(listeners[event] ??= []).push(fn);
|
||
return res;
|
||
}),
|
||
emit: vi.fn((event: string) => {
|
||
const fns = listeners[event] ?? [];
|
||
listeners[event] = [];
|
||
for (const fn of fns) fn();
|
||
}),
|
||
json: vi.fn((value: unknown) => {
|
||
captured.body = value;
|
||
return res;
|
||
}),
|
||
status: vi.fn((code: number) => {
|
||
captured.statusCode = code;
|
||
return res;
|
||
}),
|
||
set: vi.fn((obj: Record<string, string> | string, val?: string) => {
|
||
if (typeof obj === 'string') {
|
||
captured.headers[obj.toLowerCase()] = val!;
|
||
} else {
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
captured.headers[k.toLowerCase()] = v;
|
||
}
|
||
}
|
||
return res;
|
||
}),
|
||
setHeader: vi.fn((key: string, val: string) => {
|
||
captured.headers[key.toLowerCase()] = val;
|
||
return res;
|
||
}),
|
||
send: vi.fn((value: unknown) => {
|
||
captured.body = value;
|
||
res.emit('finish');
|
||
return res;
|
||
}),
|
||
end: vi.fn(() => {
|
||
captured.ended = true;
|
||
res.emit('finish');
|
||
return res;
|
||
}),
|
||
headersSent: false,
|
||
};
|
||
return { res: res as unknown as Response, captured };
|
||
};
|
||
|
||
const basicAuth = (user: string, pass: string) =>
|
||
`Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`;
|
||
|
||
const noop = vi.fn();
|
||
|
||
const makeUser = async () => {
|
||
const username = `webdav-${Math.random().toString(36).slice(2, 10)}`;
|
||
const created = await server.stores.user.create({
|
||
username,
|
||
uuid: uuidv4(),
|
||
password: null,
|
||
email: `${username}@test.local`,
|
||
free_storage: 100 * 1024 * 1024,
|
||
requires_email_confirmation: false,
|
||
});
|
||
await generateDefaultFsentries(
|
||
server.clients.db,
|
||
server.stores.user,
|
||
created,
|
||
);
|
||
const refreshed = (await server.stores.user.getById(created.id))!;
|
||
return {
|
||
userId: refreshed.id,
|
||
username: refreshed.username,
|
||
actor: {
|
||
user: {
|
||
id: refreshed.id,
|
||
uuid: refreshed.uuid,
|
||
username: refreshed.username,
|
||
},
|
||
},
|
||
};
|
||
};
|
||
|
||
describe('WebDAVController', () => {
|
||
describe('route registration', () => {
|
||
it('registers one catch-all per DAV verb, all on the dav subdomain', () => {
|
||
const router = new PuterRouter();
|
||
controller.registerRoutes(router);
|
||
|
||
for (const verb of [
|
||
'options',
|
||
'head',
|
||
'get',
|
||
'propfind',
|
||
'proppatch',
|
||
'mkcol',
|
||
'put',
|
||
'delete',
|
||
'copy',
|
||
'move',
|
||
'lock',
|
||
'unlock',
|
||
]) {
|
||
const route = router.routes.find((r) => r.method === verb);
|
||
expect(route, verb).toBeDefined();
|
||
expect(route!.path).toBe('/{*splat}');
|
||
expect(route!.options.subdomain).toBe('dav');
|
||
// The gates the materializer installs ahead of the handler.
|
||
expect(route!.options.rateLimit).toBeDefined();
|
||
expect(route!.options.concurrent).toBeDefined();
|
||
}
|
||
|
||
// `all` catches the verbs we don't implement, so it has to come
|
||
// after every route that does.
|
||
const allIndex = router.routes.findIndex((r) => r.method === 'all');
|
||
expect(allIndex).toBe(router.routes.length - 1);
|
||
});
|
||
|
||
it('registers HEAD ahead of GET so express does not fold the two', () => {
|
||
const router = new PuterRouter();
|
||
controller.registerRoutes(router);
|
||
const head = router.routes.findIndex((r) => r.method === 'head');
|
||
const get = router.routes.findIndex((r) => r.method === 'get');
|
||
expect(head).toBeLessThan(get);
|
||
});
|
||
});
|
||
|
||
describe('authentication', () => {
|
||
it('returns 401 when no auth is provided and no session actor', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(makeReq({ method: 'OPTIONS' }), res, noop);
|
||
expect(captured.statusCode).toBe(401);
|
||
expect(captured.headers['www-authenticate']).toContain('Basic');
|
||
});
|
||
|
||
it('returns 401 for malformed Basic auth (no colon)', async () => {
|
||
const { res, captured } = makeRes();
|
||
const encoded = Buffer.from('no-colon-here').toString('base64');
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'OPTIONS',
|
||
headers: { authorization: `Basic ${encoded}` },
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(401);
|
||
});
|
||
|
||
it('returns 401 for invalid -token auth', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'OPTIONS',
|
||
headers: {
|
||
authorization: basicAuth('-token', 'bad-token-value'),
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(401);
|
||
});
|
||
|
||
it('returns 401 for non-existent username', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'OPTIONS',
|
||
headers: {
|
||
authorization: basicAuth(
|
||
'nonexistent-user-xyz',
|
||
'password',
|
||
),
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(401);
|
||
});
|
||
});
|
||
|
||
describe('Basic-auth brute-force gate', () => {
|
||
// Every case pins its own `ip` so the per-IP backstop is not shared
|
||
// with the rest of the file — the memory bucket store is module-wide.
|
||
const makePasswordUser = async () => {
|
||
const username = `webdav-pw-${Math.random()
|
||
.toString(36)
|
||
.slice(2, 10)}`;
|
||
const created = await server.stores.user.create({
|
||
username,
|
||
uuid: uuidv4(),
|
||
password: await bcryptHash('correct-horse', 4),
|
||
email: `${username}@test.local`,
|
||
free_storage: 100 * 1024 * 1024,
|
||
requires_email_confirmation: false,
|
||
});
|
||
await generateDefaultFsentries(
|
||
server.clients.db,
|
||
server.stores.user,
|
||
created,
|
||
);
|
||
return username;
|
||
};
|
||
|
||
const attempt = async (headers: Record<string, string>, ip: string) => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({ method: 'OPTIONS', headers, ip }),
|
||
res,
|
||
noop,
|
||
);
|
||
return captured;
|
||
};
|
||
|
||
it('stops guessing one account after ten failures, before the compare', async () => {
|
||
const username = await makePasswordUser();
|
||
const ip = '203.0.113.10';
|
||
|
||
for (let i = 0; i < 10; i++) {
|
||
const captured = await attempt(
|
||
{ authorization: basicAuth(username, `guess-${i}`) },
|
||
ip,
|
||
);
|
||
expect(captured.statusCode).toBe(401);
|
||
}
|
||
|
||
const lookup = vi.spyOn(server.stores.user, 'getByUsername');
|
||
const blocked = await attempt(
|
||
{ authorization: basicAuth(username, 'guess-11') },
|
||
ip,
|
||
);
|
||
expect(blocked.statusCode).toBe(429);
|
||
// Refused ahead of the credential work, so the attempt costs
|
||
// neither a user lookup nor a bcrypt round.
|
||
expect(lookup).not.toHaveBeenCalled();
|
||
lookup.mockRestore();
|
||
|
||
// Bucketed per account, not globally: another account from the
|
||
// same IP still gets its attempt.
|
||
const otherUsername = await makePasswordUser();
|
||
expect(
|
||
(
|
||
await attempt(
|
||
{ authorization: basicAuth(otherUsername, 'nope') },
|
||
ip,
|
||
)
|
||
).statusCode,
|
||
).toBe(401);
|
||
});
|
||
|
||
it('does not spend the budget on successful requests', async () => {
|
||
const username = await makePasswordUser();
|
||
const ip = '203.0.113.11';
|
||
const good = {
|
||
authorization: basicAuth(username, 'correct-horse'),
|
||
};
|
||
|
||
// A working DAV client resends credentials on every request; well
|
||
// past the failure ceiling, none of them may count against it.
|
||
for (let i = 0; i < 20; i++) {
|
||
expect((await attempt(good, ip)).statusCode).toBe(200);
|
||
}
|
||
expect(
|
||
(
|
||
await attempt(
|
||
{ authorization: basicAuth(username, 'wrong') },
|
||
ip,
|
||
)
|
||
).statusCode,
|
||
).toBe(401);
|
||
});
|
||
|
||
it('backstops spraying many accounts from one address', async () => {
|
||
const ip = '203.0.113.12';
|
||
// Unknown usernames never reach bcrypt, so this is the shape an
|
||
// attacker uses to mint a fresh per-account bucket every attempt.
|
||
for (let i = 0; i < 50; i++) {
|
||
const captured = await attempt(
|
||
{ authorization: basicAuth(`sprayed-${i}`, 'password') },
|
||
ip,
|
||
);
|
||
expect(captured.statusCode).toBe(401);
|
||
}
|
||
expect(
|
||
(
|
||
await attempt(
|
||
{ authorization: basicAuth('sprayed-50', 'password') },
|
||
ip,
|
||
)
|
||
).statusCode,
|
||
).toBe(429);
|
||
});
|
||
|
||
it('holds token guessing on the per-IP backstop', async () => {
|
||
const ip = '203.0.113.13';
|
||
for (let i = 0; i < 50; i++) {
|
||
expect(
|
||
(
|
||
await attempt(
|
||
{ authorization: basicAuth('-token', `bad-${i}`) },
|
||
ip,
|
||
)
|
||
).statusCode,
|
||
).toBe(401);
|
||
}
|
||
expect(
|
||
(
|
||
await attempt(
|
||
{ authorization: basicAuth('-token', 'bad-50') },
|
||
ip,
|
||
)
|
||
).statusCode,
|
||
).toBe(429);
|
||
});
|
||
});
|
||
|
||
describe('OPTIONS (with session actor)', () => {
|
||
it('returns 200 with DAV headers when actor is present', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'OPTIONS',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(200);
|
||
expect(captured.headers['dav']).toContain('1');
|
||
expect(captured.headers['allow']).toContain('PROPFIND');
|
||
expect(captured.headers['allow']).toContain('GET');
|
||
expect(captured.headers['allow']).toContain('PUT');
|
||
expect(captured.headers['allow']).toContain('DELETE');
|
||
});
|
||
});
|
||
|
||
describe('CORS preflight', () => {
|
||
it('answers a browser preflight before the auth gate', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'OPTIONS',
|
||
path: '/someone/.vscode/settings.json',
|
||
headers: {
|
||
origin: 'https://code.puter.com',
|
||
'access-control-request-method': 'PROPFIND',
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
// A non-2xx here is what made every browser DAV client give up
|
||
// before it could send a token.
|
||
expect(captured.statusCode).toBe(200);
|
||
expect(captured.headers['dav']).toContain('1');
|
||
expect(captured.headers['allow']).toContain('PROPFIND');
|
||
expect(captured.headers['access-control-max-age']).toBe('86400');
|
||
expect(captured.headers['www-authenticate']).toBeUndefined();
|
||
});
|
||
|
||
it('still authenticates an OPTIONS that is not a preflight', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'OPTIONS',
|
||
// An Origin alone isn't a preflight — only the
|
||
// `Access-Control-Request-Method` probe is.
|
||
headers: { origin: 'https://code.puter.com' },
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(401);
|
||
// macOS opens a mount on this reply, so it keeps the DAV header.
|
||
expect(captured.headers['dav']).toContain('1');
|
||
});
|
||
});
|
||
|
||
describe('pending-verification gate', () => {
|
||
// WebDAV must enforce the same gate every other authenticated route
|
||
// gets from requireVerifiedAccount — it dispatches off a single use()
|
||
// with no route options, so the middleware is never wired in and it
|
||
// has to call assertVerifiedAccount itself. Without it, an account
|
||
// still pending email/phone/card verification could read/write its
|
||
// whole filesystem over the `dav` subdomain.
|
||
const gatedActor = (flags: Record<string, unknown>) => ({
|
||
user: {
|
||
id: 1,
|
||
uuid: 'gated-uuid',
|
||
username: 'gated',
|
||
...flags,
|
||
},
|
||
});
|
||
|
||
it('rejects a session actor pending phone verification with 403', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
actor: gatedActor({ requires_phone_verification: true }),
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(403);
|
||
});
|
||
|
||
it('rejects a session actor pending card verification with 403', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'GET',
|
||
actor: gatedActor({ requires_card_verification: true }),
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(403);
|
||
});
|
||
|
||
it('rejects a session actor with an unconfirmed email with 403', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
actor: gatedActor({
|
||
requires_email_confirmation: true,
|
||
email_confirmed: false,
|
||
}),
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(403);
|
||
});
|
||
|
||
it('enforces the gate on the Basic-auth path (flags carried onto the built actor)', async () => {
|
||
const username = `webdav-gated-${Math.random()
|
||
.toString(36)
|
||
.slice(2, 10)}`;
|
||
const created = await server.stores.user.create({
|
||
username,
|
||
uuid: uuidv4(),
|
||
password: await bcryptHash('correct-horse', 4),
|
||
email: `${username}@test.local`,
|
||
free_storage: 100 * 1024 * 1024,
|
||
requires_email_confirmation: false,
|
||
});
|
||
await server.stores.user.update(created.id, {
|
||
requires_phone_verification: 1,
|
||
});
|
||
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
headers: {
|
||
authorization: basicAuth(username, 'correct-horse'),
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(403);
|
||
});
|
||
|
||
it('lets a fully-verified session actor through the gate', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'OPTIONS',
|
||
actor: gatedActor({
|
||
requires_phone_verification: false,
|
||
requires_card_verification: false,
|
||
requires_email_confirmation: false,
|
||
}),
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(200);
|
||
});
|
||
});
|
||
|
||
describe('suspension gate', () => {
|
||
// WebDAV must also enforce the suspension gate every other authenticated
|
||
// route gets from requireAuthGate. Same single-use() dispatch means that
|
||
// middleware is never wired in, so #dispatch calls assertNotSuspended
|
||
// itself. Without it, a suspended account could read/write/delete its
|
||
// whole filesystem over the `dav` subdomain.
|
||
it('rejects a suspended session actor with 403', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'suspended-uuid',
|
||
username: 'suspended',
|
||
suspended: true,
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(403);
|
||
});
|
||
|
||
it('rejects a suspended user on the Basic-auth path with 403', async () => {
|
||
const username = `webdav-suspended-${Math.random()
|
||
.toString(36)
|
||
.slice(2, 10)}`;
|
||
const created = await server.stores.user.create({
|
||
username,
|
||
uuid: uuidv4(),
|
||
password: await bcryptHash('correct-horse', 4),
|
||
email: `${username}@test.local`,
|
||
free_storage: 100 * 1024 * 1024,
|
||
requires_email_confirmation: false,
|
||
});
|
||
await server.stores.user.update(created.id, {
|
||
suspended: 1,
|
||
});
|
||
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
headers: {
|
||
authorization: basicAuth(username, 'correct-horse'),
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(403);
|
||
});
|
||
});
|
||
|
||
describe('Basic-auth account parity with /login', () => {
|
||
const makeBasicUser = async (
|
||
prefix: string,
|
||
fields: Record<string, unknown>,
|
||
) => {
|
||
const username = `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||
const created = await server.stores.user.create({
|
||
username,
|
||
uuid: uuidv4(),
|
||
password: await bcryptHash('correct-horse', 4),
|
||
email: `${username}@test.local`,
|
||
free_storage: 100 * 1024 * 1024,
|
||
requires_email_confirmation: false,
|
||
});
|
||
await server.stores.user.update(created.id, fields);
|
||
return { id: created.id, username };
|
||
};
|
||
|
||
const propfindAs = async (username: string, password: string) => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
headers: { authorization: basicAuth(username, password) },
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
return captured.statusCode;
|
||
};
|
||
|
||
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
||
|
||
it('rejects an expired temporary password with 401', async () => {
|
||
const { username } = await makeBasicUser('webdav-temp-expired', {
|
||
temp_password_expires_at: nowSeconds() - 60,
|
||
});
|
||
expect(await propfindAs(username, 'correct-horse')).toBe(401);
|
||
});
|
||
|
||
it('rejects a seat that has not replaced its temporary password with 403', async () => {
|
||
const { username } = await makeBasicUser('webdav-temp-live', {
|
||
temp_password_expires_at: nowSeconds() + 3600,
|
||
requires_password_change: 1,
|
||
});
|
||
expect(await propfindAs(username, 'correct-horse')).toBe(403);
|
||
});
|
||
|
||
it('lets a live temporary password through once the change flag is clear', async () => {
|
||
const { username } = await makeBasicUser('webdav-temp-ok', {
|
||
temp_password_expires_at: nowSeconds() + 3600,
|
||
requires_password_change: 0,
|
||
});
|
||
const status = await propfindAs(username, 'correct-horse');
|
||
expect([401, 403]).not.toContain(status);
|
||
});
|
||
|
||
it('lets a 2FA member of a team that requires 2FA through', async () => {
|
||
const { id, username } = await makeBasicUser('webdav-2fa', {});
|
||
const secret = otpCreateSecret(username).secret;
|
||
await server.stores.user.update(id, {
|
||
otp_enabled: 1,
|
||
otp_secret: secret,
|
||
});
|
||
const seat = vi
|
||
.spyOn(server.stores.team, 'getOrgSeat')
|
||
.mockResolvedValue({ require_2fa: 1 } as never);
|
||
try {
|
||
const code = new TOTP({
|
||
issuer: 'puter.com',
|
||
label: username,
|
||
algorithm: 'SHA1',
|
||
digits: 6,
|
||
secret,
|
||
}).generate();
|
||
const status = await propfindAs(
|
||
username,
|
||
`correct-horse${code}`,
|
||
);
|
||
expect([401, 403]).not.toContain(status);
|
||
} finally {
|
||
seat.mockRestore();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('unsupported methods', () => {
|
||
it('returns 405 for unknown HTTP methods', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PATCH',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(405);
|
||
expect(captured.headers['allow']).toContain('PROPFIND');
|
||
});
|
||
});
|
||
|
||
describe('GET', () => {
|
||
it('returns 404 for a non-existent path', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'GET',
|
||
path: '/nonexistent-file-that-does-not-exist.txt',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe('PROPFIND', () => {
|
||
it('returns 207 multistatus for root path', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
path: '/',
|
||
headers: { depth: '0' },
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(207);
|
||
expect(captured.headers['content-type']).toContain(
|
||
'application/xml',
|
||
);
|
||
expect(captured.body).toContain('multistatus');
|
||
});
|
||
|
||
it('returns 404 for a non-existent path', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
path: '/does-not-exist',
|
||
headers: { depth: '0' },
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(404);
|
||
});
|
||
|
||
it('includes DAV XML properties in the root PROPFIND response', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
path: '/',
|
||
headers: { depth: '0' },
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
const xml = captured.body as string;
|
||
expect(xml).toContain('<D:response>');
|
||
expect(xml).toContain('<D:href>');
|
||
expect(xml).toContain('<D:resourcetype>');
|
||
expect(xml).toContain('<D:collection/>');
|
||
});
|
||
});
|
||
|
||
describe('MKCOL', () => {
|
||
it('rejects creating a collection at root', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'MKCOL',
|
||
path: '/',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(403);
|
||
});
|
||
|
||
it('rejects MKCOL with a body', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'MKCOL',
|
||
path: '/new-collection',
|
||
headers: { 'content-length': '10' },
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(415);
|
||
});
|
||
});
|
||
|
||
describe('PUT', () => {
|
||
it('rejects macOS junk files (.DS_Store)', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PUT',
|
||
path: '/some/dir/.DS_Store',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(422);
|
||
});
|
||
|
||
it('rejects macOS resource fork files (._prefix)', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PUT',
|
||
path: '/some/dir/._myfile.txt',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(422);
|
||
});
|
||
|
||
it('emits GUI-safe item events without leaking numeric fsentry ids', async () => {
|
||
const { actor, userId, username } = await makeUser();
|
||
const target = `/${username}/Documents/webdav-event.txt`;
|
||
const { res, captured } = makeRes();
|
||
const req = Object.assign(
|
||
Readable.from(['hello']),
|
||
makeReq({
|
||
method: 'PUT',
|
||
path: target,
|
||
headers: { 'content-length': '5' },
|
||
actor,
|
||
}),
|
||
) as Request;
|
||
|
||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||
let addedCall: (typeof emitSpy.mock.calls)[number] | undefined;
|
||
try {
|
||
await dispatchMiddleware(req, res, noop);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
addedCall = emitSpy.mock.calls.find(
|
||
([eventName]) => eventName === 'outer.gui.item.added',
|
||
);
|
||
} finally {
|
||
emitSpy.mockRestore();
|
||
}
|
||
|
||
expect(captured.statusCode).toBe(201);
|
||
expect(addedCall).toBeTruthy();
|
||
const payload = addedCall?.[1] as {
|
||
user_id_list?: number[];
|
||
response?: Record<string, unknown>;
|
||
};
|
||
expect(payload.user_id_list).toEqual([userId]);
|
||
expect(payload.response).toMatchObject({
|
||
id: expect.any(String),
|
||
uid: expect.any(String),
|
||
uuid: expect.any(String),
|
||
path: target,
|
||
from_new_service: true,
|
||
});
|
||
expect(typeof payload.response?.id).toBe('string');
|
||
expect(payload.response?.id).toBe(payload.response?.uuid);
|
||
expect(payload.response).not.toHaveProperty('userId');
|
||
});
|
||
});
|
||
|
||
describe('DELETE', () => {
|
||
it('refuses a delete outside the caller’s tree as not found', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'DELETE',
|
||
path: '/nonexistent-file-to-delete.txt',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe('COPY', () => {
|
||
it('returns 400 when Destination header is missing', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'COPY',
|
||
path: '/some/file.txt',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe('MOVE', () => {
|
||
it('returns 400 when Destination header is missing', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'MOVE',
|
||
path: '/some/file.txt',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(400);
|
||
});
|
||
});
|
||
|
||
describe('UNLOCK', () => {
|
||
it('returns 400 when Lock-Token header is missing', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'UNLOCK',
|
||
path: '/some/file.txt',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(400);
|
||
});
|
||
|
||
it('returns 204 for an expired/unknown lock token (idempotent)', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'UNLOCK',
|
||
path: '/some/file.txt',
|
||
headers: {
|
||
'lock-token':
|
||
'<urn:uuid:00000000-0000-0000-0000-000000000099>',
|
||
},
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(204);
|
||
});
|
||
});
|
||
|
||
describe('LOCK', () => {
|
||
it('creates a new exclusive lock and returns XML with lock token', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: '/test/lockable-file.txt',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(200);
|
||
expect(captured.headers['content-type']).toContain(
|
||
'application/xml',
|
||
);
|
||
const xml = captured.body as string;
|
||
expect(xml).toContain('lockdiscovery');
|
||
expect(xml).toContain('urn:uuid:');
|
||
expect(xml).toContain('<D:exclusive/>');
|
||
expect(captured.headers['lock-token']).toContain('urn:uuid:');
|
||
});
|
||
|
||
it('rejects locking a path the user has no write access to (e.g. root)', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: '/',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(403);
|
||
});
|
||
|
||
it('rejects a second exclusive lock on the same path', async () => {
|
||
const uniquePath = `/test/double-lock-${Date.now()}.txt`;
|
||
|
||
// First lock
|
||
const { res: res1, captured: cap1 } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: uniquePath,
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res1,
|
||
noop,
|
||
);
|
||
expect(cap1.statusCode).toBe(200);
|
||
|
||
// Second lock — should be 423 Locked
|
||
const { res: res2, captured: cap2 } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: uniquePath,
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res2,
|
||
noop,
|
||
);
|
||
expect(cap2.statusCode).toBe(423);
|
||
});
|
||
|
||
it('refreshes an existing lock when If header provides the token', async () => {
|
||
const uniquePath = `/test/refresh-lock-${Date.now()}.txt`;
|
||
const { res: res1, captured: cap1 } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: uniquePath,
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res1,
|
||
noop,
|
||
);
|
||
expect(cap1.statusCode).toBe(200);
|
||
const lockToken = cap1.headers['lock-token']!.replace(/[<>]/g, '');
|
||
|
||
// Refresh
|
||
const { res: res2, captured: cap2 } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: uniquePath,
|
||
headers: { if: `(<${lockToken}>)` },
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res2,
|
||
noop,
|
||
);
|
||
expect(cap2.statusCode).toBe(200);
|
||
const xml = cap2.body as string;
|
||
expect(xml).toContain(lockToken);
|
||
});
|
||
|
||
it('grants and reports the requested Timeout, on create and refresh', async () => {
|
||
const uniquePath = `/test/timeout-lock-${Date.now()}.txt`;
|
||
const actor = {
|
||
user: { id: 1, uuid: 'test-uuid', username: 'test' },
|
||
};
|
||
const { res: res1, captured: cap1 } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: uniquePath,
|
||
headers: { timeout: 'Second-120' },
|
||
actor,
|
||
}),
|
||
res1,
|
||
noop,
|
||
);
|
||
expect(cap1.statusCode).toBe(200);
|
||
expect(cap1.body as string).toContain(
|
||
'<D:timeout>Second-120</D:timeout>',
|
||
);
|
||
const lockToken = cap1.headers['lock-token']!.replace(/[<>]/g, '');
|
||
|
||
const { res: res2, captured: cap2 } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: uniquePath,
|
||
headers: { if: `(<${lockToken}>)`, timeout: 'Second-300' },
|
||
actor,
|
||
}),
|
||
res2,
|
||
noop,
|
||
);
|
||
expect(cap2.statusCode).toBe(200);
|
||
expect(cap2.body as string).toContain(
|
||
'<D:timeout>Second-300</D:timeout>',
|
||
);
|
||
});
|
||
|
||
it('reports the server max when no Timeout is requested', async () => {
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'LOCK',
|
||
path: `/test/default-timeout-${Date.now()}.txt`,
|
||
actor: {
|
||
user: { id: 1, uuid: 'test-uuid', username: 'test' },
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(200);
|
||
expect(captured.body as string).toContain(
|
||
'<D:timeout>Second-3600</D:timeout>',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('error handling', () => {
|
||
it('catches HttpError and returns its status code', async () => {
|
||
// GET on a non-existent file → HttpError(404) → 404 response
|
||
const { res, captured } = makeRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'GET',
|
||
path: '/no-such-file',
|
||
actor: {
|
||
user: {
|
||
id: 1,
|
||
uuid: 'test-uuid',
|
||
username: 'test',
|
||
},
|
||
},
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
expect(captured.statusCode).toBe(404);
|
||
});
|
||
});
|
||
});
|
||
|
||
// -- Full-verb coverage -----------------------------------------------
|
||
//
|
||
// The suite above pins auth, gates and the cheap rejections. This one drives
|
||
// each verb against a real provisioned home directory so the success paths
|
||
// (and the 4xx selections between them) are exercised end to end.
|
||
|
||
describe('WebDAVController verbs', () => {
|
||
/**
|
||
* A response double that is also a Writable, so handlers that end with
|
||
* `body.pipe(res)` (GET) work without a socket.
|
||
*/
|
||
const makeStreamRes = ({ abortOnFirstChunk = false } = {}) => {
|
||
const chunks: Buffer[] = [];
|
||
const captured = {
|
||
statusCode: 200,
|
||
headers: {} as Record<string, string>,
|
||
body: undefined as unknown,
|
||
ended: false,
|
||
text: () => Buffer.concat(chunks).toString('utf8'),
|
||
};
|
||
const sink = new Writable({
|
||
write(chunk, _encoding, callback) {
|
||
chunks.push(Buffer.from(chunk as Buffer));
|
||
// A client that hangs up mid-download surfaces as the
|
||
// response being destroyed, not as a write error.
|
||
if (abortOnFirstChunk) sink.destroy();
|
||
callback();
|
||
},
|
||
});
|
||
sink.on('finish', () => {
|
||
captured.ended = true;
|
||
});
|
||
const res = Object.assign(sink, {
|
||
status: (code: number) => {
|
||
captured.statusCode = code;
|
||
return res;
|
||
},
|
||
set: (key: Record<string, string> | string, value?: string) => {
|
||
if (typeof key === 'string') {
|
||
captured.headers[key.toLowerCase()] = value!;
|
||
} else {
|
||
for (const [k, v] of Object.entries(key)) {
|
||
captured.headers[k.toLowerCase()] = v;
|
||
}
|
||
}
|
||
return res;
|
||
},
|
||
setHeader: (key: string, value: string) => {
|
||
captured.headers[key.toLowerCase()] = value;
|
||
return res;
|
||
},
|
||
json: (value: unknown) => {
|
||
captured.body = value;
|
||
return res;
|
||
},
|
||
send: (value: unknown) => {
|
||
captured.body = value;
|
||
captured.ended = true;
|
||
// End the underlying Writable so `finish` fires, as it does
|
||
// on a real response. The DAV mount releases its concurrency
|
||
// slot on that event — without it every `send()` path would
|
||
// leak a slot and later requests would 429.
|
||
if (!sink.writableEnded) sink.end();
|
||
return res;
|
||
},
|
||
headersSent: false,
|
||
});
|
||
return { res: res as unknown as Response, captured };
|
||
};
|
||
|
||
const dispatch = async (
|
||
init: Parameters<typeof makeReq>[0] & { content?: string },
|
||
) => {
|
||
const { res, captured } = makeStreamRes();
|
||
const base = makeReq(init);
|
||
const req =
|
||
init.content === undefined
|
||
? base
|
||
: (Object.assign(
|
||
Readable.from([init.content]),
|
||
base,
|
||
) as Request);
|
||
await dispatchMiddleware(req, res, noop);
|
||
// GUI events and stream pipes settle on the next tick.
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
return captured;
|
||
};
|
||
|
||
const putFile = async (
|
||
actor: unknown,
|
||
path: string,
|
||
content: string,
|
||
): Promise<void> => {
|
||
const captured = await dispatch({
|
||
method: 'PUT',
|
||
path,
|
||
headers: { 'content-length': String(content.length) },
|
||
actor,
|
||
content,
|
||
});
|
||
if (captured.statusCode !== 201 && captured.statusCode !== 204) {
|
||
throw new Error(
|
||
`PUT ${path} failed with ${captured.statusCode}: ${String(captured.body)}`,
|
||
);
|
||
}
|
||
};
|
||
|
||
/** Index of the `eventName` GUI emit about `uid` among recorded calls. */
|
||
const guiEventIndex = (
|
||
calls: unknown[][],
|
||
eventName: string,
|
||
uid: string,
|
||
): number =>
|
||
calls.findIndex(
|
||
([name, data]) =>
|
||
name === eventName &&
|
||
(data as { response: Record<string, unknown> }).response.uid ===
|
||
uid,
|
||
);
|
||
const guiResponse = (calls: unknown[][], index: number) =>
|
||
(calls[index]?.[1] as { response: Record<string, unknown> }).response;
|
||
|
||
describe('GET / HEAD', () => {
|
||
it('streams file bytes with a strong ETag and Last-Modified', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/get-me.txt`;
|
||
await putFile(actor, path, 'webdav-body');
|
||
|
||
const captured = await dispatch({ method: 'GET', path, actor });
|
||
expect(captured.statusCode).toBe(200);
|
||
expect(captured.text()).toBe('webdav-body');
|
||
expect(captured.headers['accept-ranges']).toBe('bytes');
|
||
expect(captured.headers['content-length']).toBe('11');
|
||
expect(captured.headers.etag).toMatch(/^"[0-9a-f-]+-\d+"$/);
|
||
expect(captured.headers['last-modified']).toEqual(
|
||
expect.any(String),
|
||
);
|
||
});
|
||
|
||
it('answers HEAD with the headers and no body', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/head-me.txt`;
|
||
await putFile(actor, path, 'abcd');
|
||
|
||
const captured = await dispatch({ method: 'HEAD', path, actor });
|
||
expect(captured.statusCode).toBe(200);
|
||
expect(captured.text()).toBe('');
|
||
expect(captured.headers['content-length']).toBe('4');
|
||
});
|
||
|
||
it('serves a byte range as 206 with Content-Range', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/ranged.txt`;
|
||
await putFile(actor, path, '0123456789');
|
||
|
||
const captured = await dispatch({
|
||
method: 'GET',
|
||
path,
|
||
actor,
|
||
headers: { range: 'bytes=2-5' },
|
||
});
|
||
expect(captured.statusCode).toBe(206);
|
||
expect(captured.headers['content-range']).toBe('bytes 2-5/10');
|
||
expect(captured.text()).toBe('2345');
|
||
});
|
||
|
||
/**
|
||
* Swap the object store's answer for one GET so the body stream is
|
||
* under the test's control. Intercepts at the S3 SDK client, the real
|
||
* boundary; every other command still hits the in-memory S3.
|
||
*/
|
||
const serveBody = (body: Readable) => {
|
||
const client = server.clients.s3.get(
|
||
server.stores.s3Object.resolveRegion(null),
|
||
);
|
||
const original = client.send.bind(client);
|
||
return vi.spyOn(client, 'send').mockImplementation(((
|
||
command: unknown,
|
||
) =>
|
||
command instanceof GetObjectCommand
|
||
? Promise.resolve({
|
||
Body: body,
|
||
ContentLength: 7,
|
||
ContentType: 'text/plain',
|
||
})
|
||
: original(command as never)) as never);
|
||
};
|
||
|
||
it('tears down the upstream stream when the client disconnects mid-download', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/abort-me.txt`;
|
||
await putFile(actor, path, 'streamed');
|
||
|
||
// Never ends on its own: only a teardown can finish it.
|
||
const body = new Readable({ read() {} });
|
||
body.push('partial');
|
||
const send = serveBody(body);
|
||
const { res } = makeStreamRes({ abortOnFirstChunk: true });
|
||
try {
|
||
await dispatchMiddleware(
|
||
makeReq({ method: 'GET', path, actor }),
|
||
res,
|
||
noop,
|
||
);
|
||
} finally {
|
||
send.mockRestore();
|
||
}
|
||
|
||
expect((res as unknown as Writable).destroyed).toBe(true);
|
||
expect(body.destroyed).toBe(true);
|
||
});
|
||
|
||
it('survives the upstream stream failing mid-download', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/reset-me.txt`;
|
||
await putFile(actor, path, 'streamed');
|
||
|
||
// First read hands over a chunk, the next one fails the way a
|
||
// dropped storage connection does. Without an error listener on
|
||
// the body that event would take the process down.
|
||
let reads = 0;
|
||
const body = new Readable({
|
||
read() {
|
||
reads += 1;
|
||
if (reads === 1) this.push('partial');
|
||
else this.destroy(new Error('upstream reset'));
|
||
},
|
||
});
|
||
const send = serveBody(body);
|
||
const { res, captured } = makeStreamRes();
|
||
try {
|
||
await expect(
|
||
dispatchMiddleware(
|
||
makeReq({ method: 'GET', path, actor }),
|
||
res,
|
||
noop,
|
||
),
|
||
).resolves.toBeUndefined();
|
||
} finally {
|
||
send.mockRestore();
|
||
}
|
||
|
||
expect(captured.statusCode).toBe(200);
|
||
expect((res as unknown as Writable).destroyed).toBe(true);
|
||
expect(body.destroyed).toBe(true);
|
||
});
|
||
|
||
it('refuses to GET a directory', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const captured = await dispatch({
|
||
method: 'GET',
|
||
path: `/${username}/Documents`,
|
||
actor,
|
||
});
|
||
expect(captured.statusCode).toBe(400);
|
||
expect(captured.body).toBe('Cannot GET a directory');
|
||
});
|
||
|
||
it("hides another user's file as not found", async () => {
|
||
const owner = await makeUser();
|
||
const stranger = await makeUser();
|
||
const path = `/${owner.username}/Documents/private.txt`;
|
||
await putFile(owner.actor, path, 'secret');
|
||
|
||
const captured = await dispatch({
|
||
method: 'GET',
|
||
path,
|
||
actor: stranger.actor,
|
||
});
|
||
expect(captured.statusCode).toBe(404);
|
||
expect(captured.body).toBe('Not Found');
|
||
});
|
||
});
|
||
|
||
describe('PROPFIND', () => {
|
||
it('lists direct children at the default depth', async () => {
|
||
const { actor, username } = await makeUser();
|
||
await putFile(
|
||
actor,
|
||
`/${username}/Documents/propfind-child.txt`,
|
||
'x',
|
||
);
|
||
|
||
const captured = await dispatch({
|
||
method: 'PROPFIND',
|
||
path: `/${username}/Documents`,
|
||
actor,
|
||
});
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = captured.body as string;
|
||
expect(xml).toContain(`<D:href>/${username}/Documents/</D:href>`);
|
||
expect(xml).toContain('propfind-child.txt');
|
||
// A file child carries the two file-only properties.
|
||
expect(xml).toContain('<D:getcontentlength>1</D:getcontentlength>');
|
||
expect(xml).toContain(
|
||
'<D:getcontenttype>text/plain</D:getcontenttype>',
|
||
);
|
||
});
|
||
|
||
it('reports timestamps in the present, not the epoch', async () => {
|
||
// Entry timestamps are stored as Unix seconds. Handing those to
|
||
// `Date` unscaled reads them as milliseconds and dates every file
|
||
// to January 1970, which clients sort and sync against.
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/dated.txt`;
|
||
await putFile(actor, path, 'x');
|
||
|
||
const captured = await dispatch({
|
||
method: 'PROPFIND',
|
||
path,
|
||
actor,
|
||
headers: { depth: '0' },
|
||
});
|
||
const xml = captured.body as string;
|
||
const modified = xml.match(
|
||
/<D:getlastmodified>(.*?)<\/D:getlastmodified>/,
|
||
)?.[1];
|
||
expect(modified).toBeDefined();
|
||
const skew = Math.abs(
|
||
new Date(modified as string).getTime() - Date.now(),
|
||
);
|
||
expect(skew).toBeLessThan(5 * 60 * 1000);
|
||
});
|
||
|
||
it('emits the same ETag for an entry over PROPFIND and GET', async () => {
|
||
// A client that sees the validator change between the two treats
|
||
// the file as modified and re-downloads it on every pass.
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/etag-match.txt`;
|
||
await putFile(actor, path, 'x');
|
||
|
||
const propfind = await dispatch({
|
||
method: 'PROPFIND',
|
||
path,
|
||
actor,
|
||
headers: { depth: '0' },
|
||
});
|
||
const propfindEtag = (propfind.body as string).match(
|
||
/<D:getetag>(.*?)<\/D:getetag>/,
|
||
)?.[1];
|
||
|
||
const get = await dispatch({ method: 'GET', path, actor });
|
||
|
||
expect(propfindEtag).toBeDefined();
|
||
expect(get.headers['etag']).toBe(propfindEtag);
|
||
});
|
||
|
||
it('omits children at depth 0', async () => {
|
||
const { actor, username } = await makeUser();
|
||
await putFile(actor, `/${username}/Documents/hidden.txt`, 'x');
|
||
|
||
const captured = await dispatch({
|
||
method: 'PROPFIND',
|
||
path: `/${username}/Documents`,
|
||
actor,
|
||
headers: { depth: '0' },
|
||
});
|
||
expect(captured.statusCode).toBe(207);
|
||
expect(captured.body as string).not.toContain('hidden.txt');
|
||
});
|
||
|
||
it("lists the caller's home directory under the root collection", async () => {
|
||
const { actor, username } = await makeUser();
|
||
const captured = await dispatch({
|
||
method: 'PROPFIND',
|
||
path: '/',
|
||
actor,
|
||
});
|
||
expect(captured.statusCode).toBe(207);
|
||
expect(captured.body as string).toContain(
|
||
`<D:href>/${username}/</D:href>`,
|
||
);
|
||
});
|
||
|
||
describe('root collection per credential', () => {
|
||
// Own address, so the per-IP auth failure bucket the rest of the
|
||
// file draws on can't refuse these.
|
||
const propfindRoot = (authorization: string) =>
|
||
dispatch({
|
||
method: 'PROPFIND',
|
||
path: '/',
|
||
headers: { authorization },
|
||
ip: '203.0.113.53',
|
||
});
|
||
|
||
const mintToken = async (userId: number, permission: string) => {
|
||
const user = (await server.stores.user.getById(userId))!;
|
||
return server.services.auth.createAccessToken(
|
||
makeActor({ user }),
|
||
[[permission]],
|
||
);
|
||
};
|
||
|
||
it('lists the home for a password login and a full-access token', async () => {
|
||
const username = `webdav-root-${Math.random()
|
||
.toString(36)
|
||
.slice(2, 10)}`;
|
||
const created = await server.stores.user.create({
|
||
username,
|
||
uuid: uuidv4(),
|
||
password: await bcryptHash('correct-horse', 4),
|
||
email: `${username}@test.local`,
|
||
free_storage: 100 * 1024 * 1024,
|
||
requires_email_confirmation: false,
|
||
});
|
||
await generateDefaultFsentries(
|
||
server.clients.db,
|
||
server.stores.user,
|
||
created,
|
||
);
|
||
const fullAccess = await mintToken(created.id, FULL_API_ACCESS);
|
||
|
||
for (const authorization of [
|
||
basicAuth(username, 'correct-horse'),
|
||
basicAuth('-token', fullAccess),
|
||
]) {
|
||
const captured = await propfindRoot(authorization);
|
||
expect(captured.statusCode).toBe(207);
|
||
expect(captured.body as string).toContain(
|
||
`<D:href>/${username}/</D:href>`,
|
||
);
|
||
}
|
||
});
|
||
|
||
it('leaves the home out for a token scoped below it', async () => {
|
||
const { userId, username } = await makeUser();
|
||
const home = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${username}`,
|
||
))!;
|
||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${username}/Documents`,
|
||
))!;
|
||
const scoped = await mintToken(
|
||
userId,
|
||
`fs:${documents.uuid}:read`,
|
||
);
|
||
|
||
const captured = await propfindRoot(
|
||
basicAuth('-token', scoped),
|
||
);
|
||
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = captured.body as string;
|
||
expect(xml).toContain('<D:href>/</D:href>');
|
||
expect(xml).not.toContain(`/${username}/`);
|
||
expect(xml).not.toContain(home.uuid);
|
||
});
|
||
|
||
it('lists the home for a token that can read it', async () => {
|
||
const { userId, username } = await makeUser();
|
||
const home = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${username}`,
|
||
))!;
|
||
const scoped = await mintToken(userId, `fs:${home.uuid}:read`);
|
||
|
||
const captured = await propfindRoot(
|
||
basicAuth('-token', scoped),
|
||
);
|
||
|
||
expect(captured.statusCode).toBe(207);
|
||
expect(captured.body as string).toContain(
|
||
`<D:href>/${username}/</D:href>`,
|
||
);
|
||
});
|
||
});
|
||
|
||
it('describes a single file when asked for one', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/single.md`;
|
||
await putFile(actor, path, 'hello');
|
||
|
||
const captured = await dispatch({
|
||
method: 'PROPFIND',
|
||
path,
|
||
actor,
|
||
});
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = captured.body as string;
|
||
expect(xml).toContain('<D:resourcetype></D:resourcetype>');
|
||
expect(xml).toContain(
|
||
'<D:getcontenttype>text/markdown</D:getcontenttype>',
|
||
);
|
||
});
|
||
|
||
it("answers a share root's parent with a virtual collection", async () => {
|
||
const owner = await makeUser();
|
||
const holder = await makeUser();
|
||
await dispatch({
|
||
method: 'MKCOL',
|
||
path: `/${owner.username}/Documents/Album`,
|
||
actor: owner.actor,
|
||
});
|
||
const root = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${owner.username}/Documents/Album`,
|
||
))!;
|
||
await server.services.acl.setUserUser(
|
||
owner.actor as never,
|
||
holder.actor as never,
|
||
{
|
||
path: root.path,
|
||
resolveAncestors: () =>
|
||
server.services.fs.getAncestorChain(root.path),
|
||
},
|
||
'read',
|
||
);
|
||
|
||
// `/<owner>/<uuid>` is where Up from a share root lands; it is not
|
||
// a real path, so it answers as a virtual collection whose only
|
||
// member is the share root at its masked path.
|
||
const captured = await dispatch({
|
||
method: 'PROPFIND',
|
||
path: `/${owner.username}/${root.uuid}`,
|
||
actor: holder.actor,
|
||
});
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = captured.body as string;
|
||
expect(xml).toContain(
|
||
`<D:href>/${owner.username}/${root.uuid}/</D:href>`,
|
||
);
|
||
expect(xml).toContain(
|
||
`<D:href>/${owner.username}/${root.uuid}/Album/</D:href>`,
|
||
);
|
||
// The owner's real tree never shows through.
|
||
expect(xml).not.toContain('/Documents/');
|
||
});
|
||
|
||
it("keeps a share root's parent a 404 for a caller with no share", async () => {
|
||
const owner = await makeUser();
|
||
const stranger = await makeUser();
|
||
await dispatch({
|
||
method: 'MKCOL',
|
||
path: `/${owner.username}/Documents/Private`,
|
||
actor: owner.actor,
|
||
});
|
||
const root = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${owner.username}/Documents/Private`,
|
||
))!;
|
||
|
||
const captured = await dispatch({
|
||
method: 'PROPFIND',
|
||
path: `/${owner.username}/${root.uuid}`,
|
||
actor: stranger.actor,
|
||
});
|
||
expect(captured.statusCode).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe('PROPPATCH', () => {
|
||
it('acknowledges a property update with 207', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const captured = await dispatch({
|
||
method: 'PROPPATCH',
|
||
path: `/${username}/Documents`,
|
||
actor,
|
||
});
|
||
expect(captured.statusCode).toBe(207);
|
||
expect(captured.body as string).toContain('HTTP/1.1 200 OK');
|
||
});
|
||
});
|
||
|
||
describe('MKCOL', () => {
|
||
it('creates a collection and reports its Location', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/new-collection`;
|
||
const captured = await dispatch({ method: 'MKCOL', path, actor });
|
||
expect(captured.statusCode).toBe(201);
|
||
expect(captured.headers.location).toBe(`${path}/`);
|
||
const entry = await server.stores.fsEntry.getEntryByPath(path);
|
||
expect(entry?.isDir).toBe(true);
|
||
});
|
||
|
||
it('returns 405 when the collection already exists', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/twice`;
|
||
expect(
|
||
(await dispatch({ method: 'MKCOL', path, actor })).statusCode,
|
||
).toBe(201);
|
||
const second = await dispatch({ method: 'MKCOL', path, actor });
|
||
expect(second.statusCode).toBe(405);
|
||
expect(second.body).toBe('Already exists');
|
||
});
|
||
|
||
it("denies creating a collection in another user's home", async () => {
|
||
const owner = await makeUser();
|
||
const stranger = await makeUser();
|
||
const captured = await dispatch({
|
||
method: 'MKCOL',
|
||
path: `/${owner.username}/Documents/intruder`,
|
||
actor: stranger.actor,
|
||
});
|
||
// The parent is out of sight, so the refusal doesn't confirm it.
|
||
expect(captured.statusCode).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe('PUT', () => {
|
||
it('creates with 201 and overwrites with 204', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/put-twice.txt`;
|
||
|
||
const created = await dispatch({
|
||
method: 'PUT',
|
||
path,
|
||
headers: { 'content-length': '3' },
|
||
actor,
|
||
content: 'one',
|
||
});
|
||
expect(created.statusCode).toBe(201);
|
||
expect(created.headers.etag).toEqual(expect.any(String));
|
||
|
||
const replaced = await dispatch({
|
||
method: 'PUT',
|
||
path,
|
||
headers: { 'content-length': '5' },
|
||
actor,
|
||
content: 'three',
|
||
});
|
||
expect(replaced.statusCode).toBe(204);
|
||
expect(
|
||
(await server.stores.fsEntry.getEntryByPath(path))?.size,
|
||
).toBe(5);
|
||
});
|
||
|
||
it('answers an Expect: 100-continue handshake on the socket', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const written: string[] = [];
|
||
const captured = await dispatch({
|
||
method: 'PUT',
|
||
path: `/${username}/Documents/expect.txt`,
|
||
headers: {
|
||
'content-length': '2',
|
||
expect: '100-continue',
|
||
},
|
||
actor,
|
||
content: 'hi',
|
||
socket: { write: (chunk: string) => written.push(chunk) },
|
||
});
|
||
expect(captured.statusCode).toBe(201);
|
||
expect(written).toEqual(['HTTP/1.1 100 Continue\r\n\r\n']);
|
||
});
|
||
|
||
it('falls back to X-Expected-Entity-Length when Content-Length is absent', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/finder-style.txt`;
|
||
const captured = await dispatch({
|
||
method: 'PUT',
|
||
path,
|
||
headers: { 'x-expected-entity-length': '4' },
|
||
actor,
|
||
content: 'macs',
|
||
});
|
||
expect(captured.statusCode).toBe(201);
|
||
expect(
|
||
(await server.stores.fsEntry.getEntryByPath(path))?.size,
|
||
).toBe(4);
|
||
});
|
||
});
|
||
|
||
describe('DELETE', () => {
|
||
it('removes an existing entry with 204', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/delete-me.txt`;
|
||
await putFile(actor, path, 'bye');
|
||
|
||
const captured = await dispatch({ method: 'DELETE', path, actor });
|
||
expect(captured.statusCode).toBe(204);
|
||
expect(await server.stores.fsEntry.getEntryByPath(path)).toBeNull();
|
||
});
|
||
|
||
it('returns 404 for a path the caller may write but that does not exist', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const captured = await dispatch({
|
||
method: 'DELETE',
|
||
path: `/${username}/Documents/never-existed.txt`,
|
||
actor,
|
||
});
|
||
expect(captured.statusCode).toBe(404);
|
||
expect(captured.body).toBe('Not Found');
|
||
});
|
||
});
|
||
|
||
describe('COPY', () => {
|
||
it('copies a file to a new destination with 201', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/copy-src.txt`;
|
||
const destination = `/${username}/Documents/copy-dst.txt`;
|
||
await putFile(actor, source, 'copied');
|
||
|
||
const captured = await dispatch({
|
||
method: 'COPY',
|
||
path: source,
|
||
actor,
|
||
headers: { destination, host: 'dav.puter.localhost' },
|
||
});
|
||
expect(captured.statusCode).toBe(201);
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(destination),
|
||
).not.toBeNull();
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(source),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('accepts an absolute-URL Destination header', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/copy-abs-src.txt`;
|
||
const destination = `/${username}/Documents/copy-abs-dst.txt`;
|
||
await putFile(actor, source, 'abs');
|
||
|
||
const captured = await dispatch({
|
||
method: 'COPY',
|
||
path: source,
|
||
actor,
|
||
headers: {
|
||
destination: `http://dav.puter.localhost${destination}`,
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
});
|
||
expect(captured.statusCode).toBe(201);
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(destination),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('overwrites an existing destination with 204 and announces the replaced entry', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/copy-over-src.txt`;
|
||
const destination = `/${username}/Documents/copy-over-dst.txt`;
|
||
await putFile(actor, source, 'fresh');
|
||
await putFile(actor, destination, 'stale');
|
||
|
||
const replaced =
|
||
(await server.stores.fsEntry.getEntryByPath(destination))!;
|
||
|
||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||
let captured: Awaited<ReturnType<typeof dispatch>>;
|
||
let removed: Record<string, unknown> | undefined;
|
||
try {
|
||
captured = await dispatch({
|
||
method: 'COPY',
|
||
path: source,
|
||
actor,
|
||
headers: { destination, host: 'dav.puter.localhost' },
|
||
});
|
||
const calls = emitSpy.mock.calls;
|
||
const added = () =>
|
||
calls.findIndex(
|
||
([name]) => name === 'outer.gui.item.added',
|
||
);
|
||
await vi.waitFor(() =>
|
||
expect(added()).toBeGreaterThanOrEqual(0),
|
||
);
|
||
const removedAt = guiEventIndex(
|
||
calls,
|
||
'outer.gui.item.removed',
|
||
replaced.uuid,
|
||
);
|
||
// Removal first: the desktop hides rows by path.
|
||
expect(removedAt).toBeGreaterThanOrEqual(0);
|
||
expect(removedAt).toBeLessThan(added());
|
||
removed = guiResponse(calls, removedAt);
|
||
} finally {
|
||
emitSpy.mockRestore();
|
||
}
|
||
expect(captured.statusCode).toBe(204);
|
||
// Other clients drop the replaced entry's row by this.
|
||
expect(removed?.path).toBe(destination);
|
||
});
|
||
|
||
it('returns 412 when the destination exists and Overwrite is F', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/copy-nf-src.txt`;
|
||
const destination = `/${username}/Documents/copy-nf-dst.txt`;
|
||
await putFile(actor, source, 'a');
|
||
await putFile(actor, destination, 'b');
|
||
|
||
const captured = await dispatch({
|
||
method: 'COPY',
|
||
path: source,
|
||
actor,
|
||
headers: {
|
||
destination,
|
||
overwrite: 'F',
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
});
|
||
expect(captured.statusCode).toBe(412);
|
||
expect(captured.body).toBe('Destination exists and Overwrite=F');
|
||
});
|
||
|
||
it('returns 404 when the source is missing', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const captured = await dispatch({
|
||
method: 'COPY',
|
||
path: `/${username}/Documents/no-source.txt`,
|
||
actor,
|
||
headers: {
|
||
destination: `/${username}/Documents/anywhere.txt`,
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
});
|
||
expect(captured.statusCode).toBe(404);
|
||
expect(captured.body).toBe('Not Found');
|
||
});
|
||
|
||
it('returns 409 when the destination parent is not a directory', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/copy-409-src.txt`;
|
||
const blocker = `/${username}/Documents/not-a-dir.txt`;
|
||
await putFile(actor, source, 'a');
|
||
await putFile(actor, blocker, 'b');
|
||
|
||
const captured = await dispatch({
|
||
method: 'COPY',
|
||
path: source,
|
||
actor,
|
||
headers: {
|
||
destination: `${blocker}/child.txt`,
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
});
|
||
expect(captured.statusCode).toBe(409);
|
||
});
|
||
});
|
||
|
||
describe('MOVE', () => {
|
||
it('relocates the entry and leaves nothing behind', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/move-src.txt`;
|
||
const destination = `/${username}/Documents/move-dst.txt`;
|
||
await putFile(actor, source, 'moving');
|
||
|
||
const captured = await dispatch({
|
||
method: 'MOVE',
|
||
path: source,
|
||
actor,
|
||
headers: { destination, host: 'dav.puter.localhost' },
|
||
});
|
||
expect(captured.statusCode).toBe(201);
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(source),
|
||
).toBeNull();
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(destination),
|
||
).not.toBeNull();
|
||
});
|
||
|
||
it('overwrites an existing destination and announces both changes', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/move-over-src.txt`;
|
||
const destination = `/${username}/Documents/move-over-dst.txt`;
|
||
await putFile(actor, source, 'fresh');
|
||
await putFile(actor, destination, 'stale');
|
||
const moving =
|
||
(await server.stores.fsEntry.getEntryByPath(source))!;
|
||
const replaced =
|
||
(await server.stores.fsEntry.getEntryByPath(destination))!;
|
||
|
||
const emitSpy = vi.spyOn(server.clients.event, 'emit');
|
||
let captured: Awaited<ReturnType<typeof dispatch>>;
|
||
let moved: Record<string, unknown> | undefined;
|
||
let removed: Record<string, unknown> | undefined;
|
||
try {
|
||
captured = await dispatch({
|
||
method: 'MOVE',
|
||
path: source,
|
||
actor,
|
||
headers: { destination, host: 'dav.puter.localhost' },
|
||
});
|
||
const calls = emitSpy.mock.calls;
|
||
const movedAt = () =>
|
||
guiEventIndex(calls, 'outer.gui.item.moved', moving.uuid);
|
||
await vi.waitFor(() =>
|
||
expect(movedAt()).toBeGreaterThanOrEqual(0),
|
||
);
|
||
const removedAt = guiEventIndex(
|
||
calls,
|
||
'outer.gui.item.removed',
|
||
replaced.uuid,
|
||
);
|
||
// Removal first: the desktop hides rows by path.
|
||
expect(removedAt).toBeGreaterThanOrEqual(0);
|
||
expect(removedAt).toBeLessThan(movedAt());
|
||
moved = guiResponse(calls, movedAt());
|
||
removed = guiResponse(calls, removedAt);
|
||
} finally {
|
||
emitSpy.mockRestore();
|
||
}
|
||
expect(captured.statusCode).toBe(204);
|
||
expect(moved).toMatchObject({
|
||
path: destination,
|
||
old_path: source,
|
||
});
|
||
expect(removed?.path).toBe(destination);
|
||
});
|
||
|
||
it('returns 412 when the destination exists and Overwrite is F', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/move-nf-src.txt`;
|
||
const destination = `/${username}/Documents/move-nf-dst.txt`;
|
||
await putFile(actor, source, 'a');
|
||
await putFile(actor, destination, 'b');
|
||
|
||
const captured = await dispatch({
|
||
method: 'MOVE',
|
||
path: source,
|
||
actor,
|
||
headers: {
|
||
destination,
|
||
overwrite: 'F',
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
});
|
||
expect(captured.statusCode).toBe(412);
|
||
});
|
||
|
||
it('returns 404 when the source is missing', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const captured = await dispatch({
|
||
method: 'MOVE',
|
||
path: `/${username}/Documents/gone.txt`,
|
||
actor,
|
||
headers: {
|
||
destination: `/${username}/Documents/elsewhere.txt`,
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
});
|
||
expect(captured.statusCode).toBe(404);
|
||
expect(captured.body).toBe('Not Found');
|
||
});
|
||
|
||
it("denies moving into another user's home", async () => {
|
||
const owner = await makeUser();
|
||
const stranger = await makeUser();
|
||
const source = `/${stranger.username}/Documents/mine.txt`;
|
||
await putFile(stranger.actor, source, 'x');
|
||
|
||
const captured = await dispatch({
|
||
method: 'MOVE',
|
||
path: source,
|
||
actor: stranger.actor,
|
||
headers: {
|
||
destination: `/${owner.username}/Documents/yours.txt`,
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
});
|
||
expect(captured.statusCode).toBe(404);
|
||
});
|
||
});
|
||
|
||
describe('locking interaction', () => {
|
||
const lockPath = async (actor: unknown, path: string) => {
|
||
const captured = await dispatch({ method: 'LOCK', path, actor });
|
||
expect(captured.statusCode).toBe(200);
|
||
const token = /<D:href>(urn:uuid:[0-9a-f-]+)<\/D:href>/.exec(
|
||
captured.body as string,
|
||
)?.[1];
|
||
expect(token).toBeTruthy();
|
||
return { token: token!, headers: captured.headers };
|
||
};
|
||
|
||
it('advertises the lock token in the Lock-Token header', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/lock-header.txt`;
|
||
const { token, headers } = await lockPath(actor, path);
|
||
expect(headers['lock-token']).toBe(`<${token}>`);
|
||
});
|
||
|
||
it('grants a shared lock when the body asks for one', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/shared-lock.txt`;
|
||
const captured = await dispatch({
|
||
method: 'LOCK',
|
||
path,
|
||
actor,
|
||
body: { lockinfo: { lockscope: { shared: {} } } },
|
||
});
|
||
expect(captured.statusCode).toBe(200);
|
||
expect(captured.body as string).toContain('<D:shared/>');
|
||
});
|
||
|
||
it('allows a second shared lock on the same path', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/shared-twice.txt`;
|
||
const body = { lockinfo: { lockscope: { shared: {} } } };
|
||
expect(
|
||
(await dispatch({ method: 'LOCK', path, actor, body }))
|
||
.statusCode,
|
||
).toBe(200);
|
||
expect(
|
||
(await dispatch({ method: 'LOCK', path, actor, body }))
|
||
.statusCode,
|
||
).toBe(200);
|
||
});
|
||
|
||
it('returns 412 when refreshing an unknown lock token', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const captured = await dispatch({
|
||
method: 'LOCK',
|
||
path: `/${username}/Documents/refresh-unknown.txt`,
|
||
actor,
|
||
headers: {
|
||
if: '(<urn:uuid:22222222-2222-2222-2222-222222222222>)',
|
||
},
|
||
});
|
||
expect(captured.statusCode).toBe(412);
|
||
expect(captured.body).toBe('Lock token not found');
|
||
});
|
||
|
||
it('blocks PUT, DELETE, MKCOL and PROPPATCH on a locked path with 423', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/locked-target.txt`;
|
||
await putFile(actor, path, 'v1');
|
||
await lockPath(actor, path);
|
||
|
||
for (const method of ['PUT', 'DELETE', 'PROPPATCH']) {
|
||
const captured = await dispatch({
|
||
method,
|
||
path,
|
||
actor,
|
||
headers: { 'content-length': '2' },
|
||
...(method === 'PUT' ? { content: 'v2' } : {}),
|
||
});
|
||
expect(captured.statusCode).toBe(423);
|
||
expect(captured.body).toBe('Locked');
|
||
}
|
||
|
||
const collection = `/${username}/Documents/locked-collection`;
|
||
await lockPath(actor, collection);
|
||
const mkcol = await dispatch({
|
||
method: 'MKCOL',
|
||
path: collection,
|
||
actor,
|
||
});
|
||
expect(mkcol.statusCode).toBe(423);
|
||
});
|
||
|
||
it('lets the lock holder write through with its If token', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/lock-and-write.txt`;
|
||
await putFile(actor, path, 'v1');
|
||
const { token } = await lockPath(actor, path);
|
||
|
||
const captured = await dispatch({
|
||
method: 'PUT',
|
||
path,
|
||
actor,
|
||
headers: {
|
||
'content-length': '2',
|
||
if: `(<${token}>)`,
|
||
},
|
||
content: 'v2',
|
||
});
|
||
expect(captured.statusCode).toBe(204);
|
||
});
|
||
|
||
it('blocks COPY onto a locked destination with 423', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const source = `/${username}/Documents/copy-locked-src.txt`;
|
||
const destination = `/${username}/Documents/copy-locked-dst.txt`;
|
||
await putFile(actor, source, 'a');
|
||
await lockPath(actor, destination);
|
||
|
||
const captured = await dispatch({
|
||
method: 'COPY',
|
||
path: source,
|
||
actor,
|
||
headers: { destination, host: 'dav.puter.localhost' },
|
||
});
|
||
expect(captured.statusCode).toBe(423);
|
||
});
|
||
|
||
it('releases the lock on UNLOCK and lets writes through again', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const path = `/${username}/Documents/unlock-me.txt`;
|
||
await putFile(actor, path, 'v1');
|
||
const { token } = await lockPath(actor, path);
|
||
|
||
const unlocked = await dispatch({
|
||
method: 'UNLOCK',
|
||
path,
|
||
actor,
|
||
headers: { 'lock-token': `<${token}>` },
|
||
});
|
||
expect(unlocked.statusCode).toBe(204);
|
||
|
||
const written = await dispatch({
|
||
method: 'PUT',
|
||
path,
|
||
actor,
|
||
headers: { 'content-length': '2' },
|
||
content: 'v2',
|
||
});
|
||
expect(written.statusCode).toBe(204);
|
||
});
|
||
|
||
it('refuses to UNLOCK a token minted for a different path', async () => {
|
||
const { actor, username } = await makeUser();
|
||
const locked = `/${username}/Documents/other-lock.txt`;
|
||
const { token } = await lockPath(actor, locked);
|
||
|
||
const captured = await dispatch({
|
||
method: 'UNLOCK',
|
||
path: `/${username}/Documents/somewhere-else.txt`,
|
||
actor,
|
||
headers: { 'lock-token': `<${token}>` },
|
||
});
|
||
expect(captured.statusCode).toBe(403);
|
||
expect(captured.body).toBe('Lock token does not match this path');
|
||
});
|
||
});
|
||
|
||
describe('shared-path masking', () => {
|
||
/** A `Secrets/plan.txt` folder in `owner`'s Documents, shared with `to`. */
|
||
const shareFolder = async (
|
||
owner: Awaited<ReturnType<typeof makeUser>>,
|
||
to: Awaited<ReturnType<typeof makeUser>>,
|
||
) => {
|
||
const dirUuid = uuidv4();
|
||
const dirPath = `/${owner.username}/Documents/Secrets`;
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const parent = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${owner.username}/Documents`,
|
||
))!;
|
||
await server.clients.db.write(
|
||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
|
||
[
|
||
dirUuid,
|
||
'Secrets',
|
||
dirPath,
|
||
owner.userId,
|
||
now,
|
||
parent.id,
|
||
parent.uuid,
|
||
],
|
||
);
|
||
const dir = (await server.stores.fsEntry.getEntryByPath(dirPath))!;
|
||
await server.clients.db.write(
|
||
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`, `parent_id`, `parent_uid`) VALUES (?, ?, ?, ?, 0, ?, ?, ?)',
|
||
[
|
||
uuidv4(),
|
||
'plan.txt',
|
||
`${dirPath}/plan.txt`,
|
||
owner.userId,
|
||
now,
|
||
dir.id,
|
||
dir.uuid,
|
||
],
|
||
);
|
||
|
||
await runWithContext({ actor: owner.actor as never }, () =>
|
||
server.services.share.share(owner.actor as never, {
|
||
uid: dirUuid,
|
||
recipient: { username: to.username! },
|
||
mode: 'read',
|
||
}),
|
||
);
|
||
return { dirUuid, dirPath };
|
||
};
|
||
|
||
/**
|
||
* PROPFIND under the ALS wrap a real request gets, pre-auth actor
|
||
* empty.
|
||
*/
|
||
const propfind = async (path: string, actor: unknown, depth = '1') => {
|
||
const { res, captured } = makeRes();
|
||
await runWithContext({ actor: undefined }, () =>
|
||
dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
path,
|
||
headers: { depth },
|
||
actor,
|
||
}),
|
||
res,
|
||
noop,
|
||
),
|
||
);
|
||
return captured;
|
||
};
|
||
|
||
// The DAV controller authenticates in-controller, after the request
|
||
// context snapshotted an empty `req.actor` — so it has to place the
|
||
// actor into the context itself or outbound masking silently turns
|
||
// off and PROPFIND lists the owner's real paths.
|
||
it('lists a shared folder under its masked path, not the owner’s real one', async () => {
|
||
const owner = await makeUser();
|
||
const recipient = await makeUser();
|
||
const { dirUuid } = await shareFolder(owner, recipient);
|
||
|
||
const masked = `/${owner.username}/${dirUuid}/Secrets`;
|
||
const captured = await propfind(masked, recipient.actor);
|
||
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = String(captured.body);
|
||
expect(xml).toContain(`${masked}/plan.txt`);
|
||
expect(xml).not.toContain('/Documents/Secrets');
|
||
});
|
||
|
||
it('lists each sharer as a collection under the root', async () => {
|
||
const owner = await makeUser();
|
||
const recipient = await makeUser();
|
||
await shareFolder(owner, recipient);
|
||
|
||
const captured = await propfind('/', recipient.actor);
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = String(captured.body);
|
||
// The recipient's own home, plus the owner who shared with them.
|
||
expect(xml).toContain(`<D:href>/${recipient.username}/</D:href>`);
|
||
expect(xml).toContain(`<D:href>/${owner.username}/</D:href>`);
|
||
});
|
||
|
||
it('omits the sharers at depth 0', async () => {
|
||
const owner = await makeUser();
|
||
const recipient = await makeUser();
|
||
await shareFolder(owner, recipient);
|
||
|
||
const captured = await propfind('/', recipient.actor, '0');
|
||
expect(captured.statusCode).toBe(207);
|
||
expect(String(captured.body)).not.toContain(`/${owner.username}/`);
|
||
});
|
||
|
||
it('answers a sharer’s collection with the shares they issued', async () => {
|
||
const owner = await makeUser();
|
||
const recipient = await makeUser();
|
||
const { dirUuid } = await shareFolder(owner, recipient);
|
||
|
||
const captured = await propfind(
|
||
`/${owner.username}`,
|
||
recipient.actor,
|
||
);
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = String(captured.body);
|
||
expect(xml).toContain(`<D:href>/${owner.username}/</D:href>`);
|
||
// The `<uuid>` level, which the share root hangs off. It borrows
|
||
// the shared item's name so a client has something to show.
|
||
expect(xml).toContain(
|
||
`<D:href>/${owner.username}/${dirUuid}/</D:href>`,
|
||
);
|
||
expect(xml).toContain('<D:displayname>Secrets</D:displayname>');
|
||
// Nothing else of the owner's shows through — not their home
|
||
// directory's other children, not the folder the share sits in.
|
||
expect(xml).not.toContain('Documents');
|
||
});
|
||
|
||
it('keeps a sharer’s collection out of sight for a caller with no share', async () => {
|
||
const owner = await makeUser();
|
||
const stranger = await makeUser();
|
||
await shareFolder(owner, await makeUser());
|
||
|
||
const captured = await propfind(
|
||
`/${owner.username}`,
|
||
stranger.actor,
|
||
);
|
||
// The same 404 as a name nobody holds, so it confirms no account.
|
||
expect(captured.statusCode).toBe(404);
|
||
});
|
||
});
|
||
|
||
// The virtual collections invent two levels of path that no fsentry backs.
|
||
// Everything here checks that they only ever widen to what was actually
|
||
// shared — the rest of the owner's tree has to stay unreachable and
|
||
// unnamed.
|
||
describe('shared-space containment', () => {
|
||
/**
|
||
* `owner` shares `Shared/` (holding `in.txt`) with `to`, and keeps
|
||
* `Private/` (holding `out.txt`) to themselves.
|
||
*/
|
||
const shareOneOfTwo = async (
|
||
owner: Awaited<ReturnType<typeof makeUser>>,
|
||
to: Awaited<ReturnType<typeof makeUser>>,
|
||
) => {
|
||
const mk = async (
|
||
method: string,
|
||
path: string,
|
||
extra: Record<string, string> = {},
|
||
) => {
|
||
const { res, captured } = makeRes();
|
||
await runWithContext({ actor: undefined }, () =>
|
||
dispatchMiddleware(
|
||
makeReq({
|
||
method,
|
||
path,
|
||
actor: owner.actor,
|
||
headers: extra,
|
||
}),
|
||
res,
|
||
noop,
|
||
),
|
||
);
|
||
return captured;
|
||
};
|
||
await mk('MKCOL', `/${owner.username}/Documents/Shared`);
|
||
await mk('MKCOL', `/${owner.username}/Documents/Private`);
|
||
|
||
const shared = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${owner.username}/Documents/Shared`,
|
||
))!;
|
||
const private_ = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${owner.username}/Documents/Private`,
|
||
))!;
|
||
|
||
await runWithContext({ actor: owner.actor as never }, () =>
|
||
server.services.share.share(owner.actor as never, {
|
||
uid: shared.uuid,
|
||
recipient: { username: to.username! },
|
||
mode: 'read',
|
||
}),
|
||
);
|
||
return { shared, private: private_ };
|
||
};
|
||
|
||
const propfind = async (path: string, actor: unknown, depth = '1') => {
|
||
const { res, captured } = makeRes();
|
||
await runWithContext({ actor: undefined }, () =>
|
||
dispatchMiddleware(
|
||
makeReq({
|
||
method: 'PROPFIND',
|
||
path,
|
||
headers: { depth },
|
||
actor,
|
||
}),
|
||
res,
|
||
noop,
|
||
),
|
||
);
|
||
return captured;
|
||
};
|
||
|
||
it('lists only the items that were shared, not the owner’s siblings', async () => {
|
||
const owner = await makeUser();
|
||
const holder = await makeUser();
|
||
const { shared, private: hidden } = await shareOneOfTwo(
|
||
owner,
|
||
holder,
|
||
);
|
||
|
||
const captured = await propfind(`/${owner.username}`, holder.actor);
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = String(captured.body);
|
||
expect(xml).toContain(shared.uuid);
|
||
expect(xml).not.toContain(hidden.uuid);
|
||
expect(xml).not.toContain('Private');
|
||
});
|
||
|
||
it('does not recurse past the uuid level at Depth: infinity', async () => {
|
||
const owner = await makeUser();
|
||
const holder = await makeUser();
|
||
const { shared } = await shareOneOfTwo(owner, holder);
|
||
|
||
const captured = await propfind(
|
||
`/${owner.username}`,
|
||
holder.actor,
|
||
'infinity',
|
||
);
|
||
expect(captured.statusCode).toBe(207);
|
||
const xml = String(captured.body);
|
||
// The share's own href lives one segment deeper; asking for the
|
||
// world must not walk into it.
|
||
expect(xml).toContain(`/${shared.uuid}/`);
|
||
expect(xml).not.toContain(`/${shared.uuid}/Shared`);
|
||
});
|
||
|
||
it('refuses every way out of a share', async () => {
|
||
const owner = await makeUser();
|
||
const holder = await makeUser();
|
||
const { shared, private: hidden } = await shareOneOfTwo(
|
||
owner,
|
||
holder,
|
||
);
|
||
const root = `/${owner.username}/${shared.uuid}/Shared`;
|
||
|
||
for (const escape of [
|
||
// Up and sideways out of the shared subtree.
|
||
`${root}/../Private`,
|
||
`${root}/../../Documents`,
|
||
// Percent-encoded, because the path is decoded before it is
|
||
// resolved — the guard has to sit after that, not before.
|
||
`${root}/%2e%2e/Private`,
|
||
// The owner's real path, named directly rather than walked to.
|
||
`/${owner.username}/Documents/Private`,
|
||
`/${owner.username}/Documents`,
|
||
// A mask pointing at an entry the holder was never given —
|
||
// both a bad tail under a uuid they do hold, and the uuid of
|
||
// one they don't.
|
||
`/${owner.username}/${shared.uuid}/Private`,
|
||
`/${owner.username}/${hidden.uuid}`,
|
||
`/${owner.username}/${hidden.uuid}/Private`,
|
||
]) {
|
||
const captured = await propfind(escape, holder.actor);
|
||
expect(captured.statusCode, escape).toBeGreaterThanOrEqual(400);
|
||
expect(captured.statusCode, escape).toBeLessThan(500);
|
||
expect(String(captured.body), escape).not.toContain('Private');
|
||
}
|
||
});
|
||
|
||
it('does not leak one holder’s shares to another', async () => {
|
||
const owner = await makeUser();
|
||
const holder = await makeUser();
|
||
const outsider = await makeUser();
|
||
await shareOneOfTwo(owner, holder);
|
||
|
||
const captured = await propfind('/', outsider.actor);
|
||
expect(captured.statusCode).toBe(207);
|
||
expect(String(captured.body)).not.toContain(owner.username);
|
||
});
|
||
|
||
it('drops the sharer once the share is revoked', async () => {
|
||
const owner = await makeUser();
|
||
const holder = await makeUser();
|
||
const { shared } = await shareOneOfTwo(owner, holder);
|
||
|
||
expect(String((await propfind('/', holder.actor)).body)).toContain(
|
||
`/${owner.username}/`,
|
||
);
|
||
|
||
await runWithContext({ actor: owner.actor as never }, () =>
|
||
server.services.share.unshare(owner.actor as never, {
|
||
uid: shared.uuid,
|
||
recipient: { username: holder.username! },
|
||
}),
|
||
);
|
||
|
||
const root = await propfind('/', holder.actor);
|
||
expect(String(root.body)).not.toContain(`/${owner.username}/`);
|
||
// And the level it used to stand in for is out of sight again.
|
||
const owned = await propfind(`/${owner.username}`, holder.actor);
|
||
expect(owned.statusCode).toBe(404);
|
||
});
|
||
|
||
it('refuses writes and reads aimed at the invented levels', async () => {
|
||
const owner = await makeUser();
|
||
const holder = await makeUser();
|
||
const { shared } = await shareOneOfTwo(owner, holder);
|
||
|
||
const attempt = async (method: string, path: string) => {
|
||
const { res, captured } = makeRes();
|
||
await runWithContext({ actor: undefined }, () =>
|
||
dispatchMiddleware(
|
||
makeReq({
|
||
method,
|
||
path,
|
||
actor: holder.actor,
|
||
headers: { 'content-length': '3' },
|
||
}),
|
||
res,
|
||
noop,
|
||
),
|
||
);
|
||
return captured.statusCode;
|
||
};
|
||
|
||
// A collection, so GET is a 400 either way — what matters is that
|
||
// neither the owner level nor the uuid level accepts a write.
|
||
expect(await attempt('GET', `/${owner.username}`)).not.toBe(200);
|
||
expect(
|
||
await attempt('MKCOL', `/${owner.username}/intruder`),
|
||
).toBeGreaterThanOrEqual(400);
|
||
expect(
|
||
await attempt(
|
||
'MKCOL',
|
||
`/${owner.username}/${shared.uuid}/intruder`,
|
||
),
|
||
).toBeGreaterThanOrEqual(400);
|
||
expect(
|
||
await attempt('DELETE', `/${owner.username}/${shared.uuid}`),
|
||
).toBeGreaterThanOrEqual(400);
|
||
|
||
// Nothing landed in the owner's tree.
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(
|
||
`/${owner.username}/intruder`,
|
||
),
|
||
).toBeFalsy();
|
||
});
|
||
});
|
||
|
||
// Every check runs on the path the entry is stored under, and a refusal of
|
||
// something the caller can't see reads exactly like a path with nothing
|
||
// at it.
|
||
describe('authorization against the stored entry', () => {
|
||
it('answers a stranger alike for a missing path and a private file', async () => {
|
||
const owner = await makeUser();
|
||
const stranger = await makeUser();
|
||
await putFile(
|
||
owner.actor,
|
||
`/${owner.username}/Documents/private.txt`,
|
||
'secret',
|
||
);
|
||
|
||
const answers = [];
|
||
for (const [method, path] of [
|
||
['GET', `/${owner.username}/Documents/private.txt`],
|
||
['GET', `/${owner.username}/Documents/missing.txt`],
|
||
// A directory too: refused before the "is a directory" 400.
|
||
['GET', `/${owner.username}/Documents`],
|
||
['PROPFIND', `/${owner.username}`],
|
||
['PROPFIND', `/no-such-user-${uuidv4()}`],
|
||
['DELETE', `/${owner.username}/Documents/private.txt`],
|
||
['DELETE', `/${owner.username}/Documents/missing.txt`],
|
||
] as const) {
|
||
const captured = await dispatch({
|
||
method,
|
||
path,
|
||
actor: stranger.actor,
|
||
});
|
||
answers.push([captured.statusCode, captured.body]);
|
||
}
|
||
for (const answer of answers) {
|
||
expect(answer).toEqual([404, 'Not Found']);
|
||
}
|
||
});
|
||
|
||
// MySQL collates `fsentries.path` case-insensitively, so a request for
|
||
// `/<owner>/Public/...` can resolve a private `/<owner>/public/...`.
|
||
// The test databases compare exactly, so the lookup is made to answer
|
||
// the way MySQL would; everything past it — the ancestor chain, the
|
||
// ACL — runs for real.
|
||
it('does not pass a case variant of Public off as the public folder', async () => {
|
||
const owner = await makeUser();
|
||
const stranger = await makeUser();
|
||
await server.stores.user.update(owner.userId, {
|
||
email_confirmed: 1,
|
||
});
|
||
const privateDir = `/${owner.username}/public`;
|
||
const mkcol = await dispatch({
|
||
method: 'MKCOL',
|
||
path: privateDir,
|
||
actor: owner.actor,
|
||
});
|
||
expect(mkcol.statusCode).toBe(201);
|
||
await putFile(owner.actor, `${privateDir}/secret.txt`, 'secret');
|
||
await putFile(
|
||
owner.actor,
|
||
`/${owner.username}/Public/open.txt`,
|
||
'open',
|
||
);
|
||
|
||
const acl = server.services.acl as unknown as {
|
||
config: { enable_public_folders?: boolean };
|
||
};
|
||
const wasEnabled = acl.config.enable_public_folders;
|
||
acl.config.enable_public_folders = true;
|
||
try {
|
||
// The real Public folder is open to the stranger.
|
||
const open = await dispatch({
|
||
method: 'GET',
|
||
path: `/${owner.username}/Public/open.txt`,
|
||
actor: stranger.actor,
|
||
});
|
||
expect(open.statusCode).toBe(200);
|
||
|
||
const store = server.stores.fsEntry;
|
||
const realLookup = store.getEntryByPath.bind(store);
|
||
const asked = `/${owner.username}/Public`;
|
||
vi.spyOn(store, 'getEntryByPath').mockImplementation(
|
||
(path: string) =>
|
||
realLookup(
|
||
path === asked || path.startsWith(`${asked}/`)
|
||
? privateDir + path.slice(asked.length)
|
||
: path,
|
||
),
|
||
);
|
||
|
||
const { res, captured } = makeStreamRes();
|
||
await dispatchMiddleware(
|
||
makeReq({
|
||
method: 'GET',
|
||
path: `${asked}/secret.txt`,
|
||
actor: stranger.actor,
|
||
}),
|
||
res,
|
||
noop,
|
||
);
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
expect(captured.statusCode).toBe(404);
|
||
expect(captured.text()).toBe('');
|
||
|
||
const listing = await dispatch({
|
||
method: 'PROPFIND',
|
||
path: asked,
|
||
actor: stranger.actor,
|
||
});
|
||
expect(listing.statusCode).toBe(404);
|
||
expect(String(listing.body)).not.toContain('secret.txt');
|
||
|
||
const copied = await dispatch({
|
||
method: 'COPY',
|
||
path: `${asked}/secret.txt`,
|
||
actor: stranger.actor,
|
||
headers: {
|
||
destination: `/${stranger.username}/Documents/loot.txt`,
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
});
|
||
expect(copied.statusCode).toBe(404);
|
||
} finally {
|
||
vi.restoreAllMocks();
|
||
acl.config.enable_public_folders = wasEnabled;
|
||
}
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(
|
||
`/${stranger.username}/Documents/loot.txt`,
|
||
),
|
||
).toBeFalsy();
|
||
});
|
||
});
|
||
|
||
// A scoped token writes over DAV wherever its grant reaches, and nowhere
|
||
// else — the same reach it has over the `/fs` write routes.
|
||
describe('scoped access tokens', () => {
|
||
const mintFor = async (userId: number, permission: string) => {
|
||
const user = (await server.stores.user.getById(userId))!;
|
||
return server.services.auth.createAccessToken(makeActor({ user }), [
|
||
[permission],
|
||
]);
|
||
};
|
||
// Own address, so the per-IP auth failure bucket can't refuse these.
|
||
const asToken = (token: string, init: Parameters<typeof dispatch>[0]) =>
|
||
dispatch({
|
||
...init,
|
||
ip: '203.0.113.58',
|
||
headers: {
|
||
...init.headers,
|
||
authorization: basicAuth('-token', token),
|
||
},
|
||
});
|
||
|
||
it('writes inside its grant and is refused outside it', async () => {
|
||
const { userId, username, actor } = await makeUser();
|
||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${username}/Documents`,
|
||
))!;
|
||
const token = await mintFor(userId, `fs:${documents.uuid}:write`);
|
||
const inside = `/${username}/Documents/tok`;
|
||
|
||
expect(
|
||
(await asToken(token, { method: 'MKCOL', path: inside }))
|
||
.statusCode,
|
||
).toBe(201);
|
||
expect(
|
||
(
|
||
await asToken(token, {
|
||
method: 'PUT',
|
||
path: `${inside}/a.txt`,
|
||
headers: { 'content-length': '3' },
|
||
content: 'abc',
|
||
})
|
||
).statusCode,
|
||
).toBe(201);
|
||
expect(
|
||
(
|
||
await asToken(token, {
|
||
method: 'MOVE',
|
||
path: `${inside}/a.txt`,
|
||
headers: {
|
||
destination: `${inside}/b.txt`,
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
})
|
||
).statusCode,
|
||
).toBe(201);
|
||
expect(
|
||
(
|
||
await asToken(token, {
|
||
method: 'DELETE',
|
||
path: `${inside}/b.txt`,
|
||
})
|
||
).statusCode,
|
||
).toBe(204);
|
||
|
||
await putFile(actor, `/${username}/Desktop/kept.txt`, 'kept');
|
||
for (const init of [
|
||
{ method: 'MKCOL', path: `/${username}/Desktop/intruder` },
|
||
{
|
||
method: 'PUT',
|
||
path: `/${username}/Desktop/kept.txt`,
|
||
headers: { 'content-length': '1' },
|
||
content: 'x',
|
||
},
|
||
{ method: 'DELETE', path: `/${username}/Desktop/kept.txt` },
|
||
{
|
||
method: 'COPY',
|
||
path: `/${username}/Desktop/kept.txt`,
|
||
headers: {
|
||
destination: `${inside}/kept.txt`,
|
||
host: 'dav.puter.localhost',
|
||
},
|
||
},
|
||
{ method: 'DELETE', path: `/${username}` },
|
||
]) {
|
||
const captured = await asToken(token, init);
|
||
// Outside the grant is out of sight, so it reads as missing.
|
||
expect(captured.statusCode, init.method).toBe(404);
|
||
}
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(
|
||
`/${username}/Desktop/intruder`,
|
||
),
|
||
).toBeFalsy();
|
||
expect(
|
||
await server.stores.fsEntry.getEntryByPath(
|
||
`/${username}/Desktop/kept.txt`,
|
||
),
|
||
).toMatchObject({ size: 4 });
|
||
});
|
||
|
||
it('refuses every write to a read-only token', async () => {
|
||
const { userId, username } = await makeUser();
|
||
const documents = (await server.stores.fsEntry.getEntryByPath(
|
||
`/${username}/Documents`,
|
||
))!;
|
||
const token = await mintFor(userId, `fs:${documents.uuid}:read`);
|
||
|
||
for (const init of [
|
||
{ method: 'MKCOL', path: `/${username}/Documents/ro` },
|
||
{
|
||
method: 'PUT',
|
||
path: `/${username}/Documents/ro.txt`,
|
||
headers: { 'content-length': '1' },
|
||
content: 'x',
|
||
},
|
||
{ method: 'DELETE', path: `/${username}/Documents` },
|
||
]) {
|
||
const captured = await asToken(token, init);
|
||
expect(captured.statusCode, init.method).toBe(403);
|
||
}
|
||
});
|
||
});
|
||
});
|