diff --git a/src/index.ts b/src/index.ts index df1e01fb..93227490 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export { createDirectoryLogic, DEFAULT_DIRECTORY_CATALOG_URL, DEFAULT_DIRECTORY_ export type { AccessControlSubjectKind, AccessControlSubject } from './acl/accessControlSubjects' export type { ACLContext, AccessMode, AccessSubject, Authorization, PatchPlan } from './types' export { ResourceUploadError } from './types' +export type { ResourceCopyCollision, ResourceCopyOptions } from './types' export type { AppDetails, SolidNamespace, AuthenticationContext, SolidLogic, ResourceLogic, ResourceContent, ResourceMetadata, ResourceUploadResult, ChatLogic, DirectoryLogic, DirectoryEntry, DirectorySearchOptions, DirectoryRelationshipLabel, DirectorySource } from './types' export { UnauthorizedError, CrossOriginForbiddenError, SameOriginForbiddenError, NotFoundError, FetchError, NotEditableError, WebOperationError } from './logic/CustomError' diff --git a/src/resource/resourceCopy.ts b/src/resource/resourceCopy.ts new file mode 100644 index 00000000..8abc2e0c --- /dev/null +++ b/src/resource/resourceCopy.ts @@ -0,0 +1,228 @@ +import { NamedNode, sym } from 'rdflib' +import type { ResourceCopyOptions } from '../types' +import { assertSuccessfulHttpResponse } from './resourceHttp' + +export function createResourceCopyLogic (store, containerLogic) { + const isSafeCreationName = (value: string): boolean => + value.length > 0 && + value.trim().length > 0 && + value !== '.' && + value !== '..' && + !/[\/\\\u0000-\u001f\u007f]/.test(value) + + function resourceName (resource: NamedNode): string { + const url = new URL(resource.value) + const segments = url.pathname.split('/').filter(Boolean) + const name = decodeURIComponent(segments[segments.length - 1] || '') + if (!isSafeCreationName(name)) throw new Error('The resource URL does not contain a valid name.') + return name + } + + function copyUrl (value: string): URL { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Only HTTP(S) resource URLs can be copied.') + if (url.search || url.hash || url.username || url.password) { + throw new Error('Copy URLs must not contain a query, fragment, or credentials.') + } + return url + } + + function childUrl (parent: string, name: string, isContainer = false): string { + return `${parent}${encodeURIComponent(name)}${isContainer ? '/' : ''}` + } + + // Use _fetch here so a 412 can be handled as a collision and binary content + // can be passed through unchanged; webOperation rejects non-2xx responses. + async function putResource ( + target: string, + response: Response | undefined, + body: ArrayBuffer | undefined, + condition: Record + ): Promise { + return store.fetcher._fetch(target, { + method: 'PUT', + headers: { + 'Content-Type': response?.headers.get('content-type') || 'application/octet-stream', + ...condition + }, + body + }) + } + + async function recreateContainerAcl (sourceUrl: URL, target: string, members: NamedNode[]): Promise { + const sourceAclUrl = new URL('.acl', sourceUrl.href).href + const sourceAcl = await store.fetcher._fetch(sourceAclUrl) + if (sourceAcl.status === 404) return + assertSuccessfulHttpResponse(sourceAcl, `GET ${sourceAclUrl}`) + const sourceAclText = await sourceAcl.text() + const targetAclUrl = new URL('.acl', target).href + const created = await store.fetcher._fetch(targetAclUrl, { + method: 'PUT', + headers: { + 'Content-Type': sourceAcl.headers.get('content-type') || 'text/turtle', + 'If-None-Match': '*' + }, + body: sourceAclText.replaceAll(sourceUrl.href, target) + }) + assertSuccessfulHttpResponse(created, `PUT ${targetAclUrl}`) + } + + async function copyResourceNode ( + resource: NamedNode, + parent: string, + options: ResourceCopyOptions, + written: string[], + isRoot = false + ): Promise { + const sourceUrl = copyUrl(resource.value) + const folder = containerLogic.isContainer(resource) + if (folder && !sourceUrl.pathname.endsWith('/')) sourceUrl.pathname += '/' + if (folder && (parent === sourceUrl.href || parent.startsWith(sourceUrl.href))) { + throw new Error('A folder cannot be copied into itself or one of its descendants.') + } + + const name = resourceName(sym(sourceUrl.href)) + let target = childUrl(parent, name, folder) + if (target === sourceUrl.href) throw new Error('A resource cannot be copied onto itself.') + const members: NamedNode[] = folder ? await containerLogic.getContainerMembers(resource) : [] + const sourceAclUrl = new URL('.acl', sourceUrl.href).href + const copyMembers = members.filter(member => member.value !== sourceAclUrl) + for (const member of members) { + const memberUrl = copyUrl(member.value) + const relative = memberUrl.href.slice(sourceUrl.href.length).replace(/\/$/, '') + if (!memberUrl.href.startsWith(sourceUrl.href) || !relative || relative.includes('/')) { + throw new Error(`Invalid container member ${member.value} in ${sourceUrl.href}`) + } + } + const response: Response | undefined = folder ? undefined : await store.fetcher._fetch(sourceUrl.href) + if (response) assertSuccessfulHttpResponse(response, `GET ${sourceUrl.href}`) + const body = await response?.arrayBuffer() + return copyResourceWithCollisionHandling({ + sourceUrl, + parent, + name, + target, + folder, + members: copyMembers, + response, + body, + onCollision: isRoot ? options.onCollision : undefined, + written + }) + } + + async function copyResourceWithCollisionHandling ({ + sourceUrl, + parent, + name, + target, + folder, + members, + response, + body, + onCollision, + written + }: { + sourceUrl: URL + parent: string + name: string + target: string + folder: boolean + members: NamedNode[] + response?: Response + body?: ArrayBuffer + onCollision?: ResourceCopyOptions['onCollision'] + written: string[] + }): Promise { + let condition: Record = { 'If-None-Match': '*' } + let collision = false + let targetRecorded = false + if (folder) { + try { + await containerLogic.createContainer(target, { allowExisting: false }) + } catch (error) { + const status = typeof error === 'object' && error !== null && 'status' in error ? error.status : undefined + if (status !== 409 && status !== 412) throw error + collision = true + } + } else { + const created = await putResource(target, response, body, condition) + collision = created.status === 412 + if (!collision) assertSuccessfulHttpResponse(created, `PUT ${target}`) + } + if (!collision && folder) { + written.push(target) + targetRecorded = true + } + if (collision) { + const choice = await onCollision?.({ + source: sourceUrl.href, destination: target, isContainer: folder + }) + if (!choice) throw new Error(`A resource already exists at ${target}`) + if (choice === 'cancel') return undefined + if (choice === 'replace') { + if (folder) throw new Error('Replacing or merging existing folders is not supported. Choose Keep both instead.') + const existing: Response = await store.fetcher._fetch(target, { method: 'HEAD' }) + assertSuccessfulHttpResponse(existing, `HEAD ${target}`) + const etag = existing.headers.get('etag') + if (!etag || etag.startsWith('W/')) throw new Error(`Cannot safely replace ${target}: the server did not provide a strong ETag.`) + condition = { 'If-Match': etag } + } else { + const dot = folder ? -1 : name.lastIndexOf('.') + const stem = dot > 0 ? name.slice(0, dot) : name + const extension = dot > 0 ? name.slice(dot) : '' + target = childUrl(parent, `${stem}-copy-${crypto.randomUUID()}${extension}`, folder) + } + + if (folder) { + await containerLogic.createContainer(target, { allowExisting: false }) + written.push(target) + targetRecorded = true + } else { + const created = await putResource(target, response, body, condition) + assertSuccessfulHttpResponse(created, `PUT ${target}`) + } + } + + if (!targetRecorded) written.push(target) + for (const member of new Map(members.map(member => [member.value, member])).values()) { + await copyResourceNode(member, target, {}, written) + } + if (folder && targetRecorded) await recreateContainerAcl(sourceUrl, target, members) + return target + } + + async function copyTopLevel ( + source: NamedNode, + destination: string | NamedNode, + options: ResourceCopyOptions, + expectedContainer: boolean + ): Promise { + if (containerLogic.isContainer(source) !== expectedContainer) { + const kind = expectedContainer ? 'container' : 'resource' + throw new Error(`Cannot copy ${source.value} as a ${kind}.`) + } + const destinationUrl = copyUrl(typeof destination === 'string' ? destination : destination.value) + if (!destinationUrl.pathname.endsWith('/')) destinationUrl.pathname += '/' + const written: string[] = [] + try { + return await copyResourceNode(source, destinationUrl.href, options, written, true) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + const partial = written.length + ? ` Copy is incomplete; ${written.length} destination resource(s) were written and remain at ${written[0]}.` + : '' + throw new Error(`Copy from ${source.value} to ${destinationUrl.href} failed: ${detail}${partial}`) + } + } + + async function copyResource (source: NamedNode, destination: string | NamedNode, options: ResourceCopyOptions = {}) { + return copyTopLevel(source, destination, options, false) + } + + async function copyContainer (source: NamedNode, destination: string | NamedNode, options: ResourceCopyOptions = {}) { + return copyTopLevel(source, destination, options, true) + } + + return { copyResource, copyContainer } +} diff --git a/src/resource/resourceLogic.ts b/src/resource/resourceLogic.ts index 8b127f4f..62c4f3f9 100644 --- a/src/resource/resourceLogic.ts +++ b/src/resource/resourceLogic.ts @@ -5,10 +5,13 @@ import { assertSuccessfulHttpResponse, isMissingError } from './resourceHttp' import { readWacAccessInfo } from './resourceMetadata' import { ResourceUploadError, type ResourceAccess, type ResourceAccessWithDelete, type ResourceContent, type ResourceDeleteOptions, type ResourceLogic, type ResourceMetadata, type ResourceMetadataWithDelete, type ResourceUploadResult, type TypeIndexLogic } from '../types' import type { AclLogic } from '../types' +import { createResourceCopyLogic } from './resourceCopy' import * as mime from 'mime-types' import * as debug from '../util/debug' export function createResourceLogic(store, aclLogic: AclLogic, containerLogic, typeIndexLogic: TypeIndexLogic): ResourceLogic { + const { copyResource, copyContainer } = createResourceCopyLogic(store, containerLogic) + function uploadFiles ( files: File[], destination: string @@ -364,6 +367,8 @@ export function createResourceLogic(store, aclLogic: AclLogic, containerLogic, t return { uploadFiles, + copyResource, + copyContainer, recursiveDelete, deleteResourceAndTypeIndexIfExists, fetchMetadata, diff --git a/src/types.ts b/src/types.ts index dcd38a95..9609fd82 100644 --- a/src/types.ts +++ b/src/types.ts @@ -174,11 +174,26 @@ export type ResourceContent = { metadata: ResourceMetadata } +export type ResourceCopyCollision = { + source: string + destination: string + isContainer: boolean +} + +export type ResourceCopyOptions = { + /** Folders support keep-both or cancel, never replacement/merging. */ + onCollision?: (collision: ResourceCopyCollision) => Promise<'replace' | 'keep-both' | 'cancel'> +} + export interface ResourceLogic { uploadFiles: ( files: File[], destination: string ) => Promise, + /** Copies without deleting the source. Failures report any destination resources left by a partial copy. */ + copyResource: (source: NamedNode, destinationContainer: string | NamedNode, options?: ResourceCopyOptions) => Promise, + /** Copies a container and its descendants without deleting the source. */ + copyContainer: (source: NamedNode, destinationContainer: string | NamedNode, options?: ResourceCopyOptions) => Promise, recursiveDelete: (resource: NamedNode, options?: ResourceDeleteOptions) => Promise, deleteResourceAndTypeIndexIfExists: (resource: NamedNode, user?: NamedNode | null) => Promise, fetchMetadata: (subject: NamedNode) => Promise, diff --git a/src/util/containerLogic.ts b/src/util/containerLogic.ts index 35d825c9..cb80192f 100644 --- a/src/util/containerLogic.ts +++ b/src/util/containerLogic.ts @@ -29,7 +29,7 @@ export function createContainerLogic(store) { return getContainerElements(containerNode).length } - async function createContainer(url: string) { + async function createContainer(url: string, options: { allowExisting?: boolean } = {}) { const stringToNode = sym(url) if (!isContainer(stringToNode)) { throw new Error(`Not a container URL ${url}`) @@ -45,11 +45,11 @@ export function createContainerLogic(store) { body: ' ', // work around https://github.com/michielbdejong/community-server/issues/4#issuecomment-776222863 }) // Treat 409 as idempotent success: another process/request already created the container. - if (result.status === 409) { + if (result.status === 409 && options.allowExisting !== false) { return } if (result.status.toString()[0] !== '2') { - throw new Error(`Not OK: got ${result.status} response while creating container at ${url}`) + throw Object.assign(new Error(`Not OK: got ${result.status} response while creating container at ${url}`), { status: result.status }) } } diff --git a/test/container.test.ts b/test/container.test.ts index 7225de55..6bb6ddad 100644 --- a/test/container.test.ts +++ b/test/container.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { UpdateManager, Store, Fetcher, sym, NamedNode } from 'rdflib' +import { UpdateManager, Store, Fetcher, sym } from 'rdflib' import { createContainerLogic } from '../src/util/containerLogic' import { alice } from './helpers/dataSetup' @@ -7,7 +7,7 @@ window.$SolidTestEnvironment = { username: alice.uri } describe('Container', () => { let store - let containerLogic: { getContainerMembers: any; isContainer?: (url: NamedNode) => boolean; createContainer?: (url: string) => Promise; getContainerElements?: (containerNode: NamedNode) => NamedNode[] } + let containerLogic: ReturnType beforeEach(() => { fetchMock.resetMocks() store = new Store() @@ -31,6 +31,17 @@ describe('Container', () => { expect(result).toEqual([]) }) + it('preserves idempotent creation for existing callers', async () => { + fetchMock.mockResponseOnce('', { status: 409 }) + await expect(containerLogic.createContainer('https://container.com/')).resolves.toBeUndefined() + }) + + it.each([409, 412])('rejects an existing container in strict creation mode (HTTP %s)', async status => { + fetchMock.mockResponseOnce('', { status }) + await expect(containerLogic.createContainer('https://container.com/', { allowExisting: false })) + .rejects.toMatchObject({ status }) + }) + function containerIsEmpty() { fetchMock.mockOnceIf( 'https://com/', diff --git a/test/resourceLogic.test.ts b/test/resourceLogic.test.ts index ba9fba82..fecb5b1e 100644 --- a/test/resourceLogic.test.ts +++ b/test/resourceLogic.test.ts @@ -108,6 +108,214 @@ describe('resourceLogic', () => { expect(store.removeDocument).toHaveBeenCalledWith(container) }) + it('copies a resource with its content type using conditional creation', async () => { + const resourceLogic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + const source = sym('https://source.example/a.txt') + containerLogic.isContainer.mockReturnValue(false) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('hello', { status: 200, headers: { 'content-type': 'text/plain' } })) + .mockResolvedValueOnce(new Response(null, { status: 201 })) + + await expect(resourceLogic.copyResource(source, 'https://pod.example/files/')) + .resolves.toBe('https://pod.example/files/a.txt') + expect(store.fetcher._fetch).toHaveBeenLastCalledWith('https://pod.example/files/a.txt', expect.objectContaining({ + method: 'PUT', + headers: { 'Content-Type': 'text/plain', 'If-None-Match': '*' }, + body: expect.any(ArrayBuffer) + })) + }) + + it('copies folders recursively without deleting the source', async () => { + const resourceLogic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + const source = sym('https://source.example/folder/') + const file = sym('https://source.example/folder/a.txt') + const nested = sym('https://source.example/folder/nested/') + const nestedFile = sym('https://source.example/folder/nested/b.txt') + containerLogic.isContainer.mockImplementation(resource => resource.value.endsWith('/')) + containerLogic.getContainerMembers + .mockResolvedValueOnce([file, nested]) + .mockResolvedValueOnce([nestedFile]) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('a', { status: 200, headers: { 'content-type': 'text/plain' } })) + .mockResolvedValueOnce(new Response(null, { status: 201 })) + .mockResolvedValueOnce(new Response('b', { status: 200, headers: { 'content-type': 'text/plain' } })) + .mockResolvedValueOnce(new Response(null, { status: 201 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + .mockResolvedValueOnce(new Response(null, { status: 404 })) + + await expect(resourceLogic.copyContainer(source, 'https://pod.example/files/')) + .resolves.toBe('https://pod.example/files/folder/') + expect(containerLogic.createContainer).toHaveBeenNthCalledWith(1, 'https://pod.example/files/folder/', { allowExisting: false }) + expect(containerLogic.createContainer).toHaveBeenNthCalledWith(2, 'https://pod.example/files/folder/nested/', { allowExisting: false }) + expect(store.fetcher._fetch).not.toHaveBeenCalledWith(source.value, { method: 'DELETE' }) + }) + + it('rejects copying a folder into itself or a descendant', async () => { + const resourceLogic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + const source = sym('https://pod.example/folder/') + containerLogic.isContainer.mockReturnValue(true) + + await expect(resourceLogic.copyContainer(source, 'https://pod.example/folder/child/')) + .rejects.toThrow('itself or one of its descendants') + expect(containerLogic.createContainer).not.toHaveBeenCalled() + }) + + it('keeps both files using a fresh name and conditional creation', async () => { + containerLogic.isContainer.mockReturnValue(false) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('source')) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + .mockResolvedValueOnce(new Response(null, { status: 201 })) + const onCollision = vi.fn().mockResolvedValue('keep-both') + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + const target = await logic.copyResource(sym('https://source.example/a.txt'), 'https://pod.example/files/', { onCollision }) + expect(target).toMatch(/^https:\/\/pod.example\/files\/a-copy-[\w-]+\.txt$/) + expect(onCollision).toHaveBeenCalledWith({ + source: 'https://source.example/a.txt', destination: 'https://pod.example/files/a.txt', isContainer: false + }) + expect(store.fetcher._fetch).toHaveBeenLastCalledWith(target, expect.objectContaining({ + headers: expect.objectContaining({ 'If-None-Match': '*' }) + })) + }) + + it('replaces only after confirmation and with the current destination ETag', async () => { + containerLogic.isContainer.mockReturnValue(false) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('source')) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + .mockResolvedValueOnce(new Response(null, { headers: { etag: '"original"' } })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await logic.copyResource(sym('https://source.example/a.txt'), 'https://pod.example/files/', { + onCollision: async () => 'replace' + }) + expect(store.fetcher._fetch).toHaveBeenLastCalledWith('https://pod.example/files/a.txt', expect.objectContaining({ + headers: expect.objectContaining({ 'If-Match': '"original"' }) + })) + }) + + it('does not replace a destination without a strong ETag', async () => { + containerLogic.isContainer.mockReturnValue(false) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('source')) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + .mockResolvedValueOnce(new Response(null)) + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await expect(logic.copyResource(sym('https://source.example/a.txt'), 'https://pod.example/files/', { + onCollision: async () => 'replace' + })).rejects.toThrow('strong ETag') + expect(store.fetcher._fetch).toHaveBeenCalledTimes(3) + }) + + it('cancels without issuing a replacement write', async () => { + containerLogic.isContainer.mockReturnValue(false) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('source')) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await expect(logic.copyResource(sym('https://source.example/a.txt'), 'https://pod.example/files/', { + onCollision: async () => 'cancel' + })).resolves.toBeUndefined() + expect(store.fetcher._fetch).toHaveBeenCalledTimes(2) + }) + + it('does not retry or ask again if replacement loses a race', async () => { + containerLogic.isContainer.mockReturnValue(false) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('source')) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + .mockResolvedValueOnce(new Response(null, { headers: { etag: '"original"' } })) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + const onCollision = vi.fn().mockResolvedValue('replace') + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await expect(logic.copyResource(sym('https://source.example/a.txt'), 'https://pod.example/files/', { onCollision })) + .rejects.toThrow() + expect(onCollision).toHaveBeenCalledTimes(1) + expect(store.fetcher._fetch).toHaveBeenCalledTimes(4) + }) + + it('fails explicitly without a collision policy and never overwrites', async () => { + containerLogic.isContainer.mockReturnValue(false) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('source')) + .mockResolvedValueOnce(new Response(null, { status: 412 })) + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await expect(logic.copyResource(sym('https://source.example/a.txt'), 'https://pod.example/files/')) + .rejects.toThrow('already exists at https://pod.example/files/a.txt') + expect(store.fetcher._fetch).toHaveBeenCalledTimes(2) + }) + + it('does not retry a collided keep-both destination', async () => { + containerLogic.isContainer.mockReturnValue(false) + store.fetcher._fetch + .mockResolvedValueOnce(new Response('source')) + .mockImplementation(async () => new Response(null, { status: 412 })) + const onCollision = vi.fn().mockResolvedValue('keep-both') + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await expect(logic.copyResource(sym('https://source.example/a.txt'), 'https://pod.example/files/', { onCollision })) + .rejects.toThrow() + expect(onCollision).toHaveBeenCalledTimes(1) + expect(store.fetcher._fetch).toHaveBeenCalledTimes(3) + }) + + it('keeps colliding folders separate instead of merging their members', async () => { + containerLogic.isContainer.mockReturnValue(true) + containerLogic.getContainerMembers.mockResolvedValue([]) + containerLogic.createContainer + .mockRejectedValueOnce(Object.assign(new Error('exists'), { status: 409 })) + .mockResolvedValueOnce(undefined) + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + const target = await logic.copyContainer(sym('https://source.example/folder/'), 'https://pod.example/files/', { + onCollision: async () => 'keep-both' + }) + expect(target).toMatch(/\/folder-copy-[\w-]+\/$/) + expect(containerLogic.createContainer).toHaveBeenLastCalledWith(target, { allowExisting: false }) + }) + + it('does not prompt again for a nested collision', async () => { + const nested = sym('https://source.example/folder/nested/') + containerLogic.isContainer.mockReturnValue(true) + containerLogic.getContainerMembers + .mockResolvedValueOnce([nested]) + .mockResolvedValueOnce([]) + containerLogic.createContainer + .mockRejectedValueOnce(Object.assign(new Error('exists'), { status: 409 })) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(Object.assign(new Error('exists'), { status: 409 })) + const onCollision = vi.fn().mockResolvedValue('keep-both') + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + + await expect(logic.copyContainer(sym('https://source.example/folder/'), 'https://pod.example/files/', { onCollision })) + .rejects.toThrow('already exists') + expect(onCollision).toHaveBeenCalledTimes(1) + }) + + it('reports the retained partial destination if a nested copy fails', async () => { + containerLogic.isContainer.mockImplementation(resource => resource.value.endsWith('/')) + containerLogic.getContainerMembers.mockResolvedValue([sym('https://source.example/folder/a.txt')]) + store.fetcher._fetch.mockResolvedValueOnce(new Response(null, { status: 403 })) + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await expect(logic.copyContainer(sym('https://source.example/folder/'), 'https://pod.example/files/')) + .rejects.toThrow('Copy is incomplete; 1 destination resource(s) were written and remain at https://pod.example/files/folder/') + }) + + it('rejects invalid containment before creating the destination folder', async () => { + containerLogic.isContainer.mockReturnValue(true) + containerLogic.getContainerMembers.mockResolvedValue([sym('https://source.example/folder/')]) + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await expect(logic.copyContainer(sym('https://source.example/folder/'), 'https://pod.example/files/')) + .rejects.toThrow('Invalid container member') + expect(containerLogic.createContainer).not.toHaveBeenCalled() + }) + + it.each(['https://pod.example/files/?q=1', 'https://pod.example/files/#me', 'ftp://pod.example/files/'])( + 'rejects ambiguous or unsupported destination URLs: %s', async destination => { + const logic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) + await expect(logic.copyResource(sym('https://source.example/a.txt'), destination)).rejects.toThrow() + expect(store.fetcher._fetch).not.toHaveBeenCalled() + } + ) + it('invokes type index cleanup when deleteTypeIndexes is true', async () => { const resourceLogic = createResourceLogic(store, aclLogic, containerLogic, typeIndexLogic) const resource = sym('https://example.com/resource.ttl')