Tests for error handling

main
Jackson Morgan 8 months ago
parent 7fb351fb0c
commit c6ad10ca79
  1. 8
      package-lock.json
  2. 1
      packages/solid/package.json
  3. 8
      packages/solid/src/resource/Resource.ts
  4. 3
      packages/solid/src/resource/notifications/NotificationSubscription.ts
  5. 85
      packages/solid/src/resource/notifications/Websocket2023NotificationSubscription.ts
  6. 2
      packages/solid/src/resource/notifications/results/NotificationErrors.ts
  7. 38
      packages/solid/test/Integration.test.ts
  8. 57
      packages/solid/test/Websocket2023NotificationSubscription.test.ts
  9. 44
      packages/solid/test/configs/server-config-without-websocket.json
  10. 4
      packages/solid/test/solidServer.helper.ts

8
package-lock.json generated

@ -7569,6 +7569,13 @@
"n3": "^1.17.2"
}
},
"node_modules/@solid-notifications/types": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@solid-notifications/types/-/types-0.1.2.tgz",
"integrity": "sha512-0SP6XmOjFhqt/m4FFXnYh6slSiXMoheO3UpU7POSDStLSb6tLVAQLiy0hBKvNyGBLlftRObHWoBWlt2X/LhVRg==",
"dev": true,
"license": "MIT"
},
"node_modules/@solid/access-control-policy": {
"version": "0.1.3",
"dev": true,
@ -29628,6 +29635,7 @@
"@ldo/cli": "^0.0.1-alpha.28",
"@rdfjs/data-model": "^1.2.0",
"@rdfjs/types": "^1.0.1",
"@solid-notifications/types": "^0.1.2",
"@solid/community-server": "^7.1.3",
"@types/jest": "^27.0.3",
"cross-env": "^7.0.3",

@ -29,6 +29,7 @@
"@ldo/cli": "^0.0.1-alpha.28",
"@rdfjs/data-model": "^1.2.0",
"@rdfjs/types": "^1.0.1",
"@solid-notifications/types": "^0.1.2",
"@solid/community-server": "^7.1.3",
"@types/jest": "^27.0.3",
"cross-env": "^7.0.3",

@ -334,8 +334,7 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
* ```
*/
isSubscribedToNotifications(): boolean {
// TODO
throw new Error("Not Implemented");
return !!this.notificationSubscription;
}
/**
@ -732,10 +731,13 @@ export abstract class Resource extends (EventEmitter as new () => TypedEmitter<{
/**
* TODO
*/
async subscribeToNotifications(): Promise<OpenSubscriptionResult> {
async subscribeToNotifications(
onNotificationError?: (err: Error) => void,
): Promise<OpenSubscriptionResult> {
this.notificationSubscription = new Websocket2023NotificationSubscription(
this,
this.onNotification.bind(this),
onNotificationError,
this.context,
);
return await this.notificationSubscription.open();

@ -18,15 +18,18 @@ export type CloseSubscriptionResult =
export abstract class NotificationSubscription {
protected resource: Resource;
protected onNotification: (message: NotificationMessage) => void;
protected onError?: (err: Error) => void;
protected context: SolidLdoDatasetContext;
constructor(
resource: Resource,
onNotification: (message: NotificationMessage) => void,
onError: ((err: Error) => void) | undefined,
context: SolidLdoDatasetContext,
) {
this.resource = resource;
this.onNotification = onNotification;
this.onError = onError;
this.context = context;
}

@ -8,37 +8,35 @@ import { SubscriptionClient } from "@solid-notifications/subscription";
import { WebSocket } from "ws";
import { UnsupportedNotificationError } from "./results/NotificationErrors";
import type { NotificationMessage } from "./NotificationMessage";
import type { Resource } from "../Resource";
import type { SolidLdoDatasetContext } from "../../SolidLdoDatasetContext";
import type {
ChannelType,
NotificationChannel,
} from "@solid-notifications/types";
const CHANNEL_TYPE =
"http://www.w3.org/ns/solid/notifications#WebSocketChannel2023";
export class Websocket2023NotificationSubscription extends NotificationSubscription {
private socket: WebSocket | undefined;
private createWebsocket: (address: string) => WebSocket;
constructor(
resource: Resource,
onNotification: (message: NotificationMessage) => void,
onError: ((err: Error) => void) | undefined,
context: SolidLdoDatasetContext,
createWebsocket?: (address: string) => WebSocket,
) {
super(resource, onNotification, onError, context);
this.createWebsocket = createWebsocket ?? createWebsocketDefault;
}
async open(): Promise<OpenSubscriptionResult> {
const client = new SubscriptionClient(this.context.fetch);
try {
const notificationChannel = await client.subscribe(
this.resource.uri,
CHANNEL_TYPE,
);
return new Promise<OpenSubscriptionResult>((resolve) => {
this.socket = new WebSocket(notificationChannel.receiveFrom);
this.socket.onmessage = (message) => {
const messageData = message.data.toString();
// TODO uncompliant Pod error on misformatted message
this.onNotification(JSON.parse(messageData) as NotificationMessage);
};
this.socket.onerror = (err) =>
resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err));
this.socket.onopen = () => {
resolve({
isError: false,
type: "subscribeToNotificationSuccess",
uri: this.resource.uri,
});
};
});
const notificationChannel = await this.discoverNotificationChannel();
return this.subscribeToWebsocket(notificationChannel);
} catch (err) {
if (
err instanceof Error &&
@ -50,6 +48,45 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript
}
}
async discoverNotificationChannel(): Promise<NotificationChannel> {
const client = new SubscriptionClient(this.context.fetch);
return await client.subscribe(
this.resource.uri,
CHANNEL_TYPE as ChannelType,
);
}
async subscribeToWebsocket(
notificationChannel: NotificationChannel,
): Promise<OpenSubscriptionResult> {
return new Promise<OpenSubscriptionResult>((resolve) => {
let didResolve = false;
this.socket = this.createWebsocket(
notificationChannel.receiveFrom as string,
);
this.socket.onmessage = (message) => {
const messageData = message.data.toString();
// TODO uncompliant Pod error on misformatted message
this.onNotification(JSON.parse(messageData) as NotificationMessage);
};
this.socket.onerror = (err) => {
if (!didResolve) {
resolve(UnexpectedResourceError.fromThrown(this.resource.uri, err));
} else {
this.onError?.(err.error);
}
};
this.socket.onopen = () => {
didResolve = true;
resolve({
isError: false,
type: "subscribeToNotificationSuccess",
uri: this.resource.uri,
});
};
});
}
async close(): Promise<CloseSubscriptionResult> {
this.socket?.terminate();
return {
@ -59,3 +96,7 @@ export class Websocket2023NotificationSubscription extends NotificationSubscript
};
}
}
function createWebsocketDefault(address: string) {
return new WebSocket(address);
}

@ -1,5 +1,5 @@
import { ResourceError } from "../../../requester/results/error/ErrorResult";
export class UnsupportedNotificationError extends ResourceError {
readonly type = "unexpectedResourceError" as const;
readonly type = "unsupportedNotificationError" as const;
}

@ -2038,6 +2038,8 @@ describe("Integration", () => {
const subscriptionResult = await resource.subscribeToNotifications();
expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess");
expect(resource.isSubscribedToNotifications()).toBe(true);
await authFetch(SAMPLE_DATA_URI, {
method: "PATCH",
body: 'INSERT DATA { <http://example.org/#spiderman> <http://xmlns.com/foaf/0.1/name> "Peter Parker" . }',
@ -2062,6 +2064,7 @@ describe("Integration", () => {
expect(unsubscribeResponse.type).toBe(
"unsubscribeFromNotificationSuccess",
);
expect(resource.isSubscribedToNotifications()).toBe(false);
await authFetch(SAMPLE_DATA_URI, {
method: "PATCH",
body: 'INSERT DATA { <http://example.org/#spiderman> <http://xmlns.com/foaf/0.1/name> "Miles Morales" . }',
@ -2079,8 +2082,6 @@ describe("Integration", () => {
literal("Miles Morales"),
).size,
).toBe(0);
await resource.unsubscribeFromNotifications();
});
it("handles notification when subscribed to a child that is deleted", async () => {
@ -2191,5 +2192,38 @@ describe("Integration", () => {
await testContainer.unsubscribeFromNotifications();
});
it("returns an error when it cannot subscribe to a notification", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
await app.stop();
const subscriptionResult = await resource.subscribeToNotifications();
expect(subscriptionResult.type).toBe("unexpectedResourceError");
await app.start();
});
it("returns an error when the server doesnt support websockets", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
await app.stop();
const disabledWebsocketsApp = await createApp(
path.join(__dirname, "./configs/server-config-without-websocket.json"),
);
await disabledWebsocketsApp.start();
const subscriptionResult = await resource.subscribeToNotifications();
expect(subscriptionResult.type).toBe("unsupportedNotificationError");
await disabledWebsocketsApp.stop();
await app.start();
});
it("causes no problems when unsubscribing when not subscribed", async () => {
const resource = solidLdoDataset.getResource(SAMPLE_DATA_URI);
const result = await resource.unsubscribeFromNotifications();
expect(result.type).toBe("unsubscribeFromNotificationSuccess");
});
});
});

@ -0,0 +1,57 @@
import type { WebSocket, Event, ErrorEvent } from "ws";
import { Websocket2023NotificationSubscription } from "../src/resource/notifications/Websocket2023NotificationSubscription";
import type { SolidLdoDatasetContext } from "../src";
import { Leaf } from "../src";
import type { NotificationChannel } from "@solid-notifications/types";
describe("Websocket2023NotificationSubscription", () => {
it("returns an error when websockets have an error", async () => {
const WebSocketMock: WebSocket = {} as WebSocket;
const onErrorMock = jest.fn();
const subscription = new Websocket2023NotificationSubscription(
new Leaf("https://example.com", {
fetch,
} as unknown as SolidLdoDatasetContext),
() => {},
onErrorMock,
{} as unknown as SolidLdoDatasetContext,
() => WebSocketMock,
);
const subPromise = subscription.subscribeToWebsocket({
receiveFrom: "http://example.com",
} as unknown as NotificationChannel);
WebSocketMock.onopen?.({} as Event);
const subscriptionResult = await subPromise;
expect(subscriptionResult.type).toBe("subscribeToNotificationSuccess");
WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent);
expect(onErrorMock).toHaveBeenCalled();
});
it("returns an error when websockets have an error at the beginning", async () => {
const WebSocketMock: WebSocket = {} as WebSocket;
const onErrorMock = jest.fn();
const subscription = new Websocket2023NotificationSubscription(
new Leaf("https://example.com", {
fetch,
} as unknown as SolidLdoDatasetContext),
() => {},
onErrorMock,
{} as unknown as SolidLdoDatasetContext,
() => WebSocketMock,
);
const subPromise = subscription.subscribeToWebsocket({
receiveFrom: "http://example.com",
} as unknown as NotificationChannel);
WebSocketMock.onerror?.({ error: new Error("Test Error") } as ErrorEvent);
const subscriptionResult = await subPromise;
expect(subscriptionResult.type).toBe("unexpectedResourceError");
});
});

@ -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."
]
}
]
}

@ -14,7 +14,7 @@ export const WEB_ID =
// Use an increased timeout, since the CSS server takes too much setup time.
jest.setTimeout(40_000);
export async function createApp(): Promise<App> {
export async function createApp(customConfigPath?: string): Promise<App> {
if (process.env.SERVER) {
return {
start: () => {},
@ -28,7 +28,7 @@ export async function createApp(): Promise<App> {
mainModulePath: resolveModulePath(""),
typeChecking: false,
},
config: resolveModulePath("config/file-root.json"),
config: customConfigPath ?? resolveModulePath("config/file-root.json"),
variableBindings: {},
shorthand: {
port: 3_001,

Loading…
Cancel
Save