diff --git a/.github/workflows/e2e-node.yml b/.github/workflows/e2e-node.yml index 938c198cd6..1eb3093153 100644 --- a/.github/workflows/e2e-node.yml +++ b/.github/workflows/e2e-node.yml @@ -19,17 +19,10 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node-version: ["22.x", "20.x", "18.x"] - # NSS node-based end-to-end tests are only running for unauthenticated operations, - # because NSS doesn't support static client registration. Therefore, they run - # against an environment that has manually been pre-provisioned. - environment-name: ["ESS PodSpaces", "ESS Dev-2-2", "NSS"] + node-version: [22.x, 20.x, 18.x] + # PodSpaces doesn't support error descriptions yet. + environment-name: ["ESS Dev-2-3"] experimental: [false] - include: - - environment-name: "ESS Dev-2-3" - experimental: true - node-version: "22.x" - os: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/e2e/node/acp.test.ts b/e2e/node/acp.test.ts index f5de633c28..255f1b728b 100644 --- a/e2e/node/acp.test.ts +++ b/e2e/node/acp.test.ts @@ -38,6 +38,7 @@ import { createFetch, } from "@inrupt/internal-test-env"; import Link from "http-link-header"; +import { DEFAULT_TYPE } from "@inrupt/solid-client-errors"; import { acp_ess_2, fromRdfJsDataset, @@ -328,4 +329,172 @@ describe("An ACP Solid server", () => { await getAgentAccess(sessionResource, agent, fetchOptions), ).toStrictEqual(READ_AND_APPEND_ACCESS); }); + + it("raises an error getting agent access if service returns an error response", async () => { + const headResponse = await session.fetch(sessionResource, { + method: "HEAD", + }); + + const responseLinks = headResponse.headers.get("Link"); + if (!responseLinks) { + throw new Error("No Link header found"); + } + + const links = Link.parse(responseLinks.toString()); + const acrUrl = links?.get("rel", "acl")[0].uri; + + const customFetch: typeof fetch = async ( + info: Parameters[0], + init?: Parameters[1], + ) => { + return fetchOptions.fetch(info, { + ...init, + method: "INVALID", + }); + }; + + const agent = "https://example.org/bob"; + // This request should produce an error + const error = await getAgentAccess(acrUrl, agent, { + fetch: customFetch, + }).catch((err) => err); + + expect(error.statusCode).toBe(405); + expect(error.message).toContain( + `Fetching the metadata of the Resource at [${acrUrl}] failed: [405]`, + ); + expect(error.statusText).toBe("Method Not Allowed"); + + expect(error.problemDetails.type).toBe(DEFAULT_TYPE); + expect(error.problemDetails.title).toBe("Method Not Allowed"); + expect(error.problemDetails.status).toBe(405); + expect(error.problemDetails.detail).toBeUndefined(); + expect(error.problemDetails.instance).toBeUndefined(); + }); + + it("silently ignores setting agent access if service returns an error response", async () => { + const customFetch: typeof fetch = async ( + info: Parameters[0], + init?: Parameters[1], + ) => { + // Only change the PATCH request that updates the ACR + if (init?.method === "PATCH") { + return fetchOptions.fetch(info, { + ...init, + body: "Invalid Content", + }); + } + // All other requests fallback to the original fetch + return fetchOptions.fetch(info, init); + }; + + const agent = "https://example.org/bob"; + + // Agent does not have any access to the resource + await expect( + getAgentAccess(sessionResource, agent, fetchOptions), + ).resolves.toEqual({ + read: false, + append: false, + write: false, + controlRead: false, + controlWrite: false, + }); + + // This operation should fail and produce an 400 error to the client. It doesn't as the error is not propagated. + // This is legacy behaviour which is not consistent with other functions from the access control module. This + // maybe changed in a future major version of the library. For now this test is just proving that nothing changed + // on the ACR. This is problematic as a client may think the set access was successful. + await expect( + setAgentAccess( + sessionResource, + agent, + { read: true }, + { fetch: customFetch }, + ), + ).resolves.not.toThrow(); + + // Agent still does not have any access to the resource + await expect( + getAgentAccess(sessionResource, agent, fetchOptions), + ).resolves.toEqual({ + read: false, + append: false, + write: false, + controlRead: false, + controlWrite: false, + }); + }); + + it("silently ignores getting public access modes if service returns an error response", async () => { + // Provide an invalid Accept header to the GET request to get the server to return a 406 error + const customFetch: typeof fetch = async ( + info: Parameters[0], + init?: Parameters[1], + ) => { + // Only change the GET request + if (init?.method === "GET") { + return fetchOptions.fetch(info, { + ...init, + headers: { + ...init?.headers, + Accept: "invalid-mime-type", + }, + }); + } + // All other requests fallback to the original fetch + return fetchOptions.fetch(info, init); + }; + + // This operation should fail and produce an 400 error to the client. It doesn't as the error is not propagated. + // This is legacy behaviour which is not consistent with other functions from the access control module. This + // maybe changed in a future major version of the library. For now this test is just proving that there is no + // public access to the resource. This is problematic as a client will think there is just no public access. + await expect( + getPublicAccess(sessionResource, { fetch: customFetch }), + ).resolves.toEqual({ + read: false, + append: false, + write: false, + controlRead: false, + controlWrite: false, + }); + }); + + it("silently ignores setting public access modes if service returns an error response", async () => { + // Provide invalid body content to the PATCH request to get the server to return a 400 error + const customFetch: typeof fetch = async ( + info: Parameters[0], + init?: Parameters[1], + ) => { + // Only change the PATCH request + if (init?.method === "PATCH") { + return fetchOptions.fetch(info, { + ...init, + body: "Invalid content", + }); + } + // All other requests fallback to the original fetch + return fetchOptions.fetch(info, init); + }; + + // This operation should fail and produce an 400 error to the client. It doesn't as the error is not propagated. + // This is legacy behaviour which is not consistent with other functions from the access control module. This + // maybe changed in a future major version of the library. For now this test is just proving that there is no + // public access to the resource set. This is problematic as a client will think the operation worked. + await expect( + setPublicAccess(sessionResource, { read: true }, { fetch: customFetch }), + ).resolves.toBeNull(); + + // Check that no public access was set + await expect( + getPublicAccess(sessionResource, { fetch: customFetch }), + ).resolves.toEqual({ + read: false, + append: false, + write: false, + controlRead: false, + controlWrite: false, + }); + }); }); diff --git a/e2e/node/resource.test.ts b/e2e/node/resource.test.ts index 7f86f92f5c..a89fbe0bb2 100644 --- a/e2e/node/resource.test.ts +++ b/e2e/node/resource.test.ts @@ -41,6 +41,7 @@ import { createFetch, } from "@inrupt/internal-test-env"; import { DataFactory } from "n3"; +import { DEFAULT_TYPE, type ProblemDetails } from "@inrupt/solid-client-errors"; import { getSolidDataset, setThing, @@ -133,13 +134,23 @@ describe("Authenticated end-to-end", () => { expect(getBoolean(secondSavedThing, arbitraryPredicate)).toBe(false); await deleteSolidDataset(datasetUrl, fetchOptions); - await expect(() => - getSolidDataset(datasetUrl, fetchOptions), - ).rejects.toEqual( - expect.objectContaining({ - statusCode: 404, - }), + + // As the dataset was deleted retrieving it should produce an error. + const error = await getSolidDataset(datasetUrl, fetchOptions).catch( + (err) => err, ); + + expect(error.statusCode).toBe(404); + expect(error.message).toContain( + `Fetching the Resource at [${datasetUrl}] failed:`, + ); + expect(error.statusText).toContain("Not Found"); + + expect(error.problemDetails.type).toBe(DEFAULT_TYPE); + expect(error.problemDetails.title).toBe("Not Found"); + expect(error.problemDetails.status).toBe(404); + expect(error.problemDetails.detail).toBe("Resource not found"); + expect(error.problemDetails.instance).toBeDefined(); }); it("can create, delete, and differentiate between RDF and non-RDF Resources using a Blob from the node Buffer package", async () => { @@ -307,7 +318,19 @@ describe("Authenticated end-to-end", () => { }); it("cannot fetch non public resources unauthenticated", async () => { - await expect(getSolidDataset(sessionResource)).rejects.toThrow(); + const error = await getSolidDataset(sessionResource).catch((err) => err); + + expect(error.statusCode).toBe(401); + expect(error.message).toContain( + `Fetching the Resource at [${sessionResource}] failed:`, + ); + expect(error.statusText).toBe("Unauthorized"); + + expect(error.problemDetails.type).toBe(DEFAULT_TYPE); + expect(error.problemDetails.title).toBe("Unauthorized"); + expect(error.problemDetails.status).toBe(401); + expect(error.problemDetails.detail).toBeDefined(); + expect(error.problemDetails.instance).toBeDefined(); }); it("can fetch getWellKnownSolid", async () => { @@ -346,4 +369,201 @@ describe("Authenticated end-to-end", () => { ); expect(headers.get("Content-Type")).toContain("text/turtle"); }); + + it("raises error getting a resource if service returns an error response", async () => { + const customFetch: typeof fetch = async ( + info: Parameters[0], + init?: Parameters[1], + ) => { + return fetchOptions.fetch(info, { + ...init, + headers: { + ...init?.headers, + Accept: "plain/text", + }, + }); + }; + + // This request should produce an error + const error = await getSolidDataset(sessionResource, { + fetch: customFetch, + }).catch((err) => err); + + expect(error.statusCode).toBe(406); + expect(error.message).toContain( + `Fetching the Resource at [${sessionResource}] failed: [406]`, + ); + expect(error.statusText).toBe("Not Acceptable"); + + expect(error.problemDetails.type).toBe(DEFAULT_TYPE); + expect(error.problemDetails.title).toBe("Not Acceptable"); + expect(error.problemDetails.status).toBe(406); + expect(error.problemDetails.detail).toBeDefined(); + expect(error.problemDetails.instance).toBeDefined(); + }); + + it("raises error creating a container if service returns an error response", async () => { + // This operation should throw an error + const error = await createContainerAt(sessionContainer, { + fetch: serverToRespondWithAn400Error("PUT"), + }).catch((err) => err); + + expect(error.statusCode).toBe(400); + expect(error.message).toContain( + `Creating the empty Container at [${sessionContainer}] failed: [400]`, + ); + expect(error.statusText).toBe("Bad Request"); + + expect400ProblemDetails(error.problemDetails); + }); + + it("raises error creating a container in a container if service returns an error response", async () => { + // This operation should throw an error + const error = await createContainerInContainer(sessionContainer, { + fetch: serverToRespondWithAn400Error("POST"), + }).catch((err) => err); + + expect(error.statusCode).toBe(400); + expect(error.message).toContain( + `Creating an empty Container in the Container at [${sessionContainer}] failed: [400]`, + ); + expect(error.statusText).toBe("Bad Request"); + + expect400ProblemDetails(error.problemDetails); + }); + + it("raises error deleting a resource if service returns an error response", async () => { + // This operation should throw an error + const error = await deleteFile(sessionResource, { + fetch: serverToRespondWithAn405Error(), + }).catch((err) => err); + + expect(error.statusCode).toBe(405); + expect(error.message).toContain( + `Deleting the file at [${sessionResource}] failed: [405]`, + ); + expect(error.statusText).toBe("Method Not Allowed"); + + expect405ProblemDetails(error.problemDetails); + }); + + it("raises error deleting a dataset if service returns an error response", async () => { + // This operation should throw an error + const error = await deleteSolidDataset(sessionResource, { + fetch: serverToRespondWithAn405Error(), + }).catch((err) => err); + + expect(error.statusCode).toBe(405); + expect(error.message).toContain( + `Deleting the SolidDataset at [${sessionResource}] failed: [405]`, + ); + expect(error.statusText).toBe("Method Not Allowed"); + + expect405ProblemDetails(error.problemDetails); + }); + + it("raises error retrieving a resource if service returns an error response", async () => { + // This operation should throw an error + const error = await getSolidDataset(sessionResource, { + fetch: serverToRespondWithAn405Error(), + }).catch((err) => err); + + expect(error.statusCode).toBe(405); + expect(error.message).toContain( + `Fetching the Resource at [${sessionResource}] failed: [405]`, + ); + expect(error.statusText).toBe("Method Not Allowed"); + + expect405ProblemDetails(error.problemDetails); + }); + + it("raises error overwriting a file if service returns an error response", async () => { + // This operation should throw an error + const error = await overwriteFile( + sessionResource, + // We need to type cast because the buffer definition + // of Blob does not have the prototype property expected + // by the lib.dom.ts + new Blob(["test"], { + type: "text/plain", + }), + { + fetch: serverToRespondWithAn405Error(), + }, + ).catch((err) => err); + + expect(error.statusCode).toBe(405); + expect(error.message).toContain( + `Overwriting the file at [${sessionResource}] failed: [405]`, + ); + expect(error.statusText).toBe("Method Not Allowed"); + + expect405ProblemDetails(error.problemDetails); + }); + + it("raises error saving a dataset if service returns an error response", async () => { + // This operation should throw an error + const error = await saveSolidDatasetAt( + sessionResource, + createSolidDataset(), + { fetch: serverToRespondWithAn405Error() }, + ).catch((err) => err); + + expect(error.statusCode).toBe(405); + expect(error.message).toContain( + `Storing the Resource at [${sessionResource}] failed: [405]`, + ); + expect(error.statusText).toBe("Method Not Allowed"); + + expect405ProblemDetails(error.problemDetails); + }); + + function expect400ProblemDetails(problemDetails: ProblemDetails) { + expect(problemDetails.type).toBe(DEFAULT_TYPE); + expect(problemDetails.title).toBe("Bad Request"); + expect(problemDetails.status).toBe(400); + expect(problemDetails.detail).toBeDefined(); + expect(problemDetails.instance).toBeDefined(); + } + + function expect405ProblemDetails(problemDetails: ProblemDetails) { + expect(problemDetails.type).toBe(DEFAULT_TYPE); + expect(problemDetails.title).toBe("Method Not Allowed"); + expect(problemDetails.status).toBe(405); + expect(problemDetails.detail).toBeDefined(); + expect(problemDetails.instance).toBeDefined(); + } + + function serverToRespondWithAn405Error() { + // Change to invalid method to get the server to return a 405 error + const customFetch: typeof fetch = async ( + info: Parameters[0], + init?: Parameters[1], + ) => { + return fetchOptions.fetch(info, { + ...init, + method: "INVALID", + }); + }; + return customFetch; + } + + function serverToRespondWithAn400Error(method: string) { + // Provide invalid body content to the PUT request to get the server to return a 400 error + const customFetch: typeof fetch = async ( + info: Parameters[0], + init?: Parameters[1], + ) => { + // Only change the given method + if (init?.method === method) { + return fetchOptions.fetch(info, { + ...init, + body: "Invalid content", + }); + } + // All other requests fallback to the original fetch + return fetchOptions.fetch(info, init); + }; + return customFetch; + } });