mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
1056 lines
40 KiB
TypeScript
1056 lines
40 KiB
TypeScript
/**
|
|
* The WebDAV adapter for Nextcloud and OpenCloud: what it puts on the wire, and
|
|
* what it makes of what comes back.
|
|
*
|
|
* The cases here are the ones that cost real debugging against the two
|
|
* products, so they are pinned rather than described: Nextcloud's `OC-FileId`
|
|
* header is not its `oc:fileid` and a naive adapter re-uploads every file
|
|
* forever; `OC-Checksum: SHA256` is fine on Nextcloud and a hard 400 on
|
|
* OpenCloud; a PROPFIND answers one propstat block per status and the 404 block
|
|
* is not data; an OpenCloud drive describes its own WebDAV URL on a host the
|
|
* admin never entered.
|
|
*
|
|
* `safeFetch` and the capped readers are mocked, for the reason
|
|
* dawarich.client.test.ts gives: the SSRF guard does a real DNS lookup and the
|
|
* readers want a real stream. The responses are genuine `Response` objects, so
|
|
* headers, status and body behave as they do in production.
|
|
*/
|
|
import type {
|
|
DocumentConnectionRef,
|
|
DocumentScopeRef,
|
|
PushRequest,
|
|
} from '../../../../src/nest/doc-sync/document-provider';
|
|
import { WebdavClient } from '../../../../src/nest/doc-sync/providers/webdav.client';
|
|
import {
|
|
NextcloudDocumentProvider,
|
|
OpencloudDocumentProvider,
|
|
flavorOf,
|
|
} from '../../../../src/nest/doc-sync/providers/webdav.provider';
|
|
import { SsrfBlockedError } from '../../../../src/utils/ssrfGuard';
|
|
|
|
import { Readable } from 'node:stream';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() }));
|
|
|
|
vi.mock('../../../../src/utils/ssrfGuard', () => ({
|
|
safeFetch: safeFetchMock,
|
|
SsrfBlockedError: class SsrfBlockedError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'SsrfBlockedError';
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock('../../../../src/utils/cappedFetch', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../../../src/utils/cappedFetch')>()),
|
|
readCappedText: async (res: Response) => {
|
|
const text = await res.text();
|
|
return { text: text.slice(0, capBytes), truncated: text.length > capBytes };
|
|
},
|
|
readCappedJson: async (res: Response) => {
|
|
try {
|
|
return JSON.parse(await res.text()) as unknown;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
},
|
|
}));
|
|
|
|
/** Raised by the truncation test only; everything else stays under the cap. */
|
|
let capBytes = 1_000_000;
|
|
|
|
const NC_ORIGIN = 'https://cloud.example.org';
|
|
const OC_ORIGIN = 'https://opencloud.example.org';
|
|
const NC_ROOT = '/remote.php/dav/files/admin';
|
|
const OC_DRIVE = 'storage-1$space-9';
|
|
const OC_ROOT = `/dav/spaces/${OC_DRIVE}`;
|
|
|
|
const ncConn: DocumentConnectionRef = {
|
|
connectionId: 7,
|
|
createdAt: '2026-09-01 08:00:00',
|
|
ownerId: 3,
|
|
baseUrl: NC_ORIGIN,
|
|
secrets: { app_password: 'app-pw' },
|
|
settings: { login_name: 'admin', base_path: '/TREK' },
|
|
allowInsecureTls: false,
|
|
};
|
|
|
|
const ocConn: DocumentConnectionRef = {
|
|
connectionId: 8,
|
|
createdAt: '2026-09-01 08:00:00',
|
|
ownerId: 3,
|
|
baseUrl: OC_ORIGIN,
|
|
secrets: { app_token: 'app-token' },
|
|
settings: { username: 'admin' },
|
|
allowInsecureTls: true,
|
|
};
|
|
|
|
const ncScope: DocumentScopeRef = {
|
|
linkId: 1,
|
|
tripId: 42,
|
|
scopeKey: 'fileid:60',
|
|
remoteRootId: '60',
|
|
remoteRootPath: '/TREK/trip 42',
|
|
cursor: null,
|
|
};
|
|
|
|
const ocScope: DocumentScopeRef = {
|
|
linkId: 2,
|
|
tripId: 42,
|
|
scopeKey: `drive:${OC_DRIVE}`,
|
|
remoteRootId: OC_DRIVE,
|
|
remoteRootPath: `${OC_ORIGIN}${OC_ROOT}`,
|
|
cursor: null,
|
|
};
|
|
|
|
interface Recorded {
|
|
url: string;
|
|
method: string;
|
|
headers: Record<string, string>;
|
|
body: string | undefined;
|
|
rejectUnauthorized: boolean | undefined;
|
|
}
|
|
|
|
let calls: Recorded[] = [];
|
|
|
|
/** `METHOD /path` → the answer, or a function of the request. */
|
|
type Answer = Response | ((req: Recorded) => Response);
|
|
let routes: Map<string, Answer[]>;
|
|
|
|
function route(key: string, ...answers: Answer[]): void {
|
|
routes.set(key, answers);
|
|
}
|
|
|
|
function reply(body: string | null, init: ResponseInit = {}): Response {
|
|
return new Response(body, init);
|
|
}
|
|
|
|
/** A PROPFIND answer built from the props each entry declares. */
|
|
function multistatus(
|
|
entries: Array<{
|
|
href: string;
|
|
collection?: boolean;
|
|
etag?: string;
|
|
fileid?: string;
|
|
size?: number;
|
|
type?: string;
|
|
modified?: string;
|
|
checksums?: string;
|
|
}>,
|
|
): Response {
|
|
const body = entries
|
|
.map((entry) => {
|
|
const found: string[] = [];
|
|
const missing: string[] = [];
|
|
found.push(`<d:getetag>${entry.etag ?? '"etag"'}</d:getetag>`);
|
|
found.push(`<d:getlastmodified>${entry.modified ?? 'Fri, 18 Sep 2026 15:15:54 GMT'}</d:getlastmodified>`);
|
|
found.push(entry.collection ? '<d:resourcetype><d:collection/></d:resourcetype>' : '<d:resourcetype/>');
|
|
if (entry.fileid) found.push(`<oc:fileid>${entry.fileid}</oc:fileid>`);
|
|
if (entry.size === undefined) missing.push('<d:getcontentlength/>');
|
|
else found.push(`<d:getcontentlength>${entry.size}</d:getcontentlength>`);
|
|
if (entry.type === undefined) missing.push('<d:getcontenttype/>');
|
|
else found.push(`<d:getcontenttype>${entry.type}</d:getcontenttype>`);
|
|
if (entry.checksums === undefined) missing.push('<oc:checksums/>');
|
|
else found.push(`<oc:checksums><oc:checksum>${entry.checksums}</oc:checksum></oc:checksums>`);
|
|
|
|
const blocks = [
|
|
`<d:propstat><d:prop>${found.join('')}</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>`,
|
|
];
|
|
if (missing.length > 0) {
|
|
blocks.push(
|
|
`<d:propstat><d:prop>${missing.join('')}</d:prop><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat>`,
|
|
);
|
|
}
|
|
return `<d:response><d:href>${entry.href}</d:href>${blocks.join('')}</d:response>`;
|
|
})
|
|
.join('');
|
|
|
|
return reply(
|
|
`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">${body}</d:multistatus>`,
|
|
{ status: 207, headers: { 'content-type': 'application/xml' } },
|
|
);
|
|
}
|
|
|
|
function drive(id: string, name: string, driveType: string, webDavUrl: string): unknown {
|
|
return { id, name, driveType, root: { id, webDavUrl } };
|
|
}
|
|
|
|
function requests(method?: string): Recorded[] {
|
|
return method ? calls.filter((call) => call.method === method) : calls;
|
|
}
|
|
|
|
function pathOf(call: Recorded): string {
|
|
return new URL(call.url).pathname;
|
|
}
|
|
|
|
function pushRequest(overrides: Partial<PushRequest> = {}): PushRequest {
|
|
return {
|
|
body: Readable.from([Buffer.from('bytes')]),
|
|
fileName: 'reiseplan.pdf',
|
|
mimeType: 'application/pdf',
|
|
size: 5,
|
|
sha256: 'a'.repeat(64),
|
|
mtimeSeconds: 1_700_000_000,
|
|
trekDocUid: 'doc-1',
|
|
trekTripUid: 'trip-1',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
let nextcloud: NextcloudDocumentProvider;
|
|
let opencloud: OpencloudDocumentProvider;
|
|
|
|
beforeEach(() => {
|
|
capBytes = 1_000_000;
|
|
calls = [];
|
|
routes = new Map();
|
|
const client = new WebdavClient();
|
|
nextcloud = new NextcloudDocumentProvider(client);
|
|
opencloud = new OpencloudDocumentProvider(client);
|
|
|
|
safeFetchMock.mockImplementation(
|
|
async (url: string, init: RequestInit, options?: { rejectUnauthorized?: boolean }) => {
|
|
const headers: Record<string, string> = {};
|
|
new Headers(init.headers).forEach((value, key) => {
|
|
headers[key] = value;
|
|
});
|
|
const record: Recorded = {
|
|
url,
|
|
method: init.method ?? 'GET',
|
|
headers,
|
|
body: typeof init.body === 'string' ? init.body : undefined,
|
|
rejectUnauthorized: options?.rejectUnauthorized,
|
|
};
|
|
calls.push(record);
|
|
|
|
const key = `${record.method} ${new URL(url).pathname}`;
|
|
const queued = routes.get(key);
|
|
if (!queued || queued.length === 0) {
|
|
return reply('<d:error/>', { status: 404 });
|
|
}
|
|
const answer = queued.length > 1 ? queued.shift() : queued[0];
|
|
if (!answer) return reply(null, { status: 404 });
|
|
return typeof answer === 'function' ? answer(record) : answer.clone();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('flavour detection', () => {
|
|
it('reads the product off the connection rather than off the registered id', () => {
|
|
expect(flavorOf(ncConn)).toBe('nextcloud');
|
|
expect(flavorOf(ocConn)).toBe('opencloud');
|
|
expect(flavorOf({ ...ncConn, settings: {}, secrets: {} })).toBeNull();
|
|
});
|
|
|
|
it('answers the same capabilities whichever class asks, because the connection decides', () => {
|
|
expect(nextcloud.capabilities(ocConn).push).toBe('none');
|
|
expect(opencloud.capabilities(ncConn).push).toBe('webhook-manual');
|
|
});
|
|
|
|
it('never claims a content hash it cannot stand behind', () => {
|
|
const caps = nextcloud.capabilities(ncConn);
|
|
expect(caps.contentHashInListing).toBe(false);
|
|
expect(caps.stableId).toBe(true);
|
|
expect(caps.remoteTrash).toBe(true);
|
|
expect(caps.replaceInPlace).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('probe', () => {
|
|
it('checks the WebDAV home and upgrades push when the account may manage webhooks', async () => {
|
|
route(`PROPFIND ${NC_ROOT}`, multistatus([{ href: NC_ROOT, collection: true, fileid: '1' }]));
|
|
route('GET /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{"ocs":{"data":[]}}'));
|
|
|
|
const result = await nextcloud.probe(ncConn);
|
|
|
|
expect(result).toMatchObject({ success: true, data: { account: 'admin' } });
|
|
expect(result.success && result.data.capabilities.push).toBe('webhook-self-registered');
|
|
expect(calls[0].headers.authorization).toBe(`Basic ${Buffer.from('admin:app-pw').toString('base64')}`);
|
|
});
|
|
|
|
it('keeps push at webhook-manual when the webhook endpoint is closed to this account', async () => {
|
|
route(`PROPFIND ${NC_ROOT}`, multistatus([{ href: NC_ROOT, collection: true, fileid: '1' }]));
|
|
route('GET /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 }));
|
|
|
|
const result = await nextcloud.probe(ncConn);
|
|
|
|
expect(result.success && result.data.capabilities.push).toBe('webhook-manual');
|
|
});
|
|
|
|
it('separates a wrong login name from a wrong password', async () => {
|
|
route(`PROPFIND ${NC_ROOT}`, reply('<d:error/>', { status: 404 }));
|
|
const missing = await nextcloud.probe(ncConn);
|
|
expect(missing).toMatchObject({ success: false, error: { code: 'not_found' } });
|
|
expect(missing.success === false && missing.error.detail).toContain('admin');
|
|
|
|
calls = [];
|
|
route(`PROPFIND ${NC_ROOT}`, reply('<d:error/>', { status: 401 }));
|
|
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'unauthorized' } });
|
|
});
|
|
|
|
it('names the OpenCloud account from Graph and passes the TLS opt-in through', async () => {
|
|
route('GET /graph/v1.0/me', reply('{"onPremisesSamAccountName":"admin","displayName":"Admin"}'));
|
|
|
|
const result = await opencloud.probe(ocConn);
|
|
|
|
expect(result).toMatchObject({ success: true, data: { account: 'admin' } });
|
|
expect(result.success && result.data.capabilities.push).toBe('none');
|
|
expect(calls[0].rejectUnauthorized).toBe(false);
|
|
});
|
|
|
|
it('refuses a half-filled connection without going near the network', async () => {
|
|
const result = await nextcloud.probe({ ...ncConn, secrets: {}, settings: { login_name: 'admin' } });
|
|
|
|
expect(result).toMatchObject({ success: false, error: { code: 'unauthorized' } });
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('scopes', () => {
|
|
it('offers the folders under the base path and keys them by oc:fileid', async () => {
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK`,
|
|
multistatus([
|
|
{ href: `${NC_ROOT}/TREK/`, collection: true, fileid: '59' },
|
|
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60' },
|
|
{ href: `${NC_ROOT}/TREK/notes.txt`, fileid: '61', size: 3, type: 'text/plain' },
|
|
]),
|
|
);
|
|
|
|
const result = await nextcloud.listScopes(ncConn);
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
data: [{ scopeKey: 'fileid:60', label: 'trip 42', remoteRootId: '60', remoteRootPath: '/TREK/trip 42' }],
|
|
});
|
|
});
|
|
|
|
it('answers an empty picker rather than an error when the base folder does not exist yet', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK`, reply('<d:error/>', { status: 404 }));
|
|
|
|
expect(await nextcloud.listScopes(ncConn)).toEqual({ success: true, data: [] });
|
|
});
|
|
|
|
it('filters the picker by the query', async () => {
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK`,
|
|
multistatus([
|
|
{ href: `${NC_ROOT}/TREK/`, collection: true, fileid: '59' },
|
|
{ href: `${NC_ROOT}/TREK/Japan/`, collection: true, fileid: '60' },
|
|
{ href: `${NC_ROOT}/TREK/Peru/`, collection: true, fileid: '61' },
|
|
]),
|
|
);
|
|
|
|
const result = await nextcloud.listScopes(ncConn, 'jap');
|
|
|
|
expect(result.success && result.data.map((option) => option.label)).toEqual(['Japan']);
|
|
});
|
|
|
|
it('re-bases an OpenCloud space on the entered origin and drops the virtual share drive', async () => {
|
|
route(
|
|
'GET /graph/v1.0/me/drives',
|
|
reply(
|
|
JSON.stringify({
|
|
value: [
|
|
drive('shares$1', 'Shares', 'virtual', 'https://internal.lan:9200/dav/spaces/shares$1'),
|
|
drive(OC_DRIVE, 'TREK Trip 42', 'project', `https://internal.lan:9200${OC_ROOT}`),
|
|
],
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = await opencloud.listScopes(ocConn);
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
data: [
|
|
{
|
|
scopeKey: `drive:${OC_DRIVE}`,
|
|
label: 'TREK Trip 42',
|
|
remoteRootId: OC_DRIVE,
|
|
remoteRootPath: `${OC_ORIGIN}${OC_ROOT}`,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('creates the base folder when it is missing and reads the new id back', async () => {
|
|
route(`MKCOL ${NC_ROOT}/TREK/Japan%202026`, reply(null, { status: 409 }), reply(null, { status: 201 }));
|
|
route(`MKCOL ${NC_ROOT}/TREK`, reply(null, { status: 201 }));
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/Japan%202026`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/Japan%202026/`, collection: true, fileid: '73' }]),
|
|
);
|
|
|
|
const result = await nextcloud.createScope(ncConn, 'Japan 2026');
|
|
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
data: { scopeKey: 'fileid:73', remoteRootPath: '/TREK/Japan 2026' },
|
|
});
|
|
expect(requests('MKCOL').map(pathOf)).toEqual([
|
|
`${NC_ROOT}/TREK/Japan%202026`,
|
|
`${NC_ROOT}/TREK`,
|
|
`${NC_ROOT}/TREK/Japan%202026`,
|
|
]);
|
|
});
|
|
|
|
it('refuses to hand back a folder somebody else already made', async () => {
|
|
route(`MKCOL ${NC_ROOT}/TREK/Japan`, reply(null, { status: 405 }));
|
|
|
|
expect(await nextcloud.createScope(ncConn, 'Japan')).toMatchObject({
|
|
success: false,
|
|
error: { code: 'conflict' },
|
|
});
|
|
});
|
|
|
|
it('replaces the characters Nextcloud refuses instead of failing the whole folder', async () => {
|
|
route(`MKCOL ${NC_ROOT}/TREK/Japan-%202026`, reply(null, { status: 201 }));
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/Japan-%202026`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/Japan-%202026/`, collection: true, fileid: '74' }]),
|
|
);
|
|
|
|
const result = await nextcloud.createScope(ncConn, 'Japan: 2026');
|
|
|
|
expect(result.success && result.data.label).toBe('Japan- 2026');
|
|
});
|
|
|
|
it('creates an OpenCloud space as a project drive', async () => {
|
|
route(
|
|
'POST /graph/v1.0/drives',
|
|
reply(
|
|
JSON.stringify(
|
|
drive('storage-1$new', 'Japan', 'project', `https://internal.lan:9200/dav/spaces/storage-1$new`),
|
|
),
|
|
{
|
|
status: 201,
|
|
},
|
|
),
|
|
);
|
|
|
|
const result = await opencloud.createScope(ocConn, 'Japan');
|
|
|
|
expect(result).toMatchObject({ success: true, data: { scopeKey: 'drive:storage-1$new' } });
|
|
expect(JSON.parse(calls[0].body ?? '{}')).toEqual({ name: 'Japan', driveType: 'project' });
|
|
});
|
|
|
|
it('calls a folder with the same path but a different id scope_missing', async () => {
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '99' }]),
|
|
);
|
|
|
|
const result = await nextcloud.resolveScope(ncConn, ncScope);
|
|
|
|
expect(result).toMatchObject({ success: false, error: { code: 'scope_missing' } });
|
|
expect(result.success === false && result.error.detail).toContain('99');
|
|
});
|
|
|
|
it('reports a deleted space as scope_missing rather than as a broken connection', async () => {
|
|
route(`GET /graph/v1.0/drives/${encodeURIComponent(OC_DRIVE)}`, reply('{"error":{}}', { status: 404 }));
|
|
|
|
expect(await opencloud.resolveScope(ocConn, ocScope)).toMatchObject({
|
|
success: false,
|
|
error: { code: 'scope_missing' },
|
|
});
|
|
});
|
|
|
|
it('rejects a scope key that belongs to another provider', async () => {
|
|
const result = await nextcloud.list(ncConn, { ...ncScope, scopeKey: 'drive:storage$x' });
|
|
|
|
expect(result).toMatchObject({ success: false, error: { code: 'scope_missing' } });
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('list', () => {
|
|
const listing = () =>
|
|
multistatus([
|
|
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-1"' },
|
|
{
|
|
href: `${NC_ROOT}/TREK/trip%2042/sub/`,
|
|
collection: true,
|
|
fileid: '62',
|
|
etag: '"sub-1"',
|
|
},
|
|
{
|
|
href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`,
|
|
fileid: '61',
|
|
etag: '"doc-1"',
|
|
size: 1234,
|
|
type: 'application/pdf',
|
|
modified: 'Tue, 14 Nov 2023 22:13:20 GMT',
|
|
checksums: `SHA256:${'b'.repeat(64)}`,
|
|
},
|
|
]);
|
|
|
|
it('enumerates documents only, never the root and never a sub-folder', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, listing());
|
|
|
|
const result = await nextcloud.list(ncConn, ncScope);
|
|
|
|
expect(result.success && result.data.documents).toEqual([
|
|
{
|
|
remoteId: '61',
|
|
name: 'pass.pdf',
|
|
size: 1234,
|
|
mimeType: 'application/pdf',
|
|
remoteVersion: '"doc-1"',
|
|
contentHash: 'b'.repeat(64),
|
|
remoteModifiedAt: '2023-11-14T22:13:20.000Z',
|
|
isDeleted: false,
|
|
},
|
|
]);
|
|
expect(result.success && result.data.cursor).toBe('"root-1"');
|
|
expect(result.success && result.data.truncated).toBe(false);
|
|
});
|
|
|
|
it('skips the walk entirely when the root ETag has not moved', async () => {
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-1"' }]),
|
|
);
|
|
|
|
const result = await nextcloud.list(ncConn, { ...ncScope, cursor: '"root-1"' });
|
|
|
|
expect(result).toEqual({
|
|
success: true,
|
|
data: { documents: [], cursor: '"root-1"', cursorUnchanged: true, truncated: false },
|
|
});
|
|
expect(calls).toHaveLength(1);
|
|
expect(calls[0].headers.depth).toBe('0');
|
|
});
|
|
|
|
it('walks after the cursor probe shows a different root ETag', async () => {
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-2"' }]),
|
|
listing(),
|
|
);
|
|
|
|
const result = await nextcloud.list(ncConn, { ...ncScope, cursor: '"root-1"' });
|
|
|
|
expect(result.success && result.data.cursorUnchanged).toBe(false);
|
|
expect(calls.map((call) => call.headers.depth)).toEqual(['0', '1']);
|
|
});
|
|
|
|
it('reads a checksum only when it really is a SHA256, so OpenCloud reports none', async () => {
|
|
route(
|
|
`PROPFIND ${OC_ROOT}`,
|
|
multistatus([
|
|
{ href: `${OC_ROOT}/`, collection: true, fileid: `${OC_DRIVE}!root`, etag: '"root"' },
|
|
{
|
|
href: `${OC_ROOT}/pass.pdf`,
|
|
fileid: `${OC_DRIVE}!doc`,
|
|
etag: '"doc"',
|
|
size: 10,
|
|
type: 'application/pdf',
|
|
checksums: 'SHA1:2117 MD5:eebe ADLER32:5f9f',
|
|
},
|
|
]),
|
|
);
|
|
|
|
const result = await opencloud.list(ocConn, ocScope);
|
|
|
|
expect(result.success && result.data.documents[0]).toMatchObject({
|
|
remoteId: `${OC_DRIVE}!doc`,
|
|
contentHash: null,
|
|
});
|
|
});
|
|
|
|
it('reports a listing cut off at the size cap instead of failing the run', async () => {
|
|
capBytes = 620;
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, listing());
|
|
|
|
const result = await nextcloud.list(ncConn, ncScope);
|
|
|
|
expect(result.success && result.data.truncated).toBe(true);
|
|
expect(result.success && result.data.documents.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
it('turns a vanished folder into scope_missing rather than an empty trip', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, reply('<d:error/>', { status: 404 }));
|
|
|
|
expect(await nextcloud.list(ncConn, ncScope)).toMatchObject({
|
|
success: false,
|
|
error: { code: 'scope_missing' },
|
|
});
|
|
});
|
|
|
|
it('keeps a document without an oc:fileid addressable instead of dropping it', async () => {
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
|
|
multistatus([
|
|
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' },
|
|
{ href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`, etag: '"doc"', size: 1, type: 'application/pdf' },
|
|
]),
|
|
);
|
|
|
|
const result = await nextcloud.list(ncConn, ncScope);
|
|
|
|
expect(result.success && result.data.documents[0].remoteId).toBe('path:pass.pdf');
|
|
});
|
|
});
|
|
|
|
describe('push', () => {
|
|
const uploaded = (req: Recorded) =>
|
|
reply(null, {
|
|
status: 201,
|
|
headers: { 'oc-fileid': '00000062oc9fy1g5e2cj', 'oc-etag': `"${req.headers['x-oc-mtime']}"` },
|
|
});
|
|
|
|
it('pins the modification time and the checksum, and normalises the id Nextcloud answers with', async () => {
|
|
route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, uploaded);
|
|
route(
|
|
`PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, etag: '"x"' }]),
|
|
);
|
|
|
|
const result = await nextcloud.push(ncConn, ncScope, pushRequest());
|
|
|
|
const put = requests('PUT')[0];
|
|
expect(put.headers['x-oc-mtime']).toBe('1700000000');
|
|
expect(put.headers['oc-checksum']).toBe(`SHA256:${'a'.repeat(64)}`);
|
|
expect(put.headers['content-length']).toBe('5');
|
|
// The header spells the id 00000062oc9fy1g5e2cj; the listing spells it 62,
|
|
// and a mismatch here is a duplicate document on every run.
|
|
expect(result).toMatchObject({
|
|
success: true,
|
|
data: { remoteId: '62', remoteModifiedAt: '2023-11-14T22:13:20.000Z', deduplicated: false },
|
|
});
|
|
});
|
|
|
|
it('never sends a SHA256 checksum to OpenCloud, which answers 400 to one', async () => {
|
|
route(
|
|
`PUT ${OC_ROOT}/reiseplan.pdf`,
|
|
reply(null, { status: 201, headers: { 'oc-fileid': 'd1', 'oc-etag': '"e1"' } }),
|
|
);
|
|
route(`PROPPATCH ${OC_ROOT}/reiseplan.pdf`, multistatus([{ href: `${OC_ROOT}/reiseplan.pdf`, etag: '"e1"' }]));
|
|
|
|
await opencloud.push(ocConn, ocScope, pushRequest());
|
|
|
|
expect(requests('PUT')[0].headers['oc-checksum']).toBeUndefined();
|
|
expect(requests('PUT')[0].headers['x-oc-mtime']).toBe('1700000000');
|
|
});
|
|
|
|
it('writes the TREK anchors as dead properties without letting a refusal undo the upload', async () => {
|
|
route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, uploaded);
|
|
route(`PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply('<d:error/>', { status: 403 }));
|
|
|
|
const result = await nextcloud.push(ncConn, ncScope, pushRequest());
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(requests('PROPPATCH')[0].body).toContain('doc-1');
|
|
expect(requests('PROPPATCH')[0].body).toContain('trip-1');
|
|
});
|
|
|
|
it('asks for the id and version a proxy stripped from the response', async () => {
|
|
route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply(null, { status: 201 }));
|
|
route(
|
|
`PROPPATCH ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, etag: '"x"' }]),
|
|
);
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, fileid: '62', etag: '"stored"', size: 5 }]),
|
|
);
|
|
|
|
const result = await nextcloud.push(ncConn, ncScope, pushRequest());
|
|
|
|
expect(result).toMatchObject({ success: true, data: { remoteId: '62', remoteVersion: '"stored"' } });
|
|
});
|
|
|
|
it('sends If-Match when the core knows which version it is replacing, and reports 412 as a conflict', async () => {
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
|
|
multistatus([
|
|
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' },
|
|
{ href: `${NC_ROOT}/TREK/trip%2042/pass.pdf`, fileid: '61', etag: '"doc-1"', size: 1, type: 'application/pdf' },
|
|
]),
|
|
);
|
|
route(`PUT ${NC_ROOT}/TREK/trip%2042/pass.pdf`, reply('<d:error/>', { status: 412 }));
|
|
|
|
const result = await nextcloud.push(
|
|
ncConn,
|
|
ncScope,
|
|
pushRequest({ remoteId: '61', expectedRemoteVersion: '"doc-1"', fileName: 'pass.pdf' }),
|
|
);
|
|
|
|
expect(requests('PUT')[0].headers['if-match']).toBe('"doc-1"');
|
|
expect(result).toMatchObject({ success: false, error: { code: 'conflict', status: 412 } });
|
|
});
|
|
|
|
it('refuses to recreate a document that disappeared upstream', async () => {
|
|
route(
|
|
`PROPFIND ${NC_ROOT}/TREK/trip%2042`,
|
|
multistatus([{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' }]),
|
|
);
|
|
|
|
const result = await nextcloud.push(ncConn, ncScope, pushRequest({ remoteId: '61' }));
|
|
|
|
expect(result).toMatchObject({ success: false, error: { code: 'not_found' } });
|
|
expect(requests('PUT')).toHaveLength(0);
|
|
});
|
|
|
|
it('maps a full quota to quota_exceeded rather than a generic failure', async () => {
|
|
route(`PUT ${NC_ROOT}/TREK/trip%2042/reiseplan.pdf`, reply('<d:error/>', { status: 507 }));
|
|
|
|
expect(await nextcloud.push(ncConn, ncScope, pushRequest())).toMatchObject({
|
|
success: false,
|
|
error: { code: 'quota_exceeded' },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('fetch, rename and trash', () => {
|
|
const folder = () =>
|
|
multistatus([
|
|
{ href: `${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root"' },
|
|
{
|
|
href: `${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`,
|
|
fileid: '61',
|
|
etag: '"doc-1"',
|
|
size: 5,
|
|
type: 'application/pdf',
|
|
},
|
|
]);
|
|
|
|
it('streams a document back and reports the version it came with', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
|
|
route(
|
|
`GET ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`,
|
|
reply('the bytes', { headers: { 'content-length': '9', 'content-type': 'application/pdf', etag: '"doc-1"' } }),
|
|
);
|
|
|
|
const result = await nextcloud.fetch(ncConn, ncScope, '61');
|
|
|
|
expect(result.success).toBe(true);
|
|
if (!result.success) return;
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of result.data.body) chunks.push(Buffer.from(chunk));
|
|
expect(Buffer.concat(chunks).toString()).toBe('the bytes');
|
|
expect(result.data).toMatchObject({ size: 9, mimeType: 'application/pdf', remoteVersion: '"doc-1"' });
|
|
});
|
|
|
|
it('re-uses the listing it already walked instead of re-walking per document', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
|
|
route(`GET ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply('x', { headers: { etag: '"doc-1"' } }));
|
|
|
|
await nextcloud.list(ncConn, ncScope);
|
|
await nextcloud.fetch(ncConn, ncScope, '61');
|
|
|
|
expect(requests('PROPFIND')).toHaveLength(1);
|
|
});
|
|
|
|
it('refuses a download announcing more than TREK transfers, and one that arrives without a body', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
|
|
route(
|
|
`GET ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`,
|
|
reply('x', { headers: { 'content-length': String(3 * 1024 ** 3) } }),
|
|
reply(null, { status: 200 }),
|
|
);
|
|
|
|
expect(await nextcloud.fetch(ncConn, ncScope, '61')).toMatchObject({
|
|
success: false,
|
|
error: { code: 'too_large', status: 200 },
|
|
});
|
|
expect(await nextcloud.fetch(ncConn, ncScope, '61')).toMatchObject({
|
|
success: false,
|
|
error: { code: 'provider_error', status: 200 },
|
|
});
|
|
});
|
|
|
|
it('says not_found for an id the folder no longer holds', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
|
|
|
|
expect(await nextcloud.fetch(ncConn, ncScope, '999')).toMatchObject({
|
|
success: false,
|
|
error: { code: 'not_found' },
|
|
});
|
|
});
|
|
|
|
it('renames inside the same folder and refuses to overwrite a namesake', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
|
|
route(
|
|
`MOVE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`,
|
|
reply(null, { status: 201, headers: { 'oc-etag': '"doc-2"' } }),
|
|
);
|
|
|
|
const result = await nextcloud.rename(ncConn, ncScope, '61', 'Reisepass 2026.pdf');
|
|
|
|
const move = requests('MOVE')[0];
|
|
expect(move.headers.destination).toBe(`${NC_ORIGIN}${NC_ROOT}/TREK/trip%2042/Reisepass%202026.pdf`);
|
|
expect(move.headers.overwrite).toBe('F');
|
|
expect(result).toEqual({ success: true, data: { remoteVersion: '"doc-2"' } });
|
|
});
|
|
|
|
it('reports a rename onto an existing name as a conflict', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
|
|
route(`MOVE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`, reply('<d:error/>', { status: 412 }));
|
|
|
|
expect(await nextcloud.rename(ncConn, ncScope, '61', 'anders.pdf')).toMatchObject({
|
|
success: false,
|
|
error: { code: 'conflict' },
|
|
});
|
|
});
|
|
|
|
it('deletes into the recycle bin and treats an already-deleted document as done', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, folder());
|
|
route(
|
|
`DELETE ${NC_ROOT}/TREK/trip%2042/pass%20scan.pdf`,
|
|
reply(null, { status: 204 }),
|
|
reply('<d:error/>', { status: 404 }),
|
|
);
|
|
|
|
expect(await nextcloud.trash(ncConn, ncScope, '61')).toEqual({ success: true, data: undefined });
|
|
expect(await nextcloud.trash(ncConn, ncScope, '61')).toEqual({ success: true, data: undefined });
|
|
});
|
|
});
|
|
|
|
describe('webhooks', () => {
|
|
const OCS = '/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks';
|
|
|
|
it('registers one unfiltered subscription per event, form-encoded, with the secret as a header', async () => {
|
|
route(`GET ${OCS}`, reply('{"ocs":{"data":[]}}'));
|
|
let next = 10;
|
|
route(`POST ${OCS}`, () => reply(JSON.stringify({ ocs: { data: { id: next++ } } })));
|
|
|
|
const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's3cret');
|
|
|
|
expect(result).toEqual({ success: true, data: { subscriptionId: '10,11,12,13' } });
|
|
const bodies = requests('POST').map((call) => new URLSearchParams(call.body ?? ''));
|
|
expect(requests('POST')[0].headers['content-type']).toBe('application/x-www-form-urlencoded');
|
|
expect(bodies[0].get('httpMethod')).toBe('POST');
|
|
expect(bodies[0].get('uri')).toBe('https://trek.example/hook');
|
|
expect(bodies[0].get('authMethod')).toBe('header');
|
|
// The header the endpoint actually reads. It used to be registered as
|
|
// `X-TREK-Docsync-Signature` while doc-sync-webhook.controller looks for
|
|
// `x-trek-docsync-secret`, so every webhook Nextcloud sent was dropped
|
|
// without a trace: the subscription existed and the calls arrived.
|
|
expect(bodies[0].get('authData[x-trek-docsync-secret]')).toBe('s3cret');
|
|
// A $regex filter is not merely ignored upstream: it throws inside the
|
|
// listener and stops delivery for every other webhook on that event.
|
|
expect(bodies.every((body) => [...body.keys()].every((key) => !key.startsWith('eventFilter')))).toBe(true);
|
|
expect(bodies.map((body) => body.get('event'))).toEqual([
|
|
'OCP\\Files\\Events\\Node\\NodeCreatedEvent',
|
|
'OCP\\Files\\Events\\Node\\NodeWrittenEvent',
|
|
'OCP\\Files\\Events\\Node\\NodeDeletedEvent',
|
|
'OCP\\Files\\Events\\Node\\NodeRenamedEvent',
|
|
]);
|
|
});
|
|
|
|
it('answers a non-admin account with forbidden and leaves the link polling', async () => {
|
|
route(`GET ${OCS}`, reply('{}', { status: 403 }));
|
|
|
|
const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's');
|
|
|
|
expect(result).toMatchObject({ success: false, error: { code: 'forbidden' } });
|
|
expect(requests('POST')).toHaveLength(0);
|
|
});
|
|
|
|
it('rolls back the subscriptions it already made when a later one fails', async () => {
|
|
route(`GET ${OCS}`, reply('{"ocs":{"data":[]}}'));
|
|
route(
|
|
`POST ${OCS}`,
|
|
reply(JSON.stringify({ ocs: { data: { id: 10 } } })),
|
|
reply(JSON.stringify({ ocs: { data: { id: 11 } } })),
|
|
reply('<ocs/>', { status: 500 }),
|
|
);
|
|
route(`DELETE ${OCS}/10`, reply(null, { status: 200 }));
|
|
route(`DELETE ${OCS}/11`, reply(null, { status: 200 }));
|
|
|
|
const result = await nextcloud.registerWebhook(ncConn, ncScope, 'https://trek.example/hook', 's');
|
|
|
|
expect(result).toMatchObject({ success: false, error: { code: 'provider_error' } });
|
|
expect(requests('DELETE').map(pathOf)).toEqual([`${OCS}/10`, `${OCS}/11`]);
|
|
});
|
|
|
|
it('tells an OpenCloud admin there is nothing to subscribe to instead of failing the connection', async () => {
|
|
const result = await opencloud.registerWebhook(ocConn, ocScope, 'https://trek.example/hook', 's');
|
|
|
|
expect(result).toMatchObject({ success: false, error: { code: 'forbidden' } });
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it('removes every subscription the id list names, tolerating one that is already gone', async () => {
|
|
route(`DELETE ${OCS}/10`, reply(null, { status: 200 }));
|
|
route(`DELETE ${OCS}/11`, reply('<ocs/>', { status: 404 }));
|
|
|
|
expect(await nextcloud.unregisterWebhook(ncConn, '10,11')).toEqual({ success: true, data: undefined });
|
|
expect(requests('DELETE')).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe('an install below a path', () => {
|
|
const NC_PREFIXED = 'https://home.example.org/nextcloud';
|
|
const OC_PREFIXED = 'https://opencloud.example.org/oc';
|
|
const prefixedNc: DocumentConnectionRef = { ...ncConn, baseUrl: NC_PREFIXED };
|
|
const prefixedOc: DocumentConnectionRef = { ...ocConn, baseUrl: OC_PREFIXED };
|
|
|
|
it('reaches a Nextcloud under a sub-path instead of calling its address a wrong password', async () => {
|
|
route(
|
|
`PROPFIND /nextcloud${NC_ROOT}`,
|
|
multistatus([{ href: `/nextcloud${NC_ROOT}/`, collection: true, fileid: '1' }]),
|
|
);
|
|
route('GET /nextcloud/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{"ocs":{"data":[]}}'));
|
|
|
|
const result = await nextcloud.probe(prefixedNc);
|
|
|
|
expect(result).toMatchObject({ success: true, data: { account: 'admin' } });
|
|
expect(calls[0].url).toBe(`${NC_PREFIXED}${NC_ROOT}`);
|
|
});
|
|
|
|
it('strips the file-app address people paste and keeps the install path underneath it', async () => {
|
|
route(
|
|
`PROPFIND /nextcloud${NC_ROOT}`,
|
|
multistatus([{ href: `/nextcloud${NC_ROOT}/`, collection: true, fileid: '1' }]),
|
|
);
|
|
route('GET /nextcloud/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 }));
|
|
|
|
const result = await nextcloud.probe({ ...ncConn, baseUrl: `${NC_PREFIXED}/apps/files/files/123?dir=/TREK` });
|
|
|
|
expect(result).toMatchObject({ success: true });
|
|
expect(calls[0].url).toBe(`${NC_PREFIXED}${NC_ROOT}`);
|
|
});
|
|
|
|
it('keeps an install path that happens to contain apps, cutting only the pasted page behind it', async () => {
|
|
const APPS_ROOT = 'https://home.example.org/apps/nextcloud';
|
|
route(
|
|
`PROPFIND /apps/nextcloud${NC_ROOT}`,
|
|
multistatus([{ href: `/apps/nextcloud${NC_ROOT}/`, collection: true, fileid: '1' }]),
|
|
);
|
|
route('GET /apps/nextcloud/ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 }));
|
|
|
|
expect(await nextcloud.probe({ ...ncConn, baseUrl: `${APPS_ROOT}/apps/dashboard/` })).toMatchObject({
|
|
success: true,
|
|
});
|
|
expect(calls[0].url).toBe(`${APPS_ROOT}${NC_ROOT}`);
|
|
|
|
calls = [];
|
|
expect(await nextcloud.probe({ ...ncConn, baseUrl: APPS_ROOT })).toMatchObject({ success: true });
|
|
expect(calls[0].url).toBe(`${APPS_ROOT}${NC_ROOT}`);
|
|
});
|
|
|
|
it('takes the prefix off the hrefs it is answered, so a download is not addressed twice over', async () => {
|
|
route(
|
|
`PROPFIND /nextcloud${NC_ROOT}/TREK/trip%2042`,
|
|
multistatus([
|
|
{ href: `/nextcloud${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60', etag: '"root-1"' },
|
|
{
|
|
href: `/nextcloud${NC_ROOT}/TREK/trip%2042/pass.pdf`,
|
|
fileid: '61',
|
|
etag: '"doc-1"',
|
|
size: 4,
|
|
type: 'application/pdf',
|
|
},
|
|
]),
|
|
);
|
|
route(`GET /nextcloud${NC_ROOT}/TREK/trip%2042/pass.pdf`, reply('pdf!', { headers: { etag: '"doc-1"' } }));
|
|
|
|
const listed = await nextcloud.list(prefixedNc, ncScope);
|
|
expect(listed.success && listed.data.documents.map((doc) => doc.name)).toEqual(['pass.pdf']);
|
|
expect(listed.success && listed.data.cursor).toBe('"root-1"');
|
|
|
|
const fetched = await nextcloud.fetch(prefixedNc, ncScope, '61');
|
|
expect(fetched).toMatchObject({ success: true, data: { remoteVersion: '"doc-1"' } });
|
|
expect(requests('GET').map((call) => call.url)).toEqual([`${NC_PREFIXED}${NC_ROOT}/TREK/trip%2042/pass.pdf`]);
|
|
});
|
|
|
|
it('renames within the prefixed folder and points the Destination at the full base', async () => {
|
|
route(
|
|
`PROPFIND /nextcloud${NC_ROOT}/TREK/trip%2042`,
|
|
multistatus([
|
|
{ href: `/nextcloud${NC_ROOT}/TREK/trip%2042/`, collection: true, fileid: '60' },
|
|
{ href: `/nextcloud${NC_ROOT}/TREK/trip%2042/pass.pdf`, fileid: '61', size: 4, type: 'application/pdf' },
|
|
]),
|
|
);
|
|
route(
|
|
`MOVE /nextcloud${NC_ROOT}/TREK/trip%2042/pass.pdf`,
|
|
reply(null, { status: 201, headers: { 'oc-etag': '"moved"' } }),
|
|
);
|
|
|
|
const result = await nextcloud.rename(prefixedNc, ncScope, '61', 'passport.pdf');
|
|
|
|
expect(result).toEqual({ success: true, data: { remoteVersion: '"moved"' } });
|
|
expect(requests('MOVE')[0].headers.destination).toBe(`${NC_PREFIXED}${NC_ROOT}/TREK/trip%2042/passport.pdf`);
|
|
});
|
|
|
|
it('re-bases an OpenCloud space below a path without spelling the path twice', async () => {
|
|
route(
|
|
'GET /oc/graph/v1.0/me/drives',
|
|
reply(
|
|
JSON.stringify({
|
|
value: [drive(OC_DRIVE, 'TREK Trip 42', 'project', `https://internal.lan:9200/oc${OC_ROOT}`)],
|
|
}),
|
|
),
|
|
);
|
|
route(
|
|
`PROPFIND /oc${OC_ROOT}`,
|
|
multistatus([
|
|
{ href: `/oc${OC_ROOT}/`, collection: true, fileid: `${OC_DRIVE}!root`, etag: '"root"' },
|
|
{ href: `/oc${OC_ROOT}/pass.pdf`, fileid: `${OC_DRIVE}!doc`, etag: '"doc"', size: 10, type: 'application/pdf' },
|
|
]),
|
|
);
|
|
|
|
const scopes = await opencloud.listScopes(prefixedOc);
|
|
expect(scopes.success && scopes.data[0].remoteRootPath).toBe(`${OC_PREFIXED}${OC_ROOT}`);
|
|
|
|
const listed = await opencloud.list(prefixedOc, { ...ocScope, remoteRootPath: `${OC_PREFIXED}${OC_ROOT}` });
|
|
expect(listed.success && listed.data.documents[0]).toMatchObject({ remoteId: `${OC_DRIVE}!doc`, name: 'pass.pdf' });
|
|
expect(requests('PROPFIND').map((call) => call.url)).toEqual([`${OC_PREFIXED}${OC_ROOT}`]);
|
|
});
|
|
|
|
it('leaves a root install exactly as it was', async () => {
|
|
route(`PROPFIND ${NC_ROOT}`, multistatus([{ href: `${NC_ROOT}/`, collection: true, fileid: '1' }]));
|
|
route('GET /ocs/v2.php/apps/webhook_listeners/api/v1/webhooks', reply('{}', { status: 403 }));
|
|
|
|
expect(await nextcloud.probe({ ...ncConn, baseUrl: `${NC_ORIGIN}/index.php/apps/files/` })).toMatchObject({
|
|
success: true,
|
|
});
|
|
expect(calls[0].url).toBe(`${NC_ORIGIN}${NC_ROOT}`);
|
|
});
|
|
});
|
|
|
|
describe('failure classification', () => {
|
|
it('separates a timeout, an untrusted certificate and a blocked host', async () => {
|
|
const timeout = Object.assign(new TypeError('fetch failed'), {
|
|
cause: Object.assign(new Error('The operation was aborted'), { name: 'TimeoutError' }),
|
|
});
|
|
safeFetchMock.mockRejectedValueOnce(timeout);
|
|
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'timeout' } });
|
|
|
|
const tls = Object.assign(new TypeError('fetch failed'), {
|
|
cause: Object.assign(new Error('self-signed certificate'), { code: 'DEPTH_ZERO_SELF_SIGNED_CERT' }),
|
|
});
|
|
safeFetchMock.mockRejectedValueOnce(tls);
|
|
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'tls_untrusted' } });
|
|
|
|
safeFetchMock.mockRejectedValueOnce(new SsrfBlockedError('Requests to loopback are not allowed'));
|
|
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'ssrf_blocked' } });
|
|
|
|
safeFetchMock.mockRejectedValueOnce(new TypeError('fetch failed'));
|
|
expect(await nextcloud.probe(ncConn)).toMatchObject({ success: false, error: { code: 'unreachable' } });
|
|
});
|
|
|
|
it('keeps the upstream text in detail and out of the code', async () => {
|
|
route(`PROPFIND ${NC_ROOT}`, reply('<s:message>Username or password was incorrect</s:message>', { status: 401 }));
|
|
|
|
const result = await nextcloud.probe(ncConn);
|
|
|
|
expect(result).toMatchObject({ success: false, error: { code: 'unauthorized' } });
|
|
expect(result.success === false && result.error.detail).toContain('incorrect');
|
|
});
|
|
|
|
it('refuses a body that is not a multistatus instead of reading it as an empty folder', async () => {
|
|
route(`PROPFIND ${NC_ROOT}/TREK/trip%2042`, reply('<html><body>Please log in</body></html>', { status: 207 }));
|
|
|
|
expect(await nextcloud.list(ncConn, ncScope)).toMatchObject({
|
|
success: false,
|
|
error: { code: 'provider_error' },
|
|
});
|
|
});
|
|
});
|