From f4e861de026a964b3534a0d5d7d2fc9e10bcf4ac Mon Sep 17 00:00:00 2001 From: Jackson Morgan Date: Fri, 2 May 2025 11:54:43 -0400 Subject: [PATCH] Began setting up tests for connected library --- packages/connected/package.json | 3 +- packages/connected/test/ErrorResult.test.ts | 6 +- .../test/LinkTraversalIntegration.test.ts | 101 ++++++++++++++++ packages/connected/test/authFetch.helper.ts | 112 ++++++++++++++++++ .../server-config-without-websocket.json | 44 +++++++ .../connected/test/configs/server-config.json | 43 +++++++ .../test/configs/solid-css-seed.json | 9 ++ packages/connected/test/mocks/MockResource.ts | 76 +++++------- packages/connected/test/setup-tests.ts | 3 + packages/connected/test/solidServer.helper.ts | 42 +++++++ packages/test-solid-server/.eslintrc | 5 + packages/test-solid-server/.gitignore | 1 + packages/test-solid-server/LICENSE.txt | 0 packages/test-solid-server/Readme.md | 3 + packages/test-solid-server/tsconfig.json | 13 ++ 15 files changed, 412 insertions(+), 49 deletions(-) create mode 100644 packages/connected/test/LinkTraversalIntegration.test.ts create mode 100644 packages/connected/test/authFetch.helper.ts create mode 100644 packages/connected/test/configs/server-config-without-websocket.json create mode 100644 packages/connected/test/configs/server-config.json create mode 100644 packages/connected/test/configs/solid-css-seed.json create mode 100644 packages/connected/test/setup-tests.ts create mode 100644 packages/connected/test/solidServer.helper.ts create mode 100644 packages/test-solid-server/.eslintrc create mode 100644 packages/test-solid-server/.gitignore create mode 100644 packages/test-solid-server/LICENSE.txt create mode 100644 packages/test-solid-server/Readme.md create mode 100644 packages/test-solid-server/tsconfig.json diff --git a/packages/connected/package.json b/packages/connected/package.json index 7e328dd..08d82af 100644 --- a/packages/connected/package.json +++ b/packages/connected/package.json @@ -23,6 +23,7 @@ }, "homepage": "https://github.com/o-development/ldobjects/tree/main/packages/solid#readme", "devDependencies": { + "@ldo/connected-solid": "^1.0.0-alpha.3", "@rdfjs/data-model": "^1.2.0", "@rdfjs/types": "^1.0.1", "cross-env": "^7.0.3", @@ -44,4 +45,4 @@ "publishConfig": { "access": "public" } -} +} \ No newline at end of file diff --git a/packages/connected/test/ErrorResult.test.ts b/packages/connected/test/ErrorResult.test.ts index 873dcd9..d7323cf 100644 --- a/packages/connected/test/ErrorResult.test.ts +++ b/packages/connected/test/ErrorResult.test.ts @@ -5,9 +5,9 @@ import { UnexpectedResourceError, } from "../src/results/error/ErrorResult"; import { InvalidUriError } from "../src/results/error/InvalidUriError"; -import { MockResouce } from "./mocks/MockResource"; +import { MockResource } from "./mocks/MockResource"; -const mockResource = new MockResouce("https://example.com/"); +const mockResource = new MockResource("https://example.com/"); describe("ErrorResult", () => { describe("fromThrown", () => { @@ -38,7 +38,7 @@ describe("ErrorResult", () => { }); describe("default messages", () => { - class ConcreteResourceError extends ResourceError { + class ConcreteResourceError extends ResourceError { readonly type = "concreteResourceError" as const; } class ConcreteErrorResult extends ErrorResult { diff --git a/packages/connected/test/LinkTraversalIntegration.test.ts b/packages/connected/test/LinkTraversalIntegration.test.ts new file mode 100644 index 0000000..d6d199b --- /dev/null +++ b/packages/connected/test/LinkTraversalIntegration.test.ts @@ -0,0 +1,101 @@ +import type { App } from "@solid/community-server"; +import type { ConnectedLdoDataset } from "../src"; +import { ROOT_CONTAINER, WEB_ID, createApp } from "./solidServer.helper"; +import { generateAuthFetch } from "./authFetch.helper"; +import type { SolidConnectedPlugin } from "@ldo/connected-solid"; + +describe("Link Traversal", () => { + let app: App; + let authFetch: typeof fetch; + let fetchMock: jest.Mock< + Promise, + [input: RequestInfo | URL, init?: RequestInit | undefined] + >; + let solidLdoDataset: ConnectedLdoDataset; + + let previousJestId: string | undefined; + let previousNodeEnv: string | undefined; + beforeAll(async () => { + // Remove Jest ID so that community solid server doesn't use the Jest Import + previousJestId = process.env.JEST_WORKER_ID; + previousNodeEnv = process.env.NODE_ENV; + delete process.env.JEST_WORKER_ID; + process.env.NODE_ENV = "other_test"; + // Start up the server + app = await createApp(); + await app.start(); + authFetch = await generateAuthFetch(); + }); + + afterAll(async () => { + app.stop(); + process.env.JEST_WORKER_ID = previousJestId; + process.env.NODE_ENV = previousNodeEnv; + const testDataPath = path.join(__dirname, "./data"); + await fs.rm(testDataPath, { recursive: true, force: true }); + }); + + beforeEach(async () => { + fetchMock = jest.fn(authFetch); + solidLdoDataset = createSolidLdoDataset(); + solidLdoDataset.setContext("solid", { fetch: fetchMock }); + // Create a new document called sample.ttl + await authFetch(ROOT_CONTAINER, { + method: "POST", + headers: { + link: '; rel="type"', + slug: TEST_CONTAINER_SLUG, + }, + }); + await authFetch(TEST_CONTAINER_ACL_URI, { + method: "PUT", + headers: { + "content-type": "text/turtle", + }, + body: TEST_CONTAINER_ACL, + }); + await Promise.all([ + authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "sample.ttl" }, + body: SPIDER_MAN_TTL, + }), + authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/plain", slug: "sample.txt" }, + body: "some text.", + }), + authFetch(TEST_CONTAINER_URI, { + method: "POST", + headers: { "content-type": "text/turtle", slug: "profile.ttl" }, + body: SAMPLE_PROFILE_TTL, + }), + ]); + }); + + afterEach(async () => { + await Promise.all([ + authFetch(SAMPLE_DATA_URI, { + method: "DELETE", + }), + authFetch(SAMPLE2_DATA_URI, { + method: "DELETE", + }), + authFetch(SAMPLE_BINARY_URI, { + method: "DELETE", + }), + authFetch(SAMPLE2_BINARY_URI, { + method: "DELETE", + }), + authFetch(SAMPLE_PROFILE_URI, { + method: "DELETE", + }), + authFetch(SAMPLE_CONTAINER_URI, { + method: "DELETE", + }), + ]); + await authFetch(TEST_CONTAINER_URI, { + method: "DELETE", + }); + }); +}); diff --git a/packages/connected/test/authFetch.helper.ts b/packages/connected/test/authFetch.helper.ts new file mode 100644 index 0000000..fffee6a --- /dev/null +++ b/packages/connected/test/authFetch.helper.ts @@ -0,0 +1,112 @@ +import type { KeyPair } from "@inrupt/solid-client-authn-core"; +import { + buildAuthenticatedFetch, + createDpopHeader, + generateDpopKeyPair, +} from "@inrupt/solid-client-authn-core"; +import fetch from "cross-fetch"; + +const config = { + podName: process.env.USER_NAME || "example", + email: process.env.EMAIL || "hello@example.com", + password: process.env.PASSWORD || "abc123", +}; + +async function getAuthorization(): Promise { + // First we request the account API controls to find out where we can log in + const indexResponse = await fetch("http://localhost:3001/.account/"); + const { controls } = await indexResponse.json(); + + // And then we log in to the account API + const response = await fetch(controls.password.login, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: config.email, + password: config.password, + }), + }); + // This authorization value will be used to authenticate in the next step + const result = await response.json(); + return result.authorization; +} + +async function getSecret( + authorization: string, +): Promise<{ id: string; secret: string; resource: string }> { + // Now that we are logged in, we need to request the updated controls from the server. + // These will now have more values than in the previous example. + const indexResponse = await fetch("http://localhost:3001/.account/", { + headers: { authorization: `CSS-Account-Token ${authorization}` }, + }); + const { controls } = await indexResponse.json(); + + // Here we request the server to generate a token on our account + const response = await fetch(controls.account.clientCredentials, { + method: "POST", + headers: { + authorization: `CSS-Account-Token ${authorization}`, + "content-type": "application/json", + }, + // The name field will be used when generating the ID of your token. + // The WebID field determines which WebID you will identify as when using the token. + // Only WebIDs linked to your account can be used. + body: JSON.stringify({ + name: "my-token", + webId: `http://localhost:3001/${config.podName}/profile/card#me`, + }), + }); + + // These are the identifier and secret of your token. + // Store the secret somewhere safe as there is no way to request it again from the server! + // The `resource` value can be used to delete the token at a later point in time. + const response2 = await response.json(); + return response2; +} + +async function getAccessToken( + id: string, + secret: string, +): Promise<{ accessToken: string; dpopKey: KeyPair }> { + try { + // A key pair is needed for encryption. + // This function from `solid-client-authn` generates such a pair for you. + const dpopKey = await generateDpopKeyPair(); + + // These are the ID and secret generated in the previous step. + // Both the ID and the secret need to be form-encoded. + const authString = `${encodeURIComponent(id)}:${encodeURIComponent( + secret, + )}`; + // This URL can be found by looking at the "token_endpoint" field at + // http://localhost:3001/.well-known/openid-configuration + // if your server is hosted at http://localhost:3000/. + const tokenUrl = "http://localhost:3001/.oidc/token"; + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + // The header needs to be in base64 encoding. + authorization: `Basic ${Buffer.from(authString).toString("base64")}`, + "content-type": "application/x-www-form-urlencoded", + dpop: await createDpopHeader(tokenUrl, "POST", dpopKey), + }, + body: "grant_type=client_credentials&scope=webid", + }); + + // This is the Access token that will be used to do an authenticated request to the server. + // The JSON also contains an "expires_in" field in seconds, + // which you can use to know when you need request a new Access token. + const response2 = await response.json(); + return { accessToken: response2.access_token, dpopKey }; + } catch (err) { + console.error(err); + throw err; + } +} + +export async function generateAuthFetch() { + const authorization = await getAuthorization(); + const { id, secret } = await getSecret(authorization); + const { accessToken, dpopKey } = await getAccessToken(id, secret); + return await buildAuthenticatedFetch(accessToken, { dpopKey }); +} diff --git a/packages/connected/test/configs/server-config-without-websocket.json b/packages/connected/test/configs/server-config-without-websocket.json new file mode 100644 index 0000000..626d082 --- /dev/null +++ b/packages/connected/test/configs/server-config-without-websocket.json @@ -0,0 +1,44 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld", + "import": [ + "css:config/app/init/initialize-root.json", + "css:config/app/main/default.json", + "css:config/app/variables/default.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/webhooks.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/no-accounts.json", + "css:config/identity/oidc/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/file.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/root.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/file.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": [ + "A Solid server that stores its resources on disk and uses WAC for authorization.", + "No registration and the root container is initialized to allow full access for everyone so make sure to change this." + ] + } + ] +} \ No newline at end of file diff --git a/packages/connected/test/configs/server-config.json b/packages/connected/test/configs/server-config.json new file mode 100644 index 0000000..5e96784 --- /dev/null +++ b/packages/connected/test/configs/server-config.json @@ -0,0 +1,43 @@ +{ + "@context": [ + "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^7.0.0/components/context.jsonld" + ], + "import": [ + "css:config/app/init/static-root.json", + "css:config/app/main/default.json", + "css:config/app/variables/default.json", + "css:config/http/handler/default.json", + "css:config/http/middleware/default.json", + "css:config/http/notifications/all.json", + "css:config/http/server-factory/http.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/email/default.json", + "css:config/identity/handler/default.json", + "css:config/identity/oidc/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/ldp/authentication/dpop-bearer.json", + "css:config/ldp/authorization/webacl.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/backend/memory.json", + "css:config/storage/key-value/resource-store.json", + "css:config/storage/location/pod.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acl.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/memory.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "A Solid server that stores its resources on disk and uses WAC for authorization." + } + ] +} \ No newline at end of file diff --git a/packages/connected/test/configs/solid-css-seed.json b/packages/connected/test/configs/solid-css-seed.json new file mode 100644 index 0000000..5894d0d --- /dev/null +++ b/packages/connected/test/configs/solid-css-seed.json @@ -0,0 +1,9 @@ +[ + { + "email": "hello@example.com", + "password": "abc123", + "pods": [ + { "name": "example" } + ] + } +] \ No newline at end of file diff --git a/packages/connected/test/mocks/MockResource.ts b/packages/connected/test/mocks/MockResource.ts index a734904..7bac2c5 100644 --- a/packages/connected/test/mocks/MockResource.ts +++ b/packages/connected/test/mocks/MockResource.ts @@ -11,7 +11,7 @@ import type { DatasetChanges } from "@ldo/rdf-utils"; import type { ReadSuccess } from "../../src/results/success/ReadSuccess"; import type { UpdateSuccess } from "../../src/results/success/UpdateSuccess"; -export class MockResouce +export class MockResource extends (EventEmitter as new () => ResourceEventEmitter) implements Resource { @@ -26,48 +26,34 @@ export class MockResouce this.status = new Unfetched(this); } - isLoading(): boolean { - throw new Error("Method not implemented."); - } - isFetched(): boolean { - throw new Error("Method not implemented."); - } - isUnfetched(): boolean { - throw new Error("Method not implemented."); - } - isDoingInitialFetch(): boolean { - throw new Error("Method not implemented."); - } - isPresent(): boolean | undefined { - throw new Error("Method not implemented."); - } - isAbsent(): boolean | undefined { - throw new Error("Method not implemented."); - } - isSubscribedToNotifications(): boolean { - throw new Error("Method not implemented."); - } - read(): Promise | ResourceError> { - throw new Error("Method not implemented."); - } - readIfUnfetched(): Promise | ResourceError> { - throw new Error("Method not implemented."); - } - update( - _datasetChanges: DatasetChanges, - ): Promise | ResourceError> { - throw new Error("Method not implemented."); - } - subscribeToNotifications(_callbacks?: { - onNotification: (message: any) => void; - onNotificationError: (err: Error) => void; - }): Promise { - throw new Error("Method not implemented."); - } - unsubscribeFromNotifications(_subscriptionId: string): Promise { - throw new Error("Method not implemented."); - } - unsubscribeFromAllNotifications(): Promise { - throw new Error("Method not implemented."); - } + isLoading = jest.fn(); + isFetched = jest.fn(); + isUnfetched = jest.fn(); + isDoingInitialFetch = jest.fn(); + isPresent = jest.fn(); + isAbsent = jest.fn(); + isSubscribedToNotifications = jest.fn(); + + read = jest.fn | ResourceError>, []>(); + readIfUnfetched = jest.fn< + Promise | ResourceError>, + [] + >(); + update = jest.fn< + Promise | ResourceError>, + [DatasetChanges] + >(); + + subscribeToNotifications = jest.fn< + Promise, + [ + { + onNotification: (message: any) => void; + onNotificationError: (err: Error) => void; + }?, + ] + >(); + + unsubscribeFromNotifications = jest.fn, [string]>(); + unsubscribeFromAllNotifications = jest.fn, []>(); } diff --git a/packages/connected/test/setup-tests.ts b/packages/connected/test/setup-tests.ts new file mode 100644 index 0000000..f4378ae --- /dev/null +++ b/packages/connected/test/setup-tests.ts @@ -0,0 +1,3 @@ +import { config } from "dotenv"; + +config(); diff --git a/packages/connected/test/solidServer.helper.ts b/packages/connected/test/solidServer.helper.ts new file mode 100644 index 0000000..848abc1 --- /dev/null +++ b/packages/connected/test/solidServer.helper.ts @@ -0,0 +1,42 @@ +// Taken from https://github.com/comunica/comunica/blob/b237be4265c353a62a876187d9e21e3bc05123a3/engines/query-sparql/test/QuerySparql-solid-test.ts#L9 + +import * as path from "path"; +import type { App } from "@solid/community-server"; +import { AppRunner, resolveModulePath } from "@solid/community-server"; +import "jest-rdf"; +import type { SolidContainerUri } from "../src"; + +export const SERVER_DOMAIN = process.env.SERVER || "http://localhost:3001/"; +export const ROOT_ROUTE = process.env.ROOT_CONTAINER || ""; +export const ROOT_CONTAINER = + `${SERVER_DOMAIN}${ROOT_ROUTE}` as SolidContainerUri; +export const WEB_ID = + process.env.WEB_ID || `${SERVER_DOMAIN}example/profile/card#me`; + +// Use an increased timeout, since the CSS server takes too much setup time. +jest.setTimeout(40_000); + +export async function createApp(customConfigPath?: string): Promise { + if (process.env.SERVER) { + return { + start: () => {}, + stop: () => {}, + } as App; + } + const appRunner = new AppRunner(); + + return appRunner.create({ + loaderProperties: { + mainModulePath: resolveModulePath(""), + typeChecking: false, + }, + config: customConfigPath ?? resolveModulePath("config/file-root.json"), + variableBindings: {}, + shorthand: { + port: 3_001, + loggingLevel: "off", + seedConfig: path.join(__dirname, "configs", "solid-css-seed.json"), + rootFilePath: path.join(__dirname, "./data"), + }, + }); +} diff --git a/packages/test-solid-server/.eslintrc b/packages/test-solid-server/.eslintrc new file mode 100644 index 0000000..04abf47 --- /dev/null +++ b/packages/test-solid-server/.eslintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "../../.eslintrc" + ] +} \ No newline at end of file diff --git a/packages/test-solid-server/.gitignore b/packages/test-solid-server/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/packages/test-solid-server/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/packages/test-solid-server/LICENSE.txt b/packages/test-solid-server/LICENSE.txt new file mode 100644 index 0000000..e69de29 diff --git a/packages/test-solid-server/Readme.md b/packages/test-solid-server/Readme.md new file mode 100644 index 0000000..996e0ec --- /dev/null +++ b/packages/test-solid-server/Readme.md @@ -0,0 +1,3 @@ +# @ldo/test-solid-server + +This is a reusable Solid Server to be used in Jest integration tests. \ No newline at end of file diff --git a/packages/test-solid-server/tsconfig.json b/packages/test-solid-server/tsconfig.json new file mode 100644 index 0000000..2787d93 --- /dev/null +++ b/packages/test-solid-server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": [ + "./src" + ], + "exclude": [ + "./dist", + "./coverage" + ] +} \ No newline at end of file