UNPKG

@inrupt/solid-client

Version:

Make your web apps work with Solid Pods.

1,598 lines (1,439 loc) • 84.7 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 type { WithAccessibleAcl, AclDataset, Access, WithAcl } from "./acl"; import { getResourceAcl, getFallbackAcl, createAclFromFallbackAcl, saveAclFor, deleteAclFor, createAcl, hasAcl, getSolidDatasetWithAcl, getFileWithAcl, getResourceInfoWithAcl, } from "./acl"; import { internal_getAccess, internal_getAclRules, internal_getResourceAclRules, internal_getDefaultAclRules, internal_getResourceAclRulesForResource, internal_getDefaultAclRulesForResource, internal_combineAccessModes, internal_removeEmptyAclRules, internal_fetchResourceAcl, internal_fetchFallbackAcl, internal_getContainerPath, internal_fetchAcl, internal_setAcl, } from "./acl.internal"; import type { WithServerResourceInfo, WithChangeLog } from "../interfaces"; import { getFile } from "../resource/file"; import { mockSolidDatasetFrom } from "../resource/mock"; import { createSolidDataset } from "../resource/solidDataset"; import { createThing, getThingAll, setThing } from "../thing/thing"; import { addIri, addStringNoLocale } from "../thing/add"; import { getIri } from "../thing/get"; import { mockResponse } from "../tests.internal"; jest.spyOn(globalThis, "fetch").mockImplementation(() => Promise.resolve( new Response(undefined, { headers: { Location: "https://arbitrary.pod/resource" }, }), ), ); describe("fetchAcl", () => { it("calls the included fetcher by default", async () => { const mockResourceInfo: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; await internal_fetchAcl(mockResourceInfo); expect(fetch).toHaveBeenCalledWith("https://some.pod/resource.acl", { headers: { Accept: "text/turtle" }, }); }); it("does not attempt to fetch ACLs if the fetched Resource does not include a pointer to an ACL file, and sets an appropriate default value.", async () => { const mockFetch = jest.fn<typeof fetch>(); const mockResourceInfo: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, linkedResources: {}, }, }; const fetchedAcl = await internal_fetchAcl(mockResourceInfo, { fetch: mockFetch, }); expect(mockFetch.mock.calls).toHaveLength(0); expect(fetchedAcl.resourceAcl).toBeNull(); expect(fetchedAcl.fallbackAcl).toBeNull(); }); it("returns null for the Container ACL if the Container's ACL file could not be fetched", async () => { const mockFetch = jest.fn((url) => { const headers: HeadersInit = url === "https://some.pod/resource" ? { Link: '<resource.acl>; rel="acl"' } : url === "https://some.pod/" ? { Link: "" } : { "Content-Type": "text/turtle" }; return Promise.resolve( mockResponse( undefined, { headers, }, url as string, ), ); }); const mockResourceInfo: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; const fetchedAcl = await internal_fetchAcl(mockResourceInfo, { fetch: mockFetch, }); expect(fetchedAcl).not.toBeNull(); expect(fetchedAcl.fallbackAcl).toBeNull(); expect(fetchedAcl.resourceAcl?.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource.acl", ); }); it("returns null for both ACLs if the Resource points to an ACR instead", async () => { const mockFetch = jest.fn((url) => { if (url === "https://some.pod/resource.acl") { return Promise.resolve( mockResponse( undefined, { headers: { Link: '<http://www.w3.org/ns/solid/acp#AccessControlResource>; rel="type"', "Content-Type": "text/turtle", }, }, "https://some.pod/resource.acl", ), ); } return Promise.resolve( mockResponse( undefined, { headers: { "Content-Type": "text/turtle", Link: '<resource.acl>; rel="acl"', }, }, url as string, ), ); }); const mockResourceInfo: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; const fetchedAcl = await internal_fetchAcl(mockResourceInfo, { fetch: mockFetch, }); expect(fetchedAcl.resourceAcl).toBeNull(); expect(fetchedAcl.fallbackAcl).toBeNull(); }); it("returns the fallback ACL even if the Resource's own ACL could not be found", async () => { const mockFetch = jest.fn((url) => { if (url === "https://some.pod/resource.acl") { return Promise.resolve( mockResponse( "ACL not found", { status: 404, }, "https://some.pod/resource.acl", ), ); } const headers: HeadersInit = url === "https://some.pod/resource" ? { Link: '<resource.acl>; rel="acl"' } : url === "https://some.pod/" ? { Link: '<.acl>; rel="acl"' } : { "Content-Type": "text/turtle" }; return Promise.resolve( mockResponse( undefined, { headers, }, url as string, ), ); }); const mockResourceInfo: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; const fetchedAcl = await internal_fetchAcl(mockResourceInfo, { fetch: mockFetch, }); expect(fetchedAcl.resourceAcl).toBeNull(); expect(fetchedAcl.fallbackAcl?.internal_resourceInfo.sourceIri).toBe( "https://some.pod/.acl", ); }); }); describe("fetchResourceAcl", () => { it("returns the fetched ACL SolidDataset", async () => { const sourceDataset: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; const mockFetch = jest.fn<typeof fetch>().mockResolvedValueOnce( mockResponse( undefined, { headers: { "Content-Type": "text/turtle" }, }, "https://some.pod/resource.acl", ), ); const fetchedAcl = await internal_fetchResourceAcl(sourceDataset, { fetch: mockFetch, }); expect(fetchedAcl?.internal_accessTo).toBe("https://some.pod/resource"); expect(fetchedAcl?.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource.acl", ); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource.acl"); }); it("calls the included fetcher by default", async () => { const sourceDataset: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; await internal_fetchResourceAcl(sourceDataset); expect(fetch).toHaveBeenCalledWith("https://some.pod/resource.acl", { headers: { Accept: "text/turtle" }, }); }); it("returns null if the source SolidDataset has no known ACL IRI", async () => { const sourceDataset: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: false, linkedResources: {}, }, }; const fetchedAcl = await internal_fetchResourceAcl(sourceDataset); expect(fetchedAcl).toBeNull(); }); it("returns null if the ACL was not found", async () => { const sourceDataset: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; const mockFetch = jest.fn<typeof fetch>().mockResolvedValueOnce( mockResponse( "ACL not found", { status: 404, }, "https://some.pod/resource.acl", ), ); const fetchedAcl = await internal_fetchResourceAcl(sourceDataset, { fetch: mockFetch, }); expect(fetchedAcl).toBeNull(); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource.acl"); }); it("throws an error if the linked Resource is an ACP ACR", async () => { const sourceDataset: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; const mockFetch = jest.fn<typeof fetch>().mockResolvedValueOnce( mockResponse( undefined, { headers: { "Content-Type": "text/turtle", Link: '<http://www.w3.org/ns/solid/acp#AccessControlResource>; rel="type"', }, }, "https://some.pod/resource?ext=acr", ), ); await expect( internal_fetchResourceAcl(sourceDataset, { fetch: mockFetch, }), ).rejects.toThrow( "[https://some.pod/resource] is governed by Access Control Policies in [https://some.pod/resource?ext=acr] rather than by Web Access Control.", ); }); it("throws an error if the linked Resource identifies itself as an ACP ACR and thus is unlikely to be a WAC ACL", async () => { const sourceDataset: WithServerResourceInfo = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://some.pod/resource.acl"], }, }, }; const mockFetch = jest.fn<typeof fetch>().mockResolvedValueOnce( mockResponse( undefined, { headers: { "Content-Type": "text/turtle", Link: '<https://arbitrary.vocab/type>; rel="type", <http://www.w3.org/ns/solid/acp#AccessControlResource>; rel="type"', }, }, "https://some.pod/resource?ext=acr", ), ); await expect( internal_fetchResourceAcl(sourceDataset, { fetch: mockFetch, }), ).rejects.toThrow( "[https://some.pod/resource] is governed by Access Control Policies in [https://some.pod/resource?ext=acr] rather than by Web Access Control.", ); }); }); describe("fetchFallbackAcl", () => { it("returns the parent Container's ACL SolidDataset, if present", async () => { const sourceDataset = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, // If no ACL IRI is given, the user does not have Control Access, // in which case we wouldn't be able to reliably determine the effective ACL. // Hence, the function requires the given SolidDataset to have one known: aclUrl: "https://arbitrary.pod/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/resource.acl"], }, }, }; const mockFetch = jest.fn<typeof fetch>().mockResolvedValueOnce( mockResponse( "", { headers: { Link: '<.acl>; rel="acl"', }, }, "https://some.pod/", ), ); mockFetch.mockResolvedValueOnce( mockResponse( undefined, { headers: { "Content-Type": "text/turtle" }, }, "https://some.pod/.acl", ), ); const fetchedAcl = await internal_fetchFallbackAcl(sourceDataset, { fetch: mockFetch, }); expect(fetchedAcl?.internal_accessTo).toBe("https://some.pod/"); expect(fetchedAcl?.internal_resourceInfo.sourceIri).toBe( "https://some.pod/.acl", ); expect(mockFetch.mock.calls).toHaveLength(2); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/.acl"); }); it("calls the included fetcher by default", async () => { const sourceDataset = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, aclUrl: "https://some.pod/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/resource.acl"], }, }, }; await internal_fetchFallbackAcl(sourceDataset); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith("https://some.pod/", { method: "HEAD" }); }); it("travels up multiple levels if no ACL was found on the levels in between", async () => { const sourceDataset = { internal_resourceInfo: { sourceIri: "https://some.pod/with-acl/without-acl/resource", isRawData: false, // If no ACL IRI is given, the user does not have Control Access, // in which case we wouldn't be able to reliably determine the effective ACL. // Hence, the function requires the given SolidDataset to have one known: aclUrl: "https://arbitrary.pod/with-acl/without-acl/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/resource.acl"], }, }, }; const mockFetch = jest.fn<typeof fetch>().mockResolvedValueOnce( mockResponse( "", { headers: { Link: '<.acl>; rel="acl"', }, }, "https://some.pod/with-acl/without-acl/", ), ); mockFetch.mockResolvedValueOnce( mockResponse( "ACL not found", { status: 404, }, "https://some.pod/with-acl/without-acl/.acl", ), ); mockFetch.mockResolvedValueOnce( mockResponse( "", { headers: { Link: '<.acl>; rel="acl"', }, }, "https://some.pod/with-acl/", ), ); mockFetch.mockResolvedValueOnce( mockResponse( undefined, { headers: { "Content-Type": "text/turtle" }, }, "https://some.pod/with-acl/.acl", ), ); const fetchedAcl = await internal_fetchFallbackAcl(sourceDataset, { fetch: mockFetch, }); expect(fetchedAcl?.internal_accessTo).toBe("https://some.pod/with-acl/"); expect(fetchedAcl?.internal_resourceInfo.sourceIri).toBe( "https://some.pod/with-acl/.acl", ); expect(mockFetch.mock.calls).toHaveLength(4); expect(mockFetch.mock.calls[0][0]).toBe( "https://some.pod/with-acl/without-acl/", ); expect(mockFetch.mock.calls[1][0]).toBe( "https://some.pod/with-acl/without-acl/.acl", ); expect(mockFetch.mock.calls[2][0]).toBe("https://some.pod/with-acl/"); expect(mockFetch.mock.calls[3][0]).toBe("https://some.pod/with-acl/.acl"); }); // This happens if the user does not have Control access to that Container, in which case we will // not be able to determine the effective ACL: it("returns null if one of the Containers on the way up does not advertise an ACL", async () => { const sourceDataset = { internal_resourceInfo: { sourceIri: "https://some.pod/arbitrary-parent/no-control-access/resource", isRawData: false, // If no ACL IRI is given, the user does not have Control Access, // in which case we wouldn't be able to reliably determine the effective ACL. // Hence, the function requires the given SolidDataset to have one known: aclUrl: "https://arbitrary.pod/arbitrary-parent/no-control-access/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/resource.acl"], }, }, }; const mockFetch = jest .fn<typeof fetch>() .mockResolvedValueOnce( mockResponse( undefined, {}, "https://some.pod/arbitrary-parent/no-control-access/", ), ); const fetchedAcl = await internal_fetchFallbackAcl(sourceDataset, { fetch: mockFetch, }); expect(fetchedAcl).toBeNull(); expect(mockFetch.mock.calls).toHaveLength(1); expect(mockFetch.mock.calls[0][0]).toBe( "https://some.pod/arbitrary-parent/no-control-access/", ); }); it("returns null if no ACL could be found for the Containers up to the root of the Pod", async () => { const sourceDataset = { internal_resourceInfo: { sourceIri: "https://some.pod/resource", isRawData: false, // If no ACL IRI is given, the user does not have Control Access, // in which case we wouldn't be able to reliably determine the effective ACL. // Hence, the function requires the given SolidDataset to have one known: aclUrl: "https://arbitrary.pod/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/resource.acl"], }, }, }; const mockFetch = jest.fn<typeof fetch>().mockResolvedValueOnce( mockResponse( "", { headers: { Link: '<.acl>; rel="acl"', }, }, "https://some.pod", ), ); mockFetch.mockResolvedValueOnce( mockResponse( "ACL not found", { status: 404, }, "https://some.pod/.acl", ), ); const fetchedAcl = await internal_fetchFallbackAcl(sourceDataset, { fetch: mockFetch, }); expect(fetchedAcl).toBeNull(); expect(mockFetch.mock.calls).toHaveLength(2); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/.acl"); }); }); describe("getContainerPath", () => { it("returns the parent if the input is a Resource path", () => { expect(internal_getContainerPath("/container/resource")).toBe( "/container/", ); }); it("returns the parent if the input is a Container path", () => { expect(internal_getContainerPath("/container/child-container/")).toBe( "/container/", ); }); it("returns the root if the input is a child of the root", () => { expect(internal_getContainerPath("/resource")).toBe("/"); }); it("does not prefix a slash if the input did not do so either", () => { expect(internal_getContainerPath("container/resource")).toBe("container/"); }); }); describe("hasAcl", () => { it("returns true if a Resource was fetched with its ACL Resources attached", () => { const withAcl: WithAcl = { internal_acl: { resourceAcl: null, fallbackAcl: null, }, }; expect(hasAcl(withAcl)).toBe(true); }); it("returns false if a Resource was fetched without its ACL Resources attached", () => { const withoutAcl = {}; expect(hasAcl(withoutAcl)).toBe(false); }); }); describe("getSolidDatasetWithAcl", () => { it("returns the Resource's own ACL and not its Container's if available", async () => { const mockFetch = jest.fn((url) => { const headers: HeadersInit = url === "https://some.pod/resource" ? { Link: '<resource.acl>; rel="acl"', "Content-Type": "text/turtle" } : url === "https://some.pod/" ? { Link: '<.acl>; rel="acl"' } : { "Content-Type": "text/turtle" }; return Promise.resolve( mockResponse( undefined, { headers, }, url as string, ), ); }); const fetchedSolidDataset = await getSolidDatasetWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(fetchedSolidDataset.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource", ); expect( fetchedSolidDataset.internal_acl?.resourceAcl?.internal_resourceInfo .sourceIri, ).toBe("https://some.pod/resource.acl"); expect(fetchedSolidDataset.internal_acl?.fallbackAcl).toBeNull(); expect(mockFetch.mock.calls).toHaveLength(2); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource.acl"); }); it("returns the Resource's Container's ACL if its own ACL is not available", async () => { const mockFetch = jest.fn((url) => { if (url === "https://some.pod/resource.acl") { return Promise.resolve(new Response("Not found", { status: 404 })); } const headers: HeadersInit = url === "https://some.pod/resource" ? { Link: '<resource.acl>; rel="acl"', "Content-Type": "text/turtle" } : url === "https://some.pod/" ? { Link: '<.acl>; rel="acl"' } : { "Content-Type": "text/turtle" }; return Promise.resolve( mockResponse( undefined, { headers, }, url as string, ), ); }); const fetchedSolidDataset = await getSolidDatasetWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(fetchedSolidDataset.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource", ); expect(fetchedSolidDataset.internal_acl?.resourceAcl).toBeNull(); expect( fetchedSolidDataset.internal_acl?.fallbackAcl?.internal_resourceInfo .sourceIri, ).toBe("https://some.pod/.acl"); expect(mockFetch.mock.calls).toHaveLength(4); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource.acl"); expect(mockFetch.mock.calls[2][0]).toBe("https://some.pod/"); expect(mockFetch.mock.calls[3][0]).toBe("https://some.pod/.acl"); }); it("calls the included fetcher by default", async () => { getSolidDatasetWithAcl("https://some.pod/resource").catch(() => { // We're just checking that this is called, // so we can ignore the error about not being able to parse // the mock Response. }); expect(fetch).toHaveBeenCalledWith("https://some.pod/resource", { headers: { Accept: "text/turtle" }, }); }); it("does not attempt to fetch ACLs if the fetched Resource does not include a pointer to an ACL file, and sets an appropriate default value.", async () => { const mockFetch = jest.fn<typeof fetch>(); mockFetch.mockResolvedValueOnce( mockResponse( undefined, { headers: { Link: "", "Content-Type": "text/turtle", }, }, "https://some.pod/resource", ), ); const fetchedSolidDataset = await getSolidDatasetWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(fetchedSolidDataset.internal_acl.resourceAcl).toBeNull(); expect(fetchedSolidDataset.internal_acl.fallbackAcl).toBeNull(); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( new Response("Not allowed", { status: 403, statusText: "Forbidden" }), ); const fetchPromise = getSolidDatasetWithAcl("https://some.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Fetching the Resource at \[https:\/\/some.pod\/resource\] failed: \[403\] \[Forbidden\]/, ); }); it("returns a meaningful error when the server returns a 404", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( new Response("Not found", { status: 404, statusText: "Not Found" }), ); const fetchPromise = getSolidDatasetWithAcl("https://some.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Fetching 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 = getSolidDatasetWithAcl( "https://arbitrary.pod/resource", { fetch: mockFetch, }, ); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", }); }); }); describe("getFileWithAcl", () => { it("should GET a remote resource using the included fetcher if no other fetcher is available", async () => { jest .spyOn(globalThis, "fetch") .mockImplementation( async () => new Response("Some data", { status: 200, statusText: "OK" }), ); await getFileWithAcl("https://some.url/"); expect(fetch).toHaveBeenCalledWith("https://some.url/", undefined); }); it("should GET a remote resource using the provided fetcher", async () => { const mockFetch = jest.fn<typeof fetch>( async () => new Response("Some data", { status: 200, statusText: "OK" }), ); await getFileWithAcl("https://some.url/", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([["https://some.url/", undefined]]); }); it("should return the fetched data as a blob, along with its ACL", async () => { const mockedResponse = new Response("Some data", { status: 200, statusText: "OK", }); jest .spyOn(mockedResponse, "url", "get") .mockReturnValue("https://example.org/resource"); const mockFetch = jest .fn<typeof fetch>() .mockReturnValue(Promise.resolve(mockedResponse)); const file = await getFileWithAcl("https://example.org/resource", { fetch: mockFetch, }); expect(file.internal_resourceInfo.sourceIri).toBe( "https://example.org/resource", ); expect(file.internal_resourceInfo.contentType).toContain("text/plain"); expect(file.internal_resourceInfo.isRawData).toBe(true); const fileData = await file.text(); expect(fileData).toBe("Some data"); }); it("returns the Resource's own ACL and not its Container's if available", async () => { const mockFetch = jest.fn((url) => { const headers: HeadersInit = url === "https://some.pod/resource" ? { Link: '<resource.acl>; rel="acl"' } : url === "https://some.pod/" ? { Link: '<.acl>; rel="acl"' } : { "Content-Type": "text/turtle" }; const response = mockResponse(undefined, { headers }, url as string); return Promise.resolve(response); }); const fetchedSolidDataset = await getFileWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(fetchedSolidDataset.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource", ); expect( fetchedSolidDataset.internal_acl?.resourceAcl?.internal_resourceInfo .sourceIri, ).toBe("https://some.pod/resource.acl"); expect(fetchedSolidDataset.internal_acl?.fallbackAcl).toBeNull(); expect(mockFetch.mock.calls).toHaveLength(2); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource.acl"); }); it("returns the Resource's Container's ACL if its own ACL is not available", async () => { const mockFetch = jest.fn((url) => { if (url === "https://some.pod/resource.acl") { return Promise.resolve(new Response("Not found", { status: 404 })); } const headers: HeadersInit = url === "https://some.pod/resource" ? { Link: '<resource.acl>; rel="acl"' } : url === "https://some.pod/" ? { Link: '<.acl>; rel="acl"' } : { "Content-Type": "text/turtle" }; const response = mockResponse(undefined, { headers }, url as string); return Promise.resolve(response); }); const fetchedSolidDataset = await getFileWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(fetchedSolidDataset.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource", ); expect(fetchedSolidDataset.internal_acl?.resourceAcl).toBeNull(); expect( fetchedSolidDataset.internal_acl?.fallbackAcl?.internal_resourceInfo .sourceIri, ).toBe("https://some.pod/.acl"); expect(mockFetch.mock.calls).toHaveLength(4); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource.acl"); expect(mockFetch.mock.calls[2][0]).toBe("https://some.pod/"); expect(mockFetch.mock.calls[3][0]).toBe("https://some.pod/.acl"); }); it("does not attempt to fetch ACLs if the fetched Resource does not include a pointer to an ACL file, and sets an appropriate default value.", async () => { const mockFetch = jest.fn<typeof fetch>(); const init: ResponseInit & { url: string } = { headers: { Link: "", }, url: "https://some.pod/resource", }; mockFetch.mockResolvedValueOnce(new Response(undefined, init)); const fetchedSolidDataset = await getFileWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(fetchedSolidDataset.internal_acl.resourceAcl).toBeNull(); expect(fetchedSolidDataset.internal_acl.fallbackAcl).toBeNull(); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( new Response("Not allowed", { status: 403, statusText: "Forbidden" }), ); const fetchPromise = getFileWithAcl("https://arbitrary.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Fetching the File failed: \[403\] \[Forbidden\]./, ); }); it("returns a meaningful error when the server returns a 404", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( new Response("Not found", { status: 404, statusText: "Not Found" }), ); const fetchPromise = getFileWithAcl("https://arbitrary.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toThrow( /Fetching the File 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 = getFileWithAcl("https://arbitrary.pod/resource", { fetch: mockFetch, }); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", }); }); it("should pass the request headers through", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( new Response("Some data", { status: 200, statusText: "OK" }), ); await getFile("https://some.url/", { init: { headers: new Headers({ Accept: "text/turtle" }), }, fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([ [ "https://some.url/", { headers: new Headers({ Accept: "text/turtle" }), }, ], ]); }); it("should throw on failure", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( new Response(undefined, { status: 400, statusText: "Bad request" }), ); const response = getFile("https://some.url", { fetch: mockFetch, }); await expect(response).rejects.toThrow( "Fetching the File failed: [400] [Bad request]", ); }); }); describe("getResourceInfoWithAcl", () => { it("returns the Resource's own ACL and not its Container's if available", async () => { const mockFetch = jest.fn((url) => { const headers: HeadersInit = url === "https://some.pod/resource" ? { Link: '<resource.acl>; rel="acl"' } : url === "https://some.pod/" ? { Link: '<.acl>; rel="acl"' } : { "Content-Type": "text/turtle" }; return Promise.resolve( mockResponse( undefined, { headers, }, url as string, ), ); }); const fetchedSolidDataset = await getResourceInfoWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(fetchedSolidDataset.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource", ); expect( fetchedSolidDataset.internal_acl?.resourceAcl?.internal_resourceInfo .sourceIri, ).toBe("https://some.pod/resource.acl"); expect(fetchedSolidDataset.internal_acl?.fallbackAcl).toBeNull(); expect(mockFetch.mock.calls).toHaveLength(2); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource.acl"); }); it("returns the Resource's Container's ACL if its own ACL is not available", async () => { const mockFetch = jest.fn((url) => { if (url === "https://some.pod/resource.acl") { return Promise.resolve(new Response("Not found", { status: 404 })); } const headers: HeadersInit = url === "https://some.pod/resource" ? { Link: '<resource.acl>; rel="acl"' } : url === "https://some.pod/" ? { Link: '<.acl>; rel="acl"' } : { "Content-Type": "text/turtle" }; return Promise.resolve( mockResponse( undefined, { headers, }, url as string, ), ); }); const fetchedSolidDataset = await getResourceInfoWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(fetchedSolidDataset.internal_resourceInfo.sourceIri).toBe( "https://some.pod/resource", ); expect(fetchedSolidDataset.internal_acl?.resourceAcl).toBeNull(); expect( fetchedSolidDataset.internal_acl?.fallbackAcl?.internal_resourceInfo .sourceIri, ).toBe("https://some.pod/.acl"); expect(mockFetch.mock.calls).toHaveLength(4); expect(mockFetch.mock.calls[0][0]).toBe("https://some.pod/resource"); expect(mockFetch.mock.calls[1][0]).toBe("https://some.pod/resource.acl"); expect(mockFetch.mock.calls[2][0]).toBe("https://some.pod/"); expect(mockFetch.mock.calls[3][0]).toBe("https://some.pod/.acl"); }); it("calls the included fetcher by default", async () => { await getResourceInfoWithAcl("https://some.pod/resource"); expect(fetch).toHaveBeenCalledWith("https://some.pod/resource", { method: "HEAD", }); }); it("does not attempt to fetch ACLs if the fetched Resource does not include a pointer to an ACL file, and sets an appropriate default value.", async () => { const mockFetch = jest.fn<typeof fetch>(); mockFetch.mockResolvedValueOnce( mockResponse( undefined, { headers: { Link: "", }, }, "https://some.pod/resource", ), ); const fetchedSolidDataset = await getResourceInfoWithAcl( "https://some.pod/resource", { fetch: mockFetch }, ); expect(mockFetch.mock.calls).toHaveLength(1); expect(fetchedSolidDataset.internal_acl.resourceAcl).toBeNull(); expect(fetchedSolidDataset.internal_acl.fallbackAcl).toBeNull(); }); it("returns a meaningful error when the server returns a 403", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( mockResponse( "Not allowed", { status: 403, statusText: "Forbidden", }, "https://some.pod/resource", ), ); const fetchPromise = getResourceInfoWithAcl("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("returns a meaningful error when the server returns a 404", async () => { const mockFetch = jest.fn<typeof fetch>().mockResolvedValue( mockResponse( "Not found", { status: 404, statusText: "Not Found", }, "https://some.pod/resource", ), ); const fetchPromise = getResourceInfoWithAcl("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 = getResourceInfoWithAcl( "https://arbitrary.pod/resource", { fetch: mockFetch, }, ); await expect(fetchPromise).rejects.toMatchObject({ statusCode: 418, statusText: "I'm a teapot!", }); }); it("does not request the actual data from the server", async () => { const mockFetch = jest .fn<typeof fetch>() .mockResolvedValue( mockResponse(undefined, {}, "https://some.pod/resource"), ); await getResourceInfoWithAcl("https://some.pod/resource", { fetch: mockFetch, }); expect(mockFetch.mock.calls).toEqual([ ["https://some.pod/resource", { method: "HEAD" }], ]); }); }); describe("getResourceAcl", () => { it("returns the attached Resource ACL Dataset", () => { const aclDataset: AclDataset = { ...createSolidDataset(), internal_accessTo: "https://arbitrary.pod/resource", internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource.acl", isRawData: false, }, }; const solidDataset = { ...internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: aclDataset, fallbackAcl: null, }, ), internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: false, aclUrl: "https://arbitrary.pod/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/resource.acl"], }, }, }; expect(getResourceAcl(solidDataset)).toEqual(aclDataset); }); it("returns null if the given Resource does not consider the attached ACL to pertain to it", () => { const aclDataset: AclDataset = { ...createSolidDataset(), internal_accessTo: "https://arbitrary.pod/resource", internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource.acl", isRawData: false, }, }; const solidDataset = { ...internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: aclDataset, fallbackAcl: null, }, ), internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: false, aclUrl: "https://arbitrary.pod/other-resource.acl", linkedResources: { acl: ["https://arbitrary.pod/other-resource.acl"], }, }, }; expect(getResourceAcl(solidDataset)).toBeNull(); }); it("returns null if the attached ACL does not pertain to the given Resource", () => { const aclDataset: AclDataset = { ...createSolidDataset(), internal_accessTo: "https://arbitrary.pod/other-resource", internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource.acl", isRawData: false, }, }; const solidDataset = { ...internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: aclDataset, fallbackAcl: null, }, ), internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: false, aclUrl: "https://arbitrary.pod/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/resource.acl"], }, }, }; expect(getResourceAcl(solidDataset)).toBeNull(); }); it("returns null if the given SolidDataset does not have a Resource ACL attached", () => { const solidDataset = { ...internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: null, fallbackAcl: null, }, ), internal_resourceInfo: { sourceIri: "https://arbitrary.pod/resource", isRawData: false, linkedResources: {}, }, }; expect(getResourceAcl(solidDataset)).toBeNull(); }); }); describe("getFallbackAcl", () => { it("returns the attached Fallback ACL Dataset", () => { const aclDataset: AclDataset = { ...createSolidDataset(), internal_accessTo: "https://arbitrary.pod/", internal_resourceInfo: { sourceIri: "https://arbitrary.pod/.acl", isRawData: false, }, }; const solidDataset = internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: null, fallbackAcl: aclDataset, }, ); expect(getFallbackAcl(solidDataset)).toEqual(aclDataset); }); it("returns null if the given SolidDataset does not have a Fallback ACL attached", () => { const solidDataset = internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: null, fallbackAcl: null, }, ); expect(getFallbackAcl(solidDataset)).toBeNull(); }); }); describe("createAcl", () => { it("creates a new empty ACL", () => { const solidDataset = { ...internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: null, fallbackAcl: null, }, ), internal_resourceInfo: { sourceIri: "https://some.pod/container/resource", isRawData: false, aclUrl: "https://some.pod/container/resource.acl", linkedResources: { acl: ["https://some.pod/container/resource.acl"], }, }, }; const resourceAcl = createAcl(solidDataset); expect(getThingAll(resourceAcl)).toHaveLength(0); expect(resourceAcl.internal_accessTo).toBe( "https://some.pod/container/resource", ); expect(resourceAcl.internal_resourceInfo.sourceIri).toBe( "https://some.pod/container/resource.acl", ); }); }); describe("createAclFromFallbackAcl", () => { it("creates a new ACL including existing default rules as Resource and default rules", () => { let aclDataset: AclDataset = { ...createSolidDataset(), internal_accessTo: "https://arbitrary.pod/container/", internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/.acl", isRawData: false, }, }; let rule = createThing(); rule = addIri( rule, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", "http://www.w3.org/ns/auth/acl#Authorization", ); rule = addIri( rule, "http://www.w3.org/ns/auth/acl#default", "https://arbitrary.pod/container/", ); rule = addIri( rule, "http://www.w3.org/ns/auth/acl#agent", "https://arbitrary.pod/profileDoc#webId", ); rule = addIri( rule, "http://www.w3.org/ns/auth/acl#mode", "http://www.w3.org/ns/auth/acl#Read", ); aclDataset = setThing(aclDataset, rule); const solidDataset = { ...internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: null, fallbackAcl: aclDataset, }, ), internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/resource", isRawData: false, aclUrl: "https://arbitrary.pod/container/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/container/resource.acl"], }, }, }; const resourceAcl = createAclFromFallbackAcl(solidDataset); const firstControl = getThingAll(resourceAcl)[0]; expect(getIri(firstControl, "http://www.w3.org/ns/auth/acl#accessTo")).toBe( "https://arbitrary.pod/container/resource", ); expect(getIri(firstControl, "http://www.w3.org/ns/auth/acl#default")).toBe( "https://arbitrary.pod/container/resource", ); expect(resourceAcl.internal_accessTo).toBe( "https://arbitrary.pod/container/resource", ); expect(resourceAcl.internal_resourceInfo.sourceIri).toBe( "https://arbitrary.pod/container/resource.acl", ); }); it("supports the legacy acl:defaultForNew predicate", () => { let aclDataset: AclDataset = { ...createSolidDataset(), internal_accessTo: "https://arbitrary.pod/container/", internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/.acl", isRawData: false, }, }; let rule = createThing(); rule = addIri( rule, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", "http://www.w3.org/ns/auth/acl#Authorization", ); rule = addIri( rule, "http://www.w3.org/ns/auth/acl#defaultForNew", "https://arbitrary.pod/container/", ); rule = addIri( rule, "http://www.w3.org/ns/auth/acl#agent", "https://arbitrary.pod/profileDoc#webId", ); rule = addIri( rule, "http://www.w3.org/ns/auth/acl#mode", "http://www.w3.org/ns/auth/acl#Read", ); aclDataset = setThing(aclDataset, rule); const solidDataset = { ...internal_setAcl( mockSolidDatasetFrom("https://arbitrary.pod/resource"), { resourceAcl: null, fallbackAcl: aclDataset, }, ), internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/resource", isRawData: false, aclUrl: "https://arbitrary.pod/container/resource.acl", linkedResources: { acl: ["https://arbitrary.pod/container/resource.acl"], }, }, }; const resourceAcl = createAclFromFallbackAcl(solidDataset); const firstControl = getThingAll(resourceAcl)[0]; expect(getIri(firstControl, "http://www.w3.org/ns/auth/acl#accessTo")).toBe( "https://arbitrary.pod/container/resource", ); expect(getIri(firstControl, "http://www.w3.org/ns/auth/acl#default")).toBe( "https://arbitrary.pod/container/resource", ); expect(resourceAcl.internal_accessTo).toBe( "https://arbitrary.pod/container/resource", ); expect(resourceAcl.internal_resourceInfo.sourceIri).toBe( "https://arbitrary.pod/container/resource.acl", ); }); it("does not copy over Resource rules from the fallback ACL", () => { let aclDataset: AclDataset = { ...createSolidDataset(), internal_accessTo: "https://arbitrary.pod/container/", internal_resourceInfo: { sourceIri: "https://arbitrary.pod/container/.acl", isRawData: false, }, }; let rule = createThing(); rule = addIri( rule, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", "http://www.w3.org/ns/auth/acl#Authorization", ); rule = addIri( rule, "htt