@inrupt/solid-client
Version:
Make your web apps work with Solid Pods.
1,598 lines (1,439 loc) • 84.7 kB
text/typescript
// 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