UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

851 lines (732 loc) • 24.2 kB
// Copyright Inrupt Inc. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal in // the Software without restriction, including without limitation the rights to use, // copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the // Software, and to permit persons to whom the Software is furnished to do so, // subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, // INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A // PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE // SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // import { jest, describe, it, expect } from "@jest/globals"; import { getResourceInfo, getSourceIri, getPodOwner, isPodOwner, isContainer, isRawData, getContentType, getLinkedResourceUrlAll, getEffectiveAccess, } from "./resource"; import { internal_cloneResource } from "./resource.internal"; import type { WithResourceInfo, IriString, WithServerResourceInfo, } from "../interfaces"; import { SolidClientError } from "../interfaces"; import { createSolidDataset } from "./solidDataset"; import { mockResponse } from "../tests.internal"; jest.spyOn(globalThis, "fetch").mockImplementation( async () => new Response(undefined, { headers: { Location: "https://arbitrary.pod/resource" }, }), ); describe("getResourceInfo", () => { it("calls the included fetcher by default", async () => { await getResourceInfo("https://some.pod/resource"); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith("https://some.pod/resource", { method: "HEAD", }); }); it("uses the given fetcher if provided", async () => { const mockFetch = jest .fn<typeof fetch>() .mockReturnValue(Promise.resolve(new Response())); await getResourceInfo("https://some.pod/resource", { fetch: mockFetch, }); expect(fetch).toHaveBeenCalledTimes(0); expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledWith("https://some.pod/resource", { method: "HEAD", }); }); it("keeps track of where the SolidDataset was fetched from", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( mockResponse(undefined, undefined, "https://some.pod/resource"), ); const solidDatasetInfo = await getResourceInfo( "https://some.pod/resource", { fetch: mockFetch, }, ); expect(solidDatasetInfo.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource", ); }); it("knows when the Resource contains a SolidDataset", async () => { const solidDatasetInfo = await getResourceInfo( "https://arbitrary.pod/resource", { fetch: async () => new Response(undefined, { headers: { "Content-Type": "text/turtle" }, }), }, ); expect(solidDatasetInfo.internal_resourceInfo.isRawData).toBe(false); }); it("knows when the Resource does not contain a SolidDataset", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( mockResponse( undefined, { headers: { "Content-Type": "image/svg+xml" }, }, "https://arbitrary.pod/resource", ), ); const solidDatasetInfo = await getResourceInfo( "https://arbitrary.pod/resource", { fetch: mockFetch, }, ); expect(solidDatasetInfo.internal_resourceInfo.isRawData).toBe(true); }); it("marks a Resource as not a SolidDataset when its Content Type is unknown", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( mockResponse(undefined, undefined, "https://arbitrary.pod/resource"), ); const solidDatasetInfo = await getResourceInfo( "https://arbitrary.pod/resource", { fetch: mockFetch, }, ); expect(solidDatasetInfo.internal_resourceInfo.isRawData).toBe(true); }); it("exposes the Content Type when known", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( mockResponse( undefined, { headers: { "Content-Type": "text/turtle; charset=UTF-8" }, }, "https://some.pod/resource", ), ); const solidDatasetInfo = await getResourceInfo( "https://some.pod/resource", { fetch: mockFetch, }, ); expect(solidDatasetInfo.internal_resourceInfo.contentType).toBe( "text/turtle; charset=UTF-8", ); }); it("does not expose a Content-Type when none is known", async () => { const mockFetch = jest .fn<typeof fetch>() .mockReturnValue(Promise.resolve(mockResponse())); const solidDatasetInfo = await getResourceInfo( "https://some.pod/resource", { fetch: mockFetch, }, ); expect(solidDatasetInfo.internal_resourceInfo.contentType).toBeUndefined(); }); it("provides the IRI of the relevant ACL resource, if provided", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( mockResponse( undefined, { headers: { Link: '<aclresource.acl>; rel="acl"', }, }, "https://some.pod/container/resource", ), ); const solidDatasetInfo = await getResourceInfo( "https://some.pod/container/resource", { fetch: mockFetch }, ); expect(solidDatasetInfo.internal_resourceInfo.aclUrl).toBe( "https://some.pod/container/aclresource.acl", ); }); it("exposes the URLs of linked Resources", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( mockResponse( undefined, { headers: { Link: '<aclresource.acl>; rel="acl", <https://some.pod/profile#WebId>; rel="http://www.w3.org/ns/solid/terms#podOwner", <https://some.pod/rss>; rel="alternate", <https://some.pod/atom>; rel="alternate"', }, }, "https://some.pod", ), ); const solidDatasetInfo = await getResourceInfo( "https://some.pod/container/resource", { fetch: mockFetch }, ); expect(solidDatasetInfo.internal_resourceInfo.linkedResources).toEqual({ acl: ["https://some.pod/aclresource.acl"], "http://www.w3.org/ns/solid/terms#podOwner": [ "https://some.pod/profile#WebId", ], alternate: ["https://some.pod/rss", "https://some.pod/atom"], }); }); it("exposes when no Resources were linked", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue(mockResponse(undefined, {}, "https://arbitrary.pod")); const solidDatasetInfo = await getResourceInfo( "https://some.pod/container/resource", { fetch: mockFetch }, ); expect(solidDatasetInfo.internal_resourceInfo.linkedResources).toEqual({}); }); it("does not provide an IRI to an ACL resource if not provided one by the server", async () => { const mockedResponse = mockResponse( undefined, { headers: { Link: '<arbitrary-resource>; rel="not-acl"', }, }, "https://arbitrary.pod", ); const mockFetch = jest.fn<typeof fetch>().mockResolvedValue(mockedResponse); const solidDatasetInfo = await getResourceInfo( "https://some.pod/container/resource", { fetch: mockFetch }, ); expect(solidDatasetInfo.internal_resourceInfo.aclUrl).toBeUndefined(); }); it("provides the relevant access permissions to the Resource, if available", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( new Response(undefined, { headers: { "wac-aLLOW": 'public="read",user="read write append control"', }, }), ); const solidDatasetInfo = await getResourceInfo( "https://arbitrary.pod/container/resource", { fetch: mockFetch }, ); expect(solidDatasetInfo.internal_resourceInfo.permissions).toEqual({ user: { read: true, append: true, write: true, control: true, }, public: { read: true, append: false, write: false, control: false, }, }); }); it("defaults permissions to false if they are not set, or are set with invalid syntax", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( new Response(undefined, { headers: { // Public permissions are missing double quotes, user permissions are absent: "WAC-Allow": "public=read", }, }), ); const solidDatasetInfo = await getResourceInfo( "https://arbitrary.pod/container/resource", { fetch: mockFetch }, ); expect(solidDatasetInfo.internal_resourceInfo.permissions).toEqual({ user: { read: false, append: false, write: false, control: false, }, public: { read: false, append: false, write: false, control: false, }, }); }); it("does not provide the resource's access permissions if not provided by the server", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( new Response(undefined, { headers: {}, }), ); const solidDatasetInfo = await getResourceInfo( "https://arbitrary.pod/container/resource", { fetch: mockFetch }, ); expect(solidDatasetInfo.internal_resourceInfo.permissions).toBeUndefined(); }); it("does not request the actual data from the server", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( mockResponse(undefined, undefined, "https://some.pod/resource"), ); await getResourceInfo("https://some.pod/resource", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([ ["https://some.pod/resource", { method: "HEAD" }], ]); }); it("returns a meaningful error when the server returns a 403", async () => { const mockedResponse = new Response("Not allowed", { status: 403, statusText: "Forbidden", }); jest .spyOn(mockedResponse, "url", "get") .mockReturnValue("https://some.pod/resource"); const mockFetch = jest .fn<typeof fetch>() .mockReturnValue(Promise.resolve(mockedResponse)); const fetchPromise = getResourceInfo("https://some.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Fetching the metadata of the Resource at \[https:\/\/some.pod\/resource\] failed: \[403\] \[Forbidden\]/, ); }); it("overrides a 403 error if provided the appropriate option", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( mockResponse( "Forbidden", { status: 403, statusText: "Forbidden", }, "https://some.url", ), ); const resourceInfo = await getResourceInfo("https://some.pod/resource", { fetch: mockFetch, ignoreAuthenticationErrors: true, }); expect(resourceInfo.internal_resourceInfo.sourceIri).toBe( "https://some.url", ); }); it("returns a meaningful error when the server returns a 404", async () => { const mockedResponse = new Response("Not found", { status: 404, statusText: "Not Found", }); jest .spyOn(mockedResponse, "url", "get") .mockReturnValue("https://some.pod/resource"); const mockFetch = jest .fn<typeof fetch>() .mockReturnValue(Promise.resolve(mockedResponse)); const fetchPromise = getResourceInfo("https://some.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Fetching the metadata of the Resource at \[https:\/\/some.pod\/resource\] failed: \[404\] \[Not Found\]/, ); }); it("includes the status code and status message when a request failed", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( new Response("I'm a teapot!", { status: 418, statusText: "I'm a teapot!", }), ); const fetchPromise = getResourceInfo("https://arbitrary.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", }); }); it("does not ignore non-auth errors", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( new Response("I'm a teapot!", { status: 418, statusText: "I'm a teapot!", }), ); const fetchPromise = getResourceInfo("https://arbitrary.pod/resource", { fetch: mockFetch, ignoreAuthenticationErrors: true, }); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", }); }); it("throws an instance of SolidClientError when a request failed", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( new Response("I'm a teapot!", { status: 418, statusText: "I'm a teapot!", }), ); const fetchPromise = getResourceInfo("https://arbitrary.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toBeInstanceOf(SolidClientError); }); }); describe("isContainer", () => { it("should recognise a Container", () => { const resourceInfo: WithResourceInfo = { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/", isRawData: false, }, }; expect(isContainer(resourceInfo)).toBe(true); }); it("should recognise non-Containers", () => { const resourceInfo: WithResourceInfo = { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/not-a-container", isRawData: false, }, }; expect(isContainer(resourceInfo)).toBe(false); }); it("should recognise a Container's URL", () => { expect(isContainer("https://arbitrary.pod/container/")).toBe(true); }); it("should recognise non-Container URLs", () => { expect(isContainer("https://arbitrary.pod/container/not-a-container")).toBe( false, ); }); }); describe("isRawData", () => { it("should recognise a SolidDataset", () => { const resourceInfo: WithResourceInfo = { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/", isRawData: false, }, }; expect(isRawData(resourceInfo)).toBe(false); }); it("should recognise non-RDF Resources", () => { const resourceInfo: WithResourceInfo = { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/not-a-soliddataset.png", isRawData: true, }, }; expect(isRawData(resourceInfo)).toBe(true); }); }); describe("getContentType", () => { it("should return the Content Type if known", () => { const resourceInfo: WithResourceInfo = { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: false, contentType: "multipart/form-data; boundary=something", }, }; expect(getContentType(resourceInfo)).toBe( "multipart/form-data; boundary=something", ); }); it("should return null if no Content Type is known", () => { const resourceInfo: WithResourceInfo = { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: false, }, }; expect(getContentType(resourceInfo)).toBeNull(); }); }); describe("getSourceIri", () => { it("returns the source IRI if known", () => { const withResourceInfo: WithResourceInfo = Object.assign(new Blob(), { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: true, }, }); const sourceIri: IriString = getSourceIri(withResourceInfo); expect(sourceIri).toBe("https://arbitrary.pod/resource"); }); it("returns null if no source IRI is known", () => { expect(getSourceIri(new Blob())).toBeNull(); }); }); describe("getPodOwner", () => { it("returns the Pod Owner when known", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", linkedResources: { "http://www.w3.org/ns/solid/terms#podOwner": [ "https://some.pod/profile#WebId", ], }, }, }; expect(getPodOwner(resourceInfo)).toBe("https://some.pod/profile#WebId"); }); it("returns null if the Pod Owner is not exposed", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod/not-the-root", linkedResources: { "not-pod-owner": ["https://arbitrary.url"], }, }, }; expect(getPodOwner(resourceInfo)).toBeNull(); }); it("returns null if no Server Resource Info is attached to the given Resource", () => { expect(getPodOwner({} as WithServerResourceInfo)).toBeNull(); }); }); describe("isPodOwner", () => { it("returns true when the Pod Owner is known and equal to the given WebID", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", linkedResources: { "http://www.w3.org/ns/solid/terms#podOwner": [ "https://some.pod/profile#WebId", ], }, }, }; expect(isPodOwner("https://some.pod/profile#WebId", resourceInfo)).toBe( true, ); }); it("returns false when the Pod Owner is known but not equal to the given WebID", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", linkedResources: { "http://www.w3.org/ns/solid/terms#podOwner": [ "https://some.pod/profile#WebId", ], }, }, }; expect( isPodOwner("https://some-other.pod/profile#WebId", resourceInfo), ).toBe(false); }); it("returns null if the Pod Owner is not exposed", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod/not-the-root", linkedResources: {}, }, }; expect( isPodOwner("https://arbitrary.pod/profile#WebId", resourceInfo), ).toBeNull(); }); }); describe("getLinkedResourceUrlAll", () => { it("returns the URLs of the Resources linked to the given URL, indexed by their relation type", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", linkedResources: { acl: ["https://arbitrary.pod/.acl"], "http://www.w3.org/ns/solid/terms#podOwner": [ "https://some.pod/profile#WebId", ], }, }, }; expect(getLinkedResourceUrlAll(resourceInfo)).toStrictEqual({ acl: ["https://arbitrary.pod/.acl"], "http://www.w3.org/ns/solid/terms#podOwner": [ "https://some.pod/profile#WebId", ], }); }); it("returns an empty object when there are no linked Resources", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", linkedResources: {}, }, }; expect(getLinkedResourceUrlAll(resourceInfo)).toStrictEqual({}); }); }); describe("getEffectiveAccess", () => { it("returns the access for the current user and everyone for WAC-controlled Resources", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", permissions: { user: { read: true, append: true, write: false, control: false, }, public: { read: true, append: false, write: false, control: false, }, }, linkedResources: {}, }, }; expect(getEffectiveAccess(resourceInfo)).toStrictEqual({ user: { read: true, append: true, write: false, }, public: { read: true, append: false, write: false, }, }); }); it("returns the access for the current user for ACP-controlled Resources", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", linkedResources: { "http://www.w3.org/ns/solid/acp#allow": [ "http://www.w3.org/ns/solid/acp#Read", "http://www.w3.org/ns/solid/acp#Append", ], }, }, }; expect(getEffectiveAccess(resourceInfo)).toStrictEqual({ user: { read: true, append: true, write: false, }, }); }); it("understands Write to imply Append for ACP-controlled servers", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", linkedResources: { "http://www.w3.org/ns/solid/acp#allow": [ "http://www.w3.org/ns/solid/acp#Read", "http://www.w3.org/ns/solid/acp#Write", ], }, }, }; expect(getEffectiveAccess(resourceInfo)).toStrictEqual({ user: { read: true, append: true, write: true, }, }); }); it("interprets absence of acp:allow Link headers to mean absence of their respective access", () => { const resourceInfo: WithServerResourceInfo = { internal_resourceInfo: { isRawData: true, sourceIri: "https://arbitrary.pod", linkedResources: {}, }, }; expect(getEffectiveAccess(resourceInfo)).toStrictEqual({ user: { read: false, append: false, write: false, }, }); }); }); describe("cloneResource", () => { it("returns a new but equal Dataset", () => { const sourceObject = { ...createSolidDataset(), some: "property", }; const clonedObject = internal_cloneResource(sourceObject); expect(clonedObject.some).toBe("property"); expect(clonedObject).not.toBe(sourceObject); }); it("returns a new but equal Blob", () => { const sourceObject = Object.assign(new Blob(["Some text"]), { some: "property", }); const clonedObject = internal_cloneResource(sourceObject); expect(clonedObject.some).toBe("property"); expect(clonedObject).not.toBe(sourceObject); }); it("returns a new but equal plain object", () => { const sourceObject = { some: "property" }; const clonedObject = internal_cloneResource(sourceObject); expect(clonedObject).toEqual(sourceObject); expect(clonedObject).not.toBe(sourceObject); }); it("clones an object containing an object with no 'constructor' at all", () => { // This object creation approach creates an object with no 'constructor' // field at all. const sourceObject = { noCtor: Object.create(null) }; const clonedObject = internal_cloneResource(sourceObject); expect(clonedObject).toEqual(sourceObject); expect(clonedObject).not.toBe(sourceObject); }); });