diff --git a/.github/workflows/cth-test.yml b/.github/workflows/cth-test.yml
index aa2486609..b58380e85 100644
--- a/.github/workflows/cth-test.yml
+++ b/.github/workflows/cth-test.yml
@@ -42,7 +42,7 @@ jobs:
with:
node-version: 16.x
- name: Check out the project
- uses: actions/checkout@v3.3.0
+ uses: actions/checkout@v3.5.2
with:
ref: ${{ inputs.branch || github.ref }}
- name: Install dependencies and run build scripts
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 0eb3bcf42..a59a58b02 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -21,7 +21,7 @@ jobs:
tags: ${{ steps.meta-main.outputs.tags || steps.meta-version.outputs.tags }}
steps:
- name: Checkout
- uses: actions/checkout@v3.3.0
+ uses: actions/checkout@v3.5.2
- if: startsWith(github.ref, 'refs/tags/v') || (github.ref == 'refs/heads/main')
name: Docker meta edge and version tag
id: meta-main
@@ -55,7 +55,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v3.3.0
+ uses: actions/checkout@v3.5.2
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml
index 446b59476..1a7d8918e 100644
--- a/.github/workflows/mkdocs.yml
+++ b/.github/workflows/mkdocs.yml
@@ -21,7 +21,7 @@ jobs:
outputs:
major: ${{ steps.tagged_version.outputs.major || steps.current_version.outputs.major }}
steps:
- - uses: actions/checkout@v3.3.0
+ - uses: actions/checkout@v3.5.2
- uses: actions/setup-node@v3
with:
node-version: '16.x'
@@ -43,7 +43,7 @@ jobs:
runs-on: ubuntu-latest
needs: mkdocs-prep
steps:
- - uses: actions/checkout@v3.3.0
+ - uses: actions/checkout@v3.5.2
- uses: actions/setup-python@v4
with:
python-version: 3.x
@@ -63,7 +63,7 @@ jobs:
needs: [mkdocs-prep, mkdocs]
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3.3.0
+ - uses: actions/checkout@v3.5.2
- uses: actions/setup-node@v3
with:
node-version: '16.x'
diff --git a/.github/workflows/npm-test.yml b/.github/workflows/npm-test.yml
index 6989cd94b..c988d99db 100644
--- a/.github/workflows/npm-test.yml
+++ b/.github/workflows/npm-test.yml
@@ -7,7 +7,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3.3.0
+ - uses: actions/checkout@v3.5.2
- uses: actions/setup-node@v3
with:
node-version: '16.x'
@@ -38,7 +38,7 @@ jobs:
- name: Ensure line endings are consistent
run: git config --global core.autocrlf input
- name: Check out repository
- uses: actions/checkout@v3.3.0
+ uses: actions/checkout@v3.5.2
- name: Install dependencies and run build scripts
run: npm ci
- name: Type-check tests
@@ -81,7 +81,7 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Check out repository
- uses: actions/checkout@v3.3.0
+ uses: actions/checkout@v3.5.2
- name: Install dependencies and run build scripts
run: npm ci
- name: Run integration tests
@@ -105,7 +105,7 @@ jobs:
- name: Ensure line endings are consistent
run: git config --global core.autocrlf input
- name: Check out repository
- uses: actions/checkout@v3.3.0
+ uses: actions/checkout@v3.5.2
- name: Install dependencies and run build scripts
run: npm ci
- name: Run integration tests
@@ -127,7 +127,7 @@ jobs:
with:
node-version: '16.x'
- name: Check out repository
- uses: actions/checkout@v3.3.0
+ uses: actions/checkout@v3.5.2
- name: Install dependencies and run build scripts
run: npm ci
- name: Run deploy tests
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index ed396bcd0..c369edbbb 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -10,7 +10,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v7
+ - uses: actions/stale@v8
with:
debug-only: true
stale-issue-label: 🏚️ abandoned
diff --git a/LICENSE.md b/LICENSE.md
index dfb243b82..0c863f6c0 100644
--- a/LICENSE.md
+++ b/LICENSE.md
@@ -1,6 +1,6 @@
MIT License
-Copyright © 2019–2022 Inrupt Inc. and imec
+Copyright © 2019–2023 Inrupt Inc. and imec
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index fdbdaa7b6..abdeb98ad 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@
[](https://www.npmjs.com/package/@solid/community-server)
[](https://github.com/CommunitySolidServer/CommunitySolidServer/actions)
[](https://coveralls.io/github/CommunitySolidServer/CommunitySolidServer)
+[](https://zenodo.org/badge/latestdoi/265197208)
[](https://github.com/CommunitySolidServer/CommunitySolidServer/discussions)
[](https://gitter.im/CommunitySolidServer/community)
@@ -166,7 +167,9 @@ The Community Solid Server uses [Components.js](https://componentsjs.readthedocs
to specify how modules and components need to be wired together at runtime.
Examples and guidance on configurations
-are available in the [`config` folder](https://github.com/CommunitySolidServer/CommunitySolidServer/tree/main/config).
+are available in the [`config` folder](https://github.com/CommunitySolidServer/CommunitySolidServer/tree/main/config),
+and the [configurations tutorial](https://github.com/CommunitySolidServer/tutorials/blob/main/custom-configurations.md).
+There is also a [configuration generator](https://communitysolidserver.github.io/configuration-generator/).
Recipes for configuring the server can be found at [CommunitySolidServer/recipes](https://github.com/CommunitySolidServer/recipes).
@@ -175,7 +178,7 @@ Recipes for configuring the server can be found at [CommunitySolidServer/recipes
The server allows writing and plugging in custom modules
without altering its base source code.
-The [đź“—Â API documentation](https://communitysolidserver.github.io/CommunitySolidServer/latest/5.x/docs) and
+The [đź“—Â API documentation](https://communitysolidserver.github.io/CommunitySolidServer/5.x/docs) and
the [đź““Â user documentation](https://communitysolidserver.github.io/CommunitySolidServer/)
can help you find your way.
There is also a repository of [📚 comprehensive tutorials](https://github.com/CommunitySolidServer/tutorials/)
diff --git a/config/app/README.md b/config/app/README.md
index 20da89aa8..e3978a41c 100644
--- a/config/app/README.md
+++ b/config/app/README.md
@@ -2,13 +2,6 @@
Options related to the server startup.
-## Base
-
-This is the entry point to the main server setup.
-
-* *default*: The main application. This should only be changed/replaced
- if you want to start from a different kind of class.
-
## Init
Contains a list of initializer that need to be run when starting the server.
@@ -18,6 +11,13 @@ Contains a list of initializer that need to be run when starting the server.
This is only relevant if setup is disabled but root container access is still required.
* *initialize-prefilled-root*: Similar to `initialize-root` but adds some introductory resources to the root container.
+## Main
+
+This is the entry point to the main server setup.
+
+* *default*: The main application. This should only be changed/replaced
+ if you want to start from a different kind of class.
+
## Setup
Handles the setup page the first time the server is started.
diff --git a/config/identity/handler/account-store/default.json b/config/identity/handler/account-store/default.json
index a29dfe06d..1d8297675 100644
--- a/config/identity/handler/account-store/default.json
+++ b/config/identity/handler/account-store/default.json
@@ -24,17 +24,6 @@
"relativePath": "/forgot-password/",
"source": { "@id": "urn:solid-server:default:KeyValueStorage" }
}
- },
- {
- "comment": "Makes sure the expiring storage cleanup timer is stopped when the application needs to stop.",
- "@id": "urn:solid-server:default:Finalizer",
- "@type": "ParallelHandler",
- "handlers": [
- {
- "@type": "FinalizableHandler",
- "finalizable": { "@id": "urn:solid-server:default:ExpiringForgotPasswordStorage" }
- }
- ]
}
]
}
diff --git a/config/identity/ownership/token.json b/config/identity/ownership/token.json
index b23240d40..e2e9f0006 100644
--- a/config/identity/ownership/token.json
+++ b/config/identity/ownership/token.json
@@ -17,17 +17,6 @@
"relativePath": "/idp/tokens/",
"source": { "@id": "urn:solid-server:default:KeyValueStorage" }
}
- },
- {
- "comment": "Makes sure the expiring storage cleanup timer is stopped when the application needs to stop.",
- "@id": "urn:solid-server:default:Finalizer",
- "@type": "ParallelHandler",
- "handlers": [
- {
- "@type": "FinalizableHandler",
- "finalizable": { "@id": "urn:solid-server:default:ExpiringTokenStorage" }
- }
- ]
}
]
}
diff --git a/config/util/resource-locker/file.json b/config/util/resource-locker/file.json
index 06023b130..9ec454d20 100644
--- a/config/util/resource-locker/file.json
+++ b/config/util/resource-locker/file.json
@@ -6,18 +6,12 @@
"@id": "urn:solid-server:default:ResourceLocker",
"@type": "WrappedExpiringReadWriteLocker",
"locker": {
- "@type": "GreedyReadWriteLocker",
+ "@type": "EqualReadWriteLocker",
"locker": {
"@id": "urn:solid-server:default:FileSystemResourceLocker",
"@type": "FileSystemResourceLocker",
"args_rootFilePath": { "@id": "urn:solid-server:default:variable:rootFilePath" }
- },
- "storage": {
- "@id": "urn:solid-server:default:LockStorage"
- },
- "suffixes_count": "count",
- "suffixes_read": "read",
- "suffixes_write": "write"
+ }
},
"expiration": 6000
},
diff --git a/documentation/markdown/README.md b/documentation/markdown/README.md
index ea3dadc02..5a83ef297 100644
--- a/documentation/markdown/README.md
+++ b/documentation/markdown/README.md
@@ -45,6 +45,7 @@ the [changelog](https://github.com/CommunitySolidServer/CommunitySolidServer/blo
## Comprehensive guides and tutorials
* [The CSS tutorial repository](https://github.com/CommunitySolidServer/tutorials/)
+* [CSS configuration generator](https://communitysolidserver.github.io/configuration-generator/)
## Making changes
diff --git a/documentation/markdown/usage/client-credentials.md b/documentation/markdown/usage/client-credentials.md
index faf9f08a7..7889e85fc 100644
--- a/documentation/markdown/usage/client-credentials.md
+++ b/documentation/markdown/usage/client-credentials.md
@@ -40,6 +40,11 @@ const response = await fetch('http://localhost:3000/idp/credentials/', {
const { id, secret } = await response.json();
```
+If there is something wrong with your input the response code will be 500.
+If no account is linked to the email,
+the message will be "Account does not exist" and
+if the password is wrong it will be "Incorrect password".
+
## Requesting an Access token
The ID and secret combination generated above can be used to request an Access Token from the server.
diff --git a/package-lock.json b/package-lock.json
index 3ab547916..5026e8283 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,7 +36,7 @@
"@types/uuid": "^9.0.0",
"@types/ws": "^8.5.3",
"@types/yargs": "^17.0.10",
- "arrayify-stream": "^2.0.0",
+ "arrayify-stream": "^2.0.1",
"async-lock": "^1.3.2",
"bcryptjs": "^2.4.3",
"componentsjs": "^5.3.2",
@@ -5238,9 +5238,9 @@
}
},
"node_modules/arrayify-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/arrayify-stream/-/arrayify-stream-2.0.0.tgz",
- "integrity": "sha512-Z2NRtxpWQIz3NRA2bEZOziIungBH+fpsFFEolc5u8uVRheYitvsDNvejlfyh/hjZ9VyS9Ba62oY0zc5oa6Wu7g=="
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/arrayify-stream/-/arrayify-stream-2.0.1.tgz",
+ "integrity": "sha512-z8fB6PtmnewQpFB53piS2d1KlUi3BPMICH2h7leCOUXpQcwvZ4GbHHSpdKoUrgLMR6b4Qan/uDe1St3Ao3yIHg=="
},
"node_modules/arrify": {
"version": "1.0.1",
@@ -5636,9 +5636,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001374",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001374.tgz",
- "integrity": "sha512-mWvzatRx3w+j5wx/mpFN5v5twlPrabG8NqX2c6e45LCpymdoGqNvRkRutFUqpRTXKFQFNQJasvK0YT7suW6/Hw==",
+ "version": "1.0.30001458",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz",
+ "integrity": "sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w==",
"dev": true,
"funding": [
{
@@ -9477,9 +9477,9 @@
}
},
"node_modules/http-cache-semantics": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
- "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
},
"node_modules/http-errors": {
"version": "1.8.1",
@@ -19766,9 +19766,9 @@
}
},
"arrayify-stream": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/arrayify-stream/-/arrayify-stream-2.0.0.tgz",
- "integrity": "sha512-Z2NRtxpWQIz3NRA2bEZOziIungBH+fpsFFEolc5u8uVRheYitvsDNvejlfyh/hjZ9VyS9Ba62oY0zc5oa6Wu7g=="
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/arrayify-stream/-/arrayify-stream-2.0.1.tgz",
+ "integrity": "sha512-z8fB6PtmnewQpFB53piS2d1KlUi3BPMICH2h7leCOUXpQcwvZ4GbHHSpdKoUrgLMR6b4Qan/uDe1St3Ao3yIHg=="
},
"arrify": {
"version": "1.0.1",
@@ -20057,9 +20057,9 @@
}
},
"caniuse-lite": {
- "version": "1.0.30001374",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001374.tgz",
- "integrity": "sha512-mWvzatRx3w+j5wx/mpFN5v5twlPrabG8NqX2c6e45LCpymdoGqNvRkRutFUqpRTXKFQFNQJasvK0YT7suW6/Hw==",
+ "version": "1.0.30001458",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz",
+ "integrity": "sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w==",
"dev": true
},
"canonicalize": {
@@ -22981,9 +22981,9 @@
}
},
"http-cache-semantics": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
- "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
+ "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
},
"http-errors": {
"version": "1.8.1",
diff --git a/package.json b/package.json
index 6bc251f29..73d9d2516 100644
--- a/package.json
+++ b/package.json
@@ -126,7 +126,7 @@
"@types/uuid": "^9.0.0",
"@types/ws": "^8.5.3",
"@types/yargs": "^17.0.10",
- "arrayify-stream": "^2.0.0",
+ "arrayify-stream": "^2.0.1",
"async-lock": "^1.3.2",
"bcryptjs": "^2.4.3",
"componentsjs": "^5.3.2",
diff --git a/src/http/ldp/GetOperationHandler.ts b/src/http/ldp/GetOperationHandler.ts
index 6525016d1..5f7de96ba 100644
--- a/src/http/ldp/GetOperationHandler.ts
+++ b/src/http/ldp/GetOperationHandler.ts
@@ -1,5 +1,6 @@
import type { ResourceStore } from '../../storage/ResourceStore';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
+import { assertReadConditions } from '../../util/ResourceUtil';
import { OkResponseDescription } from '../output/response/OkResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import type { OperationHandlerInput } from './OperationHandler';
@@ -26,6 +27,9 @@ export class GetOperationHandler extends OperationHandler {
public async handle({ operation }: OperationHandlerInput): Promise {
const body = await this.store.getRepresentation(operation.target, operation.preferences, operation.conditions);
+ // Check whether the cached representation is still valid or it is necessary to send a new representation
+ assertReadConditions(body, operation.conditions);
+
return new OkResponseDescription(body.metadata, body.data);
}
}
diff --git a/src/http/ldp/HeadOperationHandler.ts b/src/http/ldp/HeadOperationHandler.ts
index 6bb7676da..276c57229 100644
--- a/src/http/ldp/HeadOperationHandler.ts
+++ b/src/http/ldp/HeadOperationHandler.ts
@@ -1,5 +1,6 @@
import type { ResourceStore } from '../../storage/ResourceStore';
import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError';
+import { assertReadConditions } from '../../util/ResourceUtil';
import { OkResponseDescription } from '../output/response/OkResponseDescription';
import type { ResponseDescription } from '../output/response/ResponseDescription';
import type { OperationHandlerInput } from './OperationHandler';
@@ -29,6 +30,10 @@ export class HeadOperationHandler extends OperationHandler {
// Close the Readable as we will not return it.
body.data.destroy();
+ // Check whether the cached representation is still valid or it is necessary to send a new representation.
+ // Generally it doesn't make much sense to use condition headers with a HEAD request, but it should be supported.
+ assertReadConditions(body, operation.conditions);
+
return new OkResponseDescription(body.metadata);
}
}
diff --git a/src/identity/configuration/IdentityProviderFactory.ts b/src/identity/configuration/IdentityProviderFactory.ts
index dd7b0f704..a075221a0 100644
--- a/src/identity/configuration/IdentityProviderFactory.ts
+++ b/src/identity/configuration/IdentityProviderFactory.ts
@@ -18,7 +18,10 @@ import type { ResponseWriter } from '../../http/output/ResponseWriter';
import { BasicRepresentation } from '../../http/representation/BasicRepresentation';
import { getLoggerFor } from '../../logging/LogUtil';
import type { KeyValueStorage } from '../../storage/keyvalue/KeyValueStorage';
+import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
+import type { HttpError } from '../../util/errors/HttpError';
import { InternalServerError } from '../../util/errors/InternalServerError';
+import { OAuthHttpError } from '../../util/errors/OAuthHttpError';
import { RedirectHttpError } from '../../util/errors/RedirectHttpError';
import { guardStream } from '../../util/GuardedStream';
import { joinUrl } from '../../util/PathUtil';
@@ -360,7 +363,8 @@ export class IdentityProviderFactory implements ProviderFactory {
// Doesn't really matter which type it is since all relevant fields are optional
const oidcError = error as errors.OIDCProviderError;
- let detailedError = error.message;
+ // Create a more detailed error message for logging and to show is `showStackTrace` is enabled.
+ let detailedError = oidcError.message;
if (oidcError.error_description) {
detailedError += ` - ${oidcError.error_description}`;
}
@@ -370,17 +374,41 @@ export class IdentityProviderFactory implements ProviderFactory {
this.logger.warn(`OIDC request failed: ${detailedError}`);
- // OIDC library hides extra details in these fields
+ // Convert to our own error object.
+ // This ensures serializing the error object will generate the correct output later on.
+ // We specifically copy the fields instead of passing the object to contain the `oidc-provider` dependency
+ // to the current file.
+ let resultingError: HttpError = new OAuthHttpError(out, oidcError.name, oidcError.statusCode, oidcError.message);
+ // Keep the original stack to make debugging easier
+ resultingError.stack = oidcError.stack;
+
if (this.showStackTrace) {
- error.message = detailedError;
+ // Expose more information if `showStackTrace` is enabled
+ resultingError.message = detailedError;
// Also change the error message in the stack trace
- if (error.stack) {
- error.stack = error.stack.replace(/.*/u, `${error.name}: ${error.message}`);
+ if (resultingError.stack) {
+ resultingError.stack = resultingError.stack.replace(/.*/u, `${oidcError.name}: ${oidcError.message}`);
}
}
- const result = await this.errorHandler.handleSafe({ error, request: guardStream(ctx.req) });
+ // A client not being found is quite often the result of cookies being stored by the authn client,
+ // so we want to provide a more detailed error message explaining what to do.
+ if (oidcError.error_description === 'client is invalid' && oidcError.error_detail === 'client not found') {
+ const unknownClientError = new BadRequestHttpError(
+ 'Unknown client, you might need to clear the local storage on the client.', {
+ errorCode: 'E0003',
+ details: {
+ client_id: ctx.request.query.client_id,
+ redirect_uri: ctx.request.query.redirect_uri,
+ },
+ },
+ );
+ unknownClientError.stack = oidcError.stack;
+ resultingError = unknownClientError;
+ }
+
+ const result = await this.errorHandler.handleSafe({ error: resultingError, request: guardStream(ctx.req) });
await this.responseWriter.handleSafe({ response: ctx.res, result });
};
}
diff --git a/src/index.ts b/src/index.ts
index 866a5faaf..80cf6092a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -469,6 +469,7 @@ export * from './util/errors/MethodNotAllowedHttpError';
export * from './util/errors/MovedPermanentlyHttpError';
export * from './util/errors/NotFoundHttpError';
export * from './util/errors/NotImplementedHttpError';
+export * from './util/errors/OAuthHttpError';
export * from './util/errors/PreconditionFailedHttpError';
export * from './util/errors/RedirectHttpError';
export * from './util/errors/SystemError';
diff --git a/src/init/SeededPodInitializer.ts b/src/init/SeededPodInitializer.ts
index ec9f11940..1e76fb292 100644
--- a/src/init/SeededPodInitializer.ts
+++ b/src/init/SeededPodInitializer.ts
@@ -1,6 +1,7 @@
import { readJson } from 'fs-extra';
import type { RegistrationManager } from '../identity/interaction/email-password/util/RegistrationManager';
import { getLoggerFor } from '../logging/LogUtil';
+import { createErrorMessage } from '../util/errors/ErrorUtil';
import { Initializer } from './Initializer';
/**
@@ -42,10 +43,15 @@ export class SeededPodInitializer extends Initializer {
this.logger.debug(`Validated input: ${JSON.stringify(validated)}`);
// Register and/or create a pod as requested. Potentially does nothing if all booleans are false.
- await this.registrationManager.register(validated, true);
- this.logger.info(`Initialized seeded pod and account for "${input.podName}".`);
- count += 1;
+ try {
+ await this.registrationManager.register(validated, true);
+ this.logger.info(`Initialized seeded pod and account for "${input.podName}".`);
+ count += 1;
+ } catch (error: unknown) {
+ this.logger.warn(`Error while initializing seeded pod: ${createErrorMessage(error)})}`);
+ }
}
+
this.logger.info(`Initialized ${count} seeded pods.`);
}
}
diff --git a/src/init/variables/extractors/BaseUrlExtractor.ts b/src/init/variables/extractors/BaseUrlExtractor.ts
index 939e0514b..665938df5 100644
--- a/src/init/variables/extractors/BaseUrlExtractor.ts
+++ b/src/init/variables/extractors/BaseUrlExtractor.ts
@@ -22,6 +22,8 @@ export class BaseUrlExtractor extends ShorthandExtractor {
throw new Error('BaseUrl argument should be provided when using Unix Domain Sockets.');
}
const port = args.port ?? this.defaultPort;
- return `http://localhost:${port}/`;
+ const url = new URL('http://localhost/');
+ url.port = `${port}`;
+ return url.href;
}
}
diff --git a/src/storage/BasicConditions.ts b/src/storage/BasicConditions.ts
index e87acaf99..8affe5736 100644
--- a/src/storage/BasicConditions.ts
+++ b/src/storage/BasicConditions.ts
@@ -1,6 +1,6 @@
import type { RepresentationMetadata } from '../http/representation/RepresentationMetadata';
import { DC } from '../util/Vocabularies';
-import { getETag } from './Conditions';
+import { getETag, isCurrentETag } from './Conditions';
import type { Conditions } from './Conditions';
export interface BasicConditionsOptions {
@@ -26,40 +26,43 @@ export class BasicConditions implements Conditions {
this.unmodifiedSince = options.unmodifiedSince;
}
- public matchesMetadata(metadata?: RepresentationMetadata): boolean {
+ public matchesMetadata(metadata?: RepresentationMetadata, strict?: boolean): boolean {
if (!metadata) {
// RFC7232: ...If-Match... If the field-value is "*", the condition is false if the origin server
// does not have a current representation for the target resource.
return !this.matchesETag?.includes('*');
}
- const modified = metadata.get(DC.terms.modified);
- const modifiedDate = modified ? new Date(modified.value) : undefined;
- const etag = getETag(metadata);
- return this.matches(etag, modifiedDate);
- }
-
- public matches(eTag?: string, lastModified?: Date): boolean {
// RFC7232: ...If-None-Match... If the field-value is "*", the condition is false if the origin server
// has a current representation for the target resource.
if (this.notMatchesETag?.includes('*')) {
return false;
}
- if (eTag) {
- if (this.matchesETag && !this.matchesETag.includes(eTag) && !this.matchesETag.includes('*')) {
- return false;
- }
- if (this.notMatchesETag?.includes(eTag)) {
- return false;
- }
+ // Helper function to see if an ETag matches the provided metadata
+ // eslint-disable-next-line func-style
+ let eTagMatches = (tag: string): boolean => isCurrentETag(tag, metadata);
+ if (strict) {
+ const eTag = getETag(metadata);
+ eTagMatches = (tag: string): boolean => tag === eTag;
}
- if (lastModified) {
- if (this.modifiedSince && lastModified < this.modifiedSince) {
+ if (this.matchesETag && !this.matchesETag.includes('*') && !this.matchesETag.some(eTagMatches)) {
+ return false;
+ }
+ if (this.notMatchesETag?.some(eTagMatches)) {
+ return false;
+ }
+
+ // In practice, this will only be undefined on a backend
+ // that doesn't store the modified date.
+ const modified = metadata.get(DC.terms.modified);
+ if (modified) {
+ const modifiedDate = new Date(modified.value);
+ if (this.modifiedSince && modifiedDate < this.modifiedSince) {
return false;
}
- if (this.unmodifiedSince && lastModified > this.unmodifiedSince) {
+ if (this.unmodifiedSince && modifiedDate > this.unmodifiedSince) {
return false;
}
}
diff --git a/src/storage/Conditions.ts b/src/storage/Conditions.ts
index c31f8d247..586252de3 100644
--- a/src/storage/Conditions.ts
+++ b/src/storage/Conditions.ts
@@ -25,16 +25,12 @@ export interface Conditions {
/**
* Checks validity based on the given metadata.
* @param metadata - Metadata of the representation. Undefined if the resource does not exist.
+ * @param strict - How to compare the ETag related headers.
+ * If true, exact string matching will be used to compare with the ETag for the given metadata.
+ * If false, it will take into account that content negotiation might still happen
+ * which can change the ETag.
*/
- matchesMetadata: (metadata?: RepresentationMetadata) => boolean;
- /**
- * Checks validity based on the given ETag and/or date.
- * This function assumes the resource being checked exists.
- * If not, the `matchesMetadata` function should be used.
- * @param eTag - Condition based on ETag.
- * @param lastModified - Condition based on last modified date.
- */
- matches: (eTag?: string, lastModified?: Date) => boolean;
+ matchesMetadata: (metadata?: RepresentationMetadata, strict?: boolean) => boolean;
}
/**
@@ -45,8 +41,32 @@ export interface Conditions {
*/
export function getETag(metadata: RepresentationMetadata): string | undefined {
const modified = metadata.get(DC.terms.modified);
- if (modified) {
+ const { contentType } = metadata;
+ if (modified && contentType) {
const date = new Date(modified.value);
- return `"${date.getTime()}"`;
+ return `"${date.getTime()}-${contentType}"`;
}
}
+
+/**
+ * Validates whether a given ETag corresponds to the current state of the resource,
+ * independent of the representation the ETag corresponds to.
+ * Assumes ETags are made with the {@link getETag} function.
+ * Since we base the ETag on the last modified date,
+ * we know the ETag still matches as long as that didn't change.
+ *
+ * @param eTag - ETag to validate.
+ * @param metadata - Metadata of the resource.
+ *
+ * @returns `true` if the ETag represents the current state of the resource.
+ */
+export function isCurrentETag(eTag: string, metadata: RepresentationMetadata): boolean {
+ const modified = metadata.get(DC.terms.modified);
+ if (!modified) {
+ return false;
+ }
+ const time = eTag.split('-', 1)[0];
+ const date = new Date(modified.value);
+ // `time` will still have the initial`"` of the ETag string
+ return time === `"${date.getTime()}`;
+}
diff --git a/src/storage/conversion/ErrorToJsonConverter.ts b/src/storage/conversion/ErrorToJsonConverter.ts
index 2f33c44f8..986a13888 100644
--- a/src/storage/conversion/ErrorToJsonConverter.ts
+++ b/src/storage/conversion/ErrorToJsonConverter.ts
@@ -2,6 +2,7 @@ import { BasicRepresentation } from '../../http/representation/BasicRepresentati
import type { Representation } from '../../http/representation/Representation';
import { APPLICATION_JSON, INTERNAL_ERROR } from '../../util/ContentTypes';
import { HttpError } from '../../util/errors/HttpError';
+import { OAuthHttpError } from '../../util/errors/OAuthHttpError';
import { getSingleItem } from '../../util/StreamUtil';
import { BaseTypedRepresentationConverter } from './BaseTypedRepresentationConverter';
import type { RepresentationConverterArgs } from './RepresentationConverter';
@@ -22,6 +23,11 @@ export class ErrorToJsonConverter extends BaseTypedRepresentationConverter {
message: error.message,
};
+ // OAuth errors responses require additional fields
+ if (OAuthHttpError.isInstance(error)) {
+ Object.assign(result, error.mandatoryFields);
+ }
+
if (HttpError.isInstance(error)) {
result.statusCode = error.statusCode;
result.errorCode = error.errorCode;
diff --git a/src/storage/keyvalue/WrappedExpiringStorage.ts b/src/storage/keyvalue/WrappedExpiringStorage.ts
index d94196a2f..32a9f062c 100644
--- a/src/storage/keyvalue/WrappedExpiringStorage.ts
+++ b/src/storage/keyvalue/WrappedExpiringStorage.ts
@@ -1,4 +1,3 @@
-import type { Finalizable } from '../../init/final/Finalizable';
import { getLoggerFor } from '../../logging/LogUtil';
import { InternalServerError } from '../../util/errors/InternalServerError';
import { setSafeInterval } from '../../util/TimerUtil';
@@ -13,7 +12,7 @@ export type Expires = { expires?: string; payload: T };
* Will delete expired entries when trying to get their value.
* Has a timer that will delete all expired data every hour (default value).
*/
-export class WrappedExpiringStorage implements ExpiringStorage, Finalizable {
+export class WrappedExpiringStorage implements ExpiringStorage {
protected readonly logger = getLoggerFor(this);
private readonly source: KeyValueStorage>;
private readonly timer: NodeJS.Timeout;
@@ -28,6 +27,7 @@ export class WrappedExpiringStorage implements ExpiringStorage {
@@ -121,11 +121,4 @@ export class WrappedExpiringStorage implements ExpiringStorage {
- clearInterval(this.timer);
- }
}
diff --git a/src/util/ResourceUtil.ts b/src/util/ResourceUtil.ts
index f06e593e1..43de0b121 100644
--- a/src/util/ResourceUtil.ts
+++ b/src/util/ResourceUtil.ts
@@ -3,6 +3,8 @@ import { DataFactory } from 'n3';
import { BasicRepresentation } from '../http/representation/BasicRepresentation';
import type { Representation } from '../http/representation/Representation';
import { RepresentationMetadata } from '../http/representation/RepresentationMetadata';
+import type { Conditions } from '../storage/Conditions';
+import { NotModifiedHttpError } from './errors/NotModifiedHttpError';
import { guardedStreamFrom } from './StreamUtil';
import { toLiteral } from './TermUtil';
import { CONTENT_TYPE_TERM, DC, LDP, RDF, SOLID_META, XSD } from './Vocabularies';
@@ -65,3 +67,25 @@ export async function cloneRepresentation(representation: Representation): Promi
representation.data = guardedStreamFrom(data);
return result;
}
+
+/**
+ * Verify whether the given {@link Representation} matches the given conditions.
+ * If not, destroy the data stream and throw a {@link NotModifiedHttpError}.
+ * If `conditions` is not defined, nothing will happen.
+ *
+ * This uses the strict conditions check which takes the content type into account;
+ * therefore, this should only be called after content negotiation, when it is certain what the output will be.
+ *
+ * Note that browsers only keep track of one ETag, and the Vary header has no impact on this,
+ * meaning the browser could send us the ETag for a Turtle resource even though it is requesting JSON-LD;
+ * this is why we have to check ETags after content negotiation.
+ *
+ * @param body - The representation to compare the conditions against.
+ * @param conditions - The conditions to assert.
+ */
+export function assertReadConditions(body: Representation, conditions?: Conditions): void {
+ if (conditions && !conditions.matchesMetadata(body.metadata, true)) {
+ body.data.destroy();
+ throw new NotModifiedHttpError();
+ }
+}
diff --git a/src/util/errors/NotModifiedHttpError.ts b/src/util/errors/NotModifiedHttpError.ts
new file mode 100644
index 000000000..9504de3b7
--- /dev/null
+++ b/src/util/errors/NotModifiedHttpError.ts
@@ -0,0 +1,14 @@
+import type { HttpErrorOptions } from './HttpError';
+import { generateHttpErrorClass } from './HttpError';
+
+// eslint-disable-next-line @typescript-eslint/naming-convention
+const BaseHttpError = generateHttpErrorClass(304, 'NotModifiedHttpError');
+
+/**
+ * An error is thrown when a request conflicts with the current state of the server.
+ */
+export class NotModifiedHttpError extends BaseHttpError {
+ public constructor(message?: string, options?: HttpErrorOptions) {
+ super(message, options);
+ }
+}
diff --git a/src/util/errors/OAuthHttpError.ts b/src/util/errors/OAuthHttpError.ts
new file mode 100644
index 000000000..715f1af3e
--- /dev/null
+++ b/src/util/errors/OAuthHttpError.ts
@@ -0,0 +1,36 @@
+import type { HttpErrorOptions } from './HttpError';
+import { HttpError } from './HttpError';
+
+/**
+ * These are the fields that can occur in an OAuth error response as described in RFC 6749, §4.1.2.1.
+ * https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
+ *
+ * This interface is identical to the ErrorOut interface of the `oidc-provider` library,
+ * but having our own version reduces the part of the codebase that is dependent on that library.
+ */
+export interface OAuthErrorFields {
+ error: string;
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ error_description?: string | undefined;
+ scope?: string | undefined;
+ state?: string | undefined;
+}
+
+/**
+ * Represents on OAuth error that is being thrown.
+ * OAuth error responses have additional fields that need to be present in the JSON response,
+ * as described in RFC 6749, §4.1.2.1.
+ */
+export class OAuthHttpError extends HttpError {
+ public readonly mandatoryFields: OAuthErrorFields;
+
+ public constructor(mandatoryFields: OAuthErrorFields, name?: string, statusCode?: number, message?: string,
+ options?: HttpErrorOptions) {
+ super(statusCode ?? 500, name ?? 'OAuthHttpError', message, options);
+ this.mandatoryFields = mandatoryFields;
+ }
+
+ public static isInstance(error: unknown): error is OAuthHttpError {
+ return HttpError.isInstance(error) && Boolean((error as OAuthHttpError).mandatoryFields);
+ }
+}
diff --git a/src/util/locking/FileSystemResourceLocker.ts b/src/util/locking/FileSystemResourceLocker.ts
index 57ed70a19..6ad8018cd 100644
--- a/src/util/locking/FileSystemResourceLocker.ts
+++ b/src/util/locking/FileSystemResourceLocker.ts
@@ -53,6 +53,9 @@ function isCodedError(err: unknown): err is { code: string } & Error {
/**
* A resource locker making use of the [proper-lockfile](https://www.npmjs.com/package/proper-lockfile) library.
* Note that no locks are kept in memory, thus this is considered thread- and process-safe.
+ * While it stores the actual locks on disk, it also tracks them in memory for when they need to be released.
+ * This means only the worker thread that acquired a lock can release it again,
+ * making this implementation unusable in combination with a wrapping read/write lock implementation.
*
* This **proper-lockfile** library has its own retry mechanism for the operations, since a lock/unlock call will
* either resolve successfully or reject immediately with the causing error. The retry function of the library
diff --git a/templates/error/descriptions/E0003.md.hbs b/templates/error/descriptions/E0003.md.hbs
new file mode 100644
index 000000000..1bd8513e3
--- /dev/null
+++ b/templates/error/descriptions/E0003.md.hbs
@@ -0,0 +1,26 @@
+# Authenticating with unknown client
+You are trying to log in to an application,
+but we can't proceed
+because the app is using invalid settings.
+
+To force the app to send us the right details,
+delete the local storage in your browser for the site that sent you here.
+Based on the data the app sent us,
+this is probably `{{ redirect_uri }}`.
+
+## Detailed error information
+We received a request from a client with ID `{{ client_id }}`,
+but this client is not registered with the server.
+
+Probably,
+this client was registered with the server in the past,
+but it is no longer recognized
+because some internal server data was removed.
+Your data is still safe;
+we just don't recognize the app's previous authentication anymore.
+
+Because your browser still has the old authentication settings stored,
+it tries to use them instead of setting up new ones.
+By clearing those settings,
+the application should automatically create a new client,
+allowing you to log in again.
diff --git a/templates/main.html.ejs b/templates/main.html.ejs
index f0e7f20a4..7d664f5ea 100644
--- a/templates/main.html.ejs
+++ b/templates/main.html.ejs
@@ -17,7 +17,7 @@
diff --git a/templates/root/prefilled/base/index.html b/templates/root/prefilled/base/index.html
index e8170600f..c116e399d 100644
--- a/templates/root/prefilled/base/index.html
+++ b/templates/root/prefilled/base/index.html
@@ -30,13 +30,20 @@
If you want to keep data permanently,
choose a configuration that saves data to disk instead.
+
+ To learn more about how this server can be used,
+ have a look at the
+ getting started tutorial.
+
Getting started as a developer
- Run the setup to configure your server.
-
The default configuration includes
the ready-to-use root Pod you're currently looking at.
+
+ Besides the provided configurations,
+ you can also fine-tune your own custom configuration using the
+ configuration generator.
You can easily choose any folder on your disk
@@ -58,7 +65,7 @@
diff --git a/test/integration/Conditions.test.ts b/test/integration/Conditions.test.ts
index 4980a7480..574681e95 100644
--- a/test/integration/Conditions.test.ts
+++ b/test/integration/Conditions.test.ts
@@ -170,6 +170,34 @@ describe.each(stores)('A server supporting conditions with %s', (name, { storeCo
expect(await deleteResource(documentUrl!)).toBeUndefined();
});
+ it('throws 304 error if "if-none-match" header matches and request type is GET or HEAD.', async(): Promise => {
+ // GET root ETag
+ let response = await getResource(baseUrl);
+ const eTag = response.headers.get('ETag');
+ expect(typeof eTag).toBe('string');
+
+ // GET fails because of header
+ response = await fetch(baseUrl, {
+ method: 'GET',
+ headers: { 'if-none-match': eTag! },
+ });
+ expect(response.status).toBe(304);
+
+ // HEAD fails because of header
+ response = await fetch(baseUrl, {
+ method: 'HEAD',
+ headers: { 'if-none-match': eTag! },
+ });
+ expect(response.status).toBe(304);
+
+ // GET succeeds if the ETag header doesn't match
+ response = await fetch(baseUrl, {
+ method: 'GET',
+ headers: { 'if-none-match': '"123456"' },
+ });
+ expect(response.status).toBe(200);
+ });
+
it('prevents operations if the "if-unmodified-since" header is before the modified date.', async(): Promise => {
const documentUrl = `${baseUrl}document3.txt`;
// PUT
@@ -197,4 +225,22 @@ describe.each(stores)('A server supporting conditions with %s', (name, { storeCo
});
expect(response.status).toBe(205);
});
+
+ it('returns different ETags for different content-types.', async(): Promise => {
+ let response = await getResource(baseUrl, { accept: 'text/turtle' }, { contentType: 'text/turtle' });
+ const eTagTurtle = response.headers.get('ETag');
+ response = await getResource(baseUrl, { accept: 'application/ld+json' }, { contentType: 'application/ld+json' });
+ const eTagJson = response.headers.get('ETag');
+ expect(eTagTurtle).not.toEqual(eTagJson);
+
+ // Both ETags can be used on the same resource
+ response = await fetch(baseUrl, { headers: { 'if-none-match': eTagTurtle!, accept: 'text/turtle' }});
+ expect(response.status).toBe(304);
+ response = await fetch(baseUrl, { headers: { 'if-none-match': eTagJson!, accept: 'application/ld+json' }});
+ expect(response.status).toBe(304);
+
+ // But not for the other representation
+ response = await fetch(baseUrl, { headers: { 'if-none-match': eTagTurtle!, accept: 'application/ld+json' }});
+ expect(response.status).toBe(200);
+ });
});
diff --git a/test/integration/Identity.test.ts b/test/integration/Identity.test.ts
index 8b1f62458..02897881d 100644
--- a/test/integration/Identity.test.ts
+++ b/test/integration/Identity.test.ts
@@ -625,6 +625,7 @@ describe('A Solid server with IDP', (): void => {
expect(json.message).toBe(`invalid_request - unrecognized route or not allowed method (GET on /.oidc/foo)`);
expect(json.statusCode).toBe(404);
expect(json.stack).toBeDefined();
+ expect(json.error).toBe('invalid_request');
});
});
});
diff --git a/test/unit/http/ldp/GetOperationHandler.test.ts b/test/unit/http/ldp/GetOperationHandler.test.ts
index b667c70fc..20044c6e1 100644
--- a/test/unit/http/ldp/GetOperationHandler.test.ts
+++ b/test/unit/http/ldp/GetOperationHandler.test.ts
@@ -1,10 +1,13 @@
+import type { Readable } from 'stream';
import { GetOperationHandler } from '../../../../src/http/ldp/GetOperationHandler';
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation';
+import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError';
describe('A GetOperationHandler', (): void => {
let operation: Operation;
@@ -13,12 +16,15 @@ describe('A GetOperationHandler', (): void => {
const body = new BasicRepresentation();
let store: ResourceStore;
let handler: GetOperationHandler;
+ let data: Readable;
+ const metadata = new RepresentationMetadata();
beforeEach(async(): Promise => {
operation = { method: 'GET', target: { path: 'http://test.com/foo' }, preferences, conditions, body };
+ data = { destroy: jest.fn() } as any;
store = {
getRepresentation: jest.fn(async(): Promise =>
- ({ binary: false, data: 'data', metadata: 'metadata' } as any)),
+ ({ binary: false, data, metadata } as any)),
} as unknown as ResourceStore;
handler = new GetOperationHandler(store);
@@ -33,9 +39,17 @@ describe('A GetOperationHandler', (): void => {
it('returns the representation from the store with the correct response.', async(): Promise => {
const result = await handler.handle({ operation });
expect(result.statusCode).toBe(200);
- expect(result.metadata).toBe('metadata');
- expect(result.data).toBe('data');
+ expect(result.metadata).toBe(metadata);
+ expect(result.data).toBe(data);
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions);
});
+
+ it('returns a 304 if the conditions do not match.', async(): Promise => {
+ operation.conditions = {
+ matchesMetadata: (): boolean => false,
+ };
+ await expect(handler.handle({ operation })).rejects.toThrow(NotModifiedHttpError);
+ expect(data.destroy).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/test/unit/http/ldp/HeadOperationHandler.test.ts b/test/unit/http/ldp/HeadOperationHandler.test.ts
index 141c87715..140d98fca 100644
--- a/test/unit/http/ldp/HeadOperationHandler.test.ts
+++ b/test/unit/http/ldp/HeadOperationHandler.test.ts
@@ -3,9 +3,11 @@ import { HeadOperationHandler } from '../../../../src/http/ldp/HeadOperationHand
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../../src/http/representation/Representation';
+import { RepresentationMetadata } from '../../../../src/http/representation/RepresentationMetadata';
import { BasicConditions } from '../../../../src/storage/BasicConditions';
import type { ResourceStore } from '../../../../src/storage/ResourceStore';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError';
describe('A HeadOperationHandler', (): void => {
let operation: Operation;
@@ -15,13 +17,14 @@ describe('A HeadOperationHandler', (): void => {
let store: ResourceStore;
let handler: HeadOperationHandler;
let data: Readable;
+ const metadata = new RepresentationMetadata();
beforeEach(async(): Promise => {
operation = { method: 'HEAD', target: { path: 'http://test.com/foo' }, preferences, conditions, body };
data = { destroy: jest.fn() } as any;
store = {
getRepresentation: jest.fn(async(): Promise =>
- ({ binary: false, data, metadata: 'metadata' } as any)),
+ ({ binary: false, data, metadata } as any)),
} as any;
handler = new HeadOperationHandler(store);
@@ -38,10 +41,18 @@ describe('A HeadOperationHandler', (): void => {
it('returns the representation from the store with the correct response.', async(): Promise => {
const result = await handler.handle({ operation });
expect(result.statusCode).toBe(200);
- expect(result.metadata).toBe('metadata');
+ expect(result.metadata).toBe(metadata);
expect(result.data).toBeUndefined();
expect(data.destroy).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenCalledTimes(1);
expect(store.getRepresentation).toHaveBeenLastCalledWith(operation.target, preferences, conditions);
});
+
+ it('returns a 304 if the conditions do not match.', async(): Promise => {
+ operation.conditions = {
+ matchesMetadata: (): boolean => false,
+ };
+ await expect(handler.handle({ operation })).rejects.toThrow(NotModifiedHttpError);
+ expect(data.destroy).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/test/unit/http/output/metadata/ModifiedMetadataWriter.test.ts b/test/unit/http/output/metadata/ModifiedMetadataWriter.test.ts
index 824fd2b1f..d0ad42bbd 100644
--- a/test/unit/http/output/metadata/ModifiedMetadataWriter.test.ts
+++ b/test/unit/http/output/metadata/ModifiedMetadataWriter.test.ts
@@ -2,21 +2,22 @@ import { createResponse } from 'node-mocks-http';
import { ModifiedMetadataWriter } from '../../../../../src/http/output/metadata/ModifiedMetadataWriter';
import { RepresentationMetadata } from '../../../../../src/http/representation/RepresentationMetadata';
import type { HttpResponse } from '../../../../../src/server/HttpResponse';
+import { getETag } from '../../../../../src/storage/Conditions';
import { updateModifiedDate } from '../../../../../src/util/ResourceUtil';
-import { DC } from '../../../../../src/util/Vocabularies';
+import { CONTENT_TYPE, DC } from '../../../../../src/util/Vocabularies';
describe('A ModifiedMetadataWriter', (): void => {
const writer = new ModifiedMetadataWriter();
it('adds the Last-Modified and ETag header if there is dc:modified metadata.', async(): Promise => {
const response = createResponse() as HttpResponse;
- const metadata = new RepresentationMetadata();
+ const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' });
updateModifiedDate(metadata);
const dateTime = metadata.get(DC.terms.modified)!.value;
await expect(writer.handle({ response, metadata })).resolves.toBeUndefined();
expect(response.getHeaders()).toEqual({
'last-modified': new Date(dateTime).toUTCString(),
- etag: `"${new Date(dateTime).getTime()}"`,
+ etag: getETag(metadata),
});
});
diff --git a/test/unit/identity/configuration/IdentityProviderFactory.test.ts b/test/unit/identity/configuration/IdentityProviderFactory.test.ts
index 805f09bf4..bfaf2c377 100644
--- a/test/unit/identity/configuration/IdentityProviderFactory.test.ts
+++ b/test/unit/identity/configuration/IdentityProviderFactory.test.ts
@@ -14,6 +14,7 @@ import type { Interaction, InteractionHandler } from '../../../../src/identity/i
import type { AdapterFactory } from '../../../../src/identity/storage/AdapterFactory';
import type { KeyValueStorage } from '../../../../src/storage/keyvalue/KeyValueStorage';
import { FoundHttpError } from '../../../../src/util/errors/FoundHttpError';
+import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError';
/* eslint-disable @typescript-eslint/naming-convention */
jest.mock('oidc-provider', (): any => ({
@@ -62,6 +63,10 @@ describe('An IdentityProviderFactory', (): void => {
res: {},
request: {
href: 'http://example.com/idp/',
+ query: {
+ client_id: 'CLIENT_ID',
+ redirect_uri: 'REDIRECT_URI',
+ },
},
accepts: jest.fn().mockReturnValue('type'),
} as any;
@@ -236,14 +241,42 @@ describe('An IdentityProviderFactory', (): void => {
error.error_description = 'more info';
error.error_detail = 'more details';
+ const oAuthError = new OAuthHttpError(error, error.name, 500, 'bad data - more info - more details');
+
await expect((config.renderError as any)(ctx, {}, error)).resolves.toBeUndefined();
expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
expect(errorHandler.handleSafe)
- .toHaveBeenLastCalledWith({ error, request: ctx.req });
+ .toHaveBeenLastCalledWith({ error: oAuthError, request: ctx.req });
+ expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
+ expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response: ctx.res, result: { statusCode: 500 }});
+ expect(oAuthError.message).toBe('bad data - more info - more details');
+ expect(oAuthError.stack).toContain('Error: bad data - more info - more details');
+ });
+
+ it('throws a specific error for unknown clients.', async(): Promise => {
+ const provider = await factory.getProvider() as any;
+ const { config } = provider as { config: Configuration };
+
+ const error = new Error('invalid_client') as errors.OIDCProviderError;
+ error.error_description = 'client is invalid';
+ error.error_detail = 'client not found';
+
+ await expect((config.renderError as any)(ctx, {}, error)).resolves.toBeUndefined();
+ expect(errorHandler.handleSafe).toHaveBeenCalledTimes(1);
+ expect(errorHandler.handleSafe)
+ .toHaveBeenLastCalledWith({ error: expect.objectContaining({
+ statusCode: 400,
+ name: 'BadRequestHttpError',
+ message: 'Unknown client, you might need to clear the local storage on the client.',
+ errorCode: 'E0003',
+ details: {
+ client_id: 'CLIENT_ID',
+ redirect_uri: 'REDIRECT_URI',
+ },
+ }),
+ request: ctx.req });
expect(responseWriter.handleSafe).toHaveBeenCalledTimes(1);
expect(responseWriter.handleSafe).toHaveBeenLastCalledWith({ response: ctx.res, result: { statusCode: 500 }});
- expect(error.message).toBe('bad data - more info - more details');
- expect(error.stack).toContain('Error: bad data - more info - more details');
});
it('adds middleware to make the OIDC provider think the request wants HTML.', async(): Promise => {
diff --git a/test/unit/init/SeededPodInitializer.test.ts b/test/unit/init/SeededPodInitializer.test.ts
index d4b8647d4..142f88655 100644
--- a/test/unit/init/SeededPodInitializer.test.ts
+++ b/test/unit/init/SeededPodInitializer.test.ts
@@ -45,4 +45,11 @@ describe('A SeededPodInitializer', (): void => {
expect(registrationManager.validateInput).toHaveBeenCalledTimes(2);
expect(registrationManager.register).toHaveBeenCalledTimes(2);
});
+
+ it('does not throw exceptions when a seeded pod already exists.', async(): Promise => {
+ registrationManager.register = jest.fn().mockRejectedValueOnce(new Error('Pod already exists'));
+ await new SeededPodInitializer(registrationManager, configFilePath).handle();
+ expect(registrationManager.validateInput).toHaveBeenCalledTimes(2);
+ expect(registrationManager.register).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts
index 55e4281fc..8e31120aa 100644
--- a/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts
+++ b/test/unit/init/variables/extractors/BaseUrlExtractor.test.ts
@@ -24,4 +24,8 @@ describe('A BaseUrlExtractor', (): void => {
it('defaults to port 3000.', async(): Promise => {
await expect(computer.handle({})).resolves.toBe('http://localhost:3000/');
});
+
+ it('does not add the port if it is 80.', async(): Promise => {
+ await expect(computer.handle({ port: 80 })).resolves.toBe('http://localhost/');
+ });
});
diff --git a/test/unit/server/notifications/generate/ActivityNotificationGenerator.test.ts b/test/unit/server/notifications/generate/ActivityNotificationGenerator.test.ts
index e4f52716c..3c18c9faa 100644
--- a/test/unit/server/notifications/generate/ActivityNotificationGenerator.test.ts
+++ b/test/unit/server/notifications/generate/ActivityNotificationGenerator.test.ts
@@ -6,7 +6,7 @@ import {
} from '../../../../../src/server/notifications/generate/ActivityNotificationGenerator';
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
import type { ResourceStore } from '../../../../../src/storage/ResourceStore';
-import { AS, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
+import { AS, CONTENT_TYPE, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
describe('An ActivityNotificationGenerator', (): void => {
const topic: ResourceIdentifier = { path: 'http://example.com/foo' };
@@ -20,6 +20,7 @@ describe('An ActivityNotificationGenerator', (): void => {
[RDF.type]: LDP.terms.Resource,
// Needed for ETag
[DC.modified]: new Date().toISOString(),
+ [CONTENT_TYPE]: 'text/turtle',
});
let store: jest.Mocked;
let generator: ActivityNotificationGenerator;
@@ -51,7 +52,7 @@ describe('An ActivityNotificationGenerator', (): void => {
id: `urn:${ms}:http://example.com/foo`,
type: 'Update',
object: 'http://example.com/foo',
- state: expect.stringMatching(/"\d+"/u),
+ state: expect.stringMatching(/"\d+-text\/turtle"/u),
published: date,
});
diff --git a/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts b/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts
index bc6df8622..d12e4e0b7 100644
--- a/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts
+++ b/test/unit/server/notifications/generate/AddRemoveNotificationGenerator.test.ts
@@ -6,7 +6,7 @@ import {
} from '../../../../../src/server/notifications/generate/AddRemoveNotificationGenerator';
import type { NotificationChannel } from '../../../../../src/server/notifications/NotificationChannel';
import type { ResourceStore } from '../../../../../src/storage/ResourceStore';
-import { AS, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
+import { AS, CONTENT_TYPE, DC, LDP, RDF } from '../../../../../src/util/Vocabularies';
describe('An AddRemoveNotificationGenerator', (): void => {
const topic: ResourceIdentifier = { path: 'http://example.com/' };
@@ -27,6 +27,7 @@ describe('An AddRemoveNotificationGenerator', (): void => {
[RDF.type]: LDP.terms.Resource,
// Needed for ETag
[DC.modified]: new Date().toISOString(),
+ [CONTENT_TYPE]: 'text/turtle',
});
store = {
getRepresentation: jest.fn().mockResolvedValue(new BasicRepresentation('', responseMetadata)),
@@ -72,7 +73,7 @@ describe('An AddRemoveNotificationGenerator', (): void => {
type: 'Add',
object: 'http://example.com/foo',
target: 'http://example.com/',
- state: expect.stringMatching(/"\d+"/u),
+ state: expect.stringMatching(/"\d+-text\/turtle"/u),
published: date,
});
diff --git a/test/unit/storage/BasicConditions.test.ts b/test/unit/storage/BasicConditions.test.ts
index 27bca8710..fa79c6db6 100644
--- a/test/unit/storage/BasicConditions.test.ts
+++ b/test/unit/storage/BasicConditions.test.ts
@@ -1,71 +1,82 @@
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
import { BasicConditions } from '../../../src/storage/BasicConditions';
import { getETag } from '../../../src/storage/Conditions';
-import { DC } from '../../../src/util/Vocabularies';
+import { CONTENT_TYPE, DC } from '../../../src/util/Vocabularies';
+
+function getMetadata(modified: Date, type = 'application/ld+json'): RepresentationMetadata {
+ return new RepresentationMetadata({
+ [DC.modified]: `${modified.toISOString()}`,
+ [CONTENT_TYPE]: type,
+ });
+}
describe('A BasicConditions', (): void => {
const now = new Date(2020, 10, 20);
const tomorrow = new Date(2020, 10, 21);
const yesterday = new Date(2020, 10, 19);
- const eTags = [ '123456', 'abcdefg' ];
+ const turtleTag = getETag(getMetadata(now, 'text/turtle'))!;
+ const jsonLdTag = getETag(getMetadata(now))!;
it('copies the input parameters.', async(): Promise => {
+ const eTags = [ '123456', 'abcdefg' ];
const options = { matchesETag: eTags, notMatchesETag: eTags, modifiedSince: now, unmodifiedSince: now };
expect(new BasicConditions(options)).toMatchObject(options);
});
it('always returns false if notMatchesETag contains *.', async(): Promise => {
const conditions = new BasicConditions({ notMatchesETag: [ '*' ]});
- expect(conditions.matches()).toBe(false);
+ expect(conditions.matchesMetadata(new RepresentationMetadata())).toBe(false);
});
- it('requires matchesETag to contain the provided ETag.', async(): Promise => {
- const conditions = new BasicConditions({ matchesETag: [ '1234' ]});
- expect(conditions.matches('abcd')).toBe(false);
- expect(conditions.matches('1234')).toBe(true);
+ it('requires matchesETag to match the provided ETag timestamp.', async(): Promise => {
+ const conditions = new BasicConditions({ matchesETag: [ turtleTag ]});
+ expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(false);
+ expect(conditions.matchesMetadata(getMetadata(now))).toBe(true);
+ });
+
+ it('requires matchesETag to match the exact provided ETag in strict mode.', async(): Promise => {
+ const turtleConditions = new BasicConditions({ matchesETag: [ turtleTag ]});
+ const jsonLdConditions = new BasicConditions({ matchesETag: [ jsonLdTag ]});
+ expect(turtleConditions.matchesMetadata(getMetadata(now), true)).toBe(false);
+ expect(jsonLdConditions.matchesMetadata(getMetadata(now), true)).toBe(true);
});
it('supports all ETags if matchesETag contains *.', async(): Promise => {
const conditions = new BasicConditions({ matchesETag: [ '*' ]});
- expect(conditions.matches('abcd')).toBe(true);
- expect(conditions.matches('1234')).toBe(true);
+ expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(true);
+ expect(conditions.matchesMetadata(getMetadata(now))).toBe(true);
});
- it('requires notMatchesETag to not contain the provided ETag.', async(): Promise => {
- const conditions = new BasicConditions({ notMatchesETag: [ '1234' ]});
- expect(conditions.matches('1234')).toBe(false);
- expect(conditions.matches('abcd')).toBe(true);
+ it('requires notMatchesETag to not match the provided ETag timestamp.', async(): Promise => {
+ const conditions = new BasicConditions({ notMatchesETag: [ turtleTag ]});
+ expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(true);
+ expect(conditions.matchesMetadata(getMetadata(now))).toBe(false);
+ });
+
+ it('requires notMatchesETag to not match the exact provided ETag in strict mode.', async(): Promise => {
+ const turtleConditions = new BasicConditions({ notMatchesETag: [ turtleTag ]});
+ const jsonLdConditions = new BasicConditions({ notMatchesETag: [ jsonLdTag ]});
+ expect(turtleConditions.matchesMetadata(getMetadata(now), true)).toBe(true);
+ expect(jsonLdConditions.matchesMetadata(getMetadata(now), true)).toBe(false);
});
it('requires lastModified to be after modifiedSince.', async(): Promise => {
const conditions = new BasicConditions({ modifiedSince: now });
- expect(conditions.matches(undefined, yesterday)).toBe(false);
- expect(conditions.matches(undefined, tomorrow)).toBe(true);
+ expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(false);
+ expect(conditions.matchesMetadata(getMetadata(tomorrow))).toBe(true);
});
it('requires lastModified to be before unmodifiedSince.', async(): Promise => {
const conditions = new BasicConditions({ unmodifiedSince: now });
- expect(conditions.matches(undefined, tomorrow)).toBe(false);
- expect(conditions.matches(undefined, yesterday)).toBe(true);
- });
-
- it('can match based on the last modified date in the metadata.', async(): Promise => {
- const metadata = new RepresentationMetadata({ [DC.modified]: now.toISOString() });
- const conditions = new BasicConditions({
- modifiedSince: yesterday,
- unmodifiedSince: tomorrow,
- matchesETag: [ getETag(metadata)! ],
- notMatchesETag: [ '123456' ],
- });
- expect(conditions.matchesMetadata(metadata)).toBe(true);
+ expect(conditions.matchesMetadata(getMetadata(yesterday))).toBe(true);
+ expect(conditions.matchesMetadata(getMetadata(tomorrow))).toBe(false);
});
it('matches if no date is found in the metadata.', async(): Promise => {
- const metadata = new RepresentationMetadata();
+ const metadata = new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' });
const conditions = new BasicConditions({
modifiedSince: yesterday,
unmodifiedSince: tomorrow,
- matchesETag: [ getETag(metadata)! ],
notMatchesETag: [ '123456' ],
});
expect(conditions.matchesMetadata(metadata)).toBe(true);
diff --git a/test/unit/storage/Conditions.test.ts b/test/unit/storage/Conditions.test.ts
index 98a44e589..9a9eee469 100644
--- a/test/unit/storage/Conditions.test.ts
+++ b/test/unit/storage/Conditions.test.ts
@@ -1,17 +1,53 @@
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
-import { getETag } from '../../../src/storage/Conditions';
-import { DC } from '../../../src/util/Vocabularies';
+import { getETag, isCurrentETag } from '../../../src/storage/Conditions';
+import { CONTENT_TYPE, DC } from '../../../src/util/Vocabularies';
describe('Conditions', (): void => {
describe('#getETag', (): void => {
- it('creates an ETag based on the date last modified.', async(): Promise => {
+ it('creates an ETag based on the date last modified and content-type.', async(): Promise => {
const now = new Date();
- const metadata = new RepresentationMetadata({ [DC.modified]: now.toISOString() });
- expect(getETag(metadata)).toBe(`"${now.getTime()}"`);
+ const metadata = new RepresentationMetadata({
+ [DC.modified]: now.toISOString(),
+ [CONTENT_TYPE]: 'text/turtle',
+ });
+ expect(getETag(metadata)).toBe(`"${now.getTime()}-text/turtle"`);
});
- it('returns undefined if no date was found.', async(): Promise => {
+ it('returns undefined if no date or content-type was found.', async(): Promise => {
+ const now = new Date();
expect(getETag(new RepresentationMetadata())).toBeUndefined();
+ expect(getETag(new RepresentationMetadata({ [DC.modified]: now.toISOString() }))).toBeUndefined();
+ expect(getETag(new RepresentationMetadata({ [CONTENT_TYPE]: 'text/turtle' }))).toBeUndefined();
+ });
+ });
+
+ describe('#isCurrentETag', (): void => {
+ const now = new Date();
+
+ it('compares an ETag with the current resource state.', async(): Promise => {
+ const metadata = new RepresentationMetadata({
+ [DC.modified]: now.toISOString(),
+ [CONTENT_TYPE]: 'text/turtle',
+ });
+ const eTag = getETag(metadata)!;
+ expect(isCurrentETag(eTag, metadata)).toBe(true);
+ expect(isCurrentETag('"ETag"', metadata)).toBe(false);
+ });
+
+ it('ignores the content-type.', async(): Promise => {
+ const metadata = new RepresentationMetadata({
+ [DC.modified]: now.toISOString(),
+ [CONTENT_TYPE]: 'text/turtle',
+ });
+ const eTag = getETag(metadata)!;
+ metadata.contentType = 'application/ld+json';
+ expect(isCurrentETag(eTag, metadata)).toBe(true);
+ expect(isCurrentETag('"ETag"', metadata)).toBe(false);
+ });
+
+ it('returns false if the metadata has no last modified date.', async(): Promise => {
+ const metadata = new RepresentationMetadata();
+ expect(isCurrentETag('"ETag"', metadata)).toBe(false);
});
});
});
diff --git a/test/unit/storage/DataAccessorBasedStore.test.ts b/test/unit/storage/DataAccessorBasedStore.test.ts
index b96176085..60615b4b5 100644
--- a/test/unit/storage/DataAccessorBasedStore.test.ts
+++ b/test/unit/storage/DataAccessorBasedStore.test.ts
@@ -25,6 +25,7 @@ import { trimTrailingSlashes } from '../../../src/util/PathUtil';
import { guardedStreamFrom } from '../../../src/util/StreamUtil';
import { CONTENT_TYPE, SOLID_HTTP, LDP, PIM, RDF, SOLID_META, DC, SOLID_AS, AS } from '../../../src/util/Vocabularies';
import { SimpleSuffixStrategy } from '../../util/SimpleSuffixStrategy';
+
const { namedNode, quad, literal } = DataFactory;
const GENERATED_PREDICATE = namedNode('generated');
diff --git a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts
index 1b9ca1f01..018d0398d 100644
--- a/test/unit/storage/conversion/ErrorToJsonConverter.test.ts
+++ b/test/unit/storage/conversion/ErrorToJsonConverter.test.ts
@@ -1,6 +1,8 @@
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import { ErrorToJsonConverter } from '../../../../src/storage/conversion/ErrorToJsonConverter';
import { BadRequestHttpError } from '../../../../src/util/errors/BadRequestHttpError';
+import type { OAuthErrorFields } from '../../../../src/util/errors/OAuthHttpError';
+import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError';
import { readJsonStream } from '../../../../src/util/StreamUtil';
describe('An ErrorToJsonConverter', (): void => {
@@ -47,6 +49,35 @@ describe('An ErrorToJsonConverter', (): void => {
});
});
+ it('adds OAuth fields if present.', async(): Promise => {
+ const out: OAuthErrorFields = {
+ error: 'error',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ error_description: 'error_description',
+ scope: 'scope',
+ state: 'state',
+ };
+ const error = new OAuthHttpError(out, 'InvalidRequest', 400, 'error text');
+ const representation = new BasicRepresentation([ error ], 'internal/error', false);
+ const prom = converter.handle({ identifier, representation, preferences });
+ await expect(prom).resolves.toBeDefined();
+ const result = await prom;
+ expect(result.binary).toBe(true);
+ expect(result.metadata.contentType).toBe('application/json');
+ await expect(readJsonStream(result.data)).resolves.toEqual({
+ name: 'InvalidRequest',
+ message: 'error text',
+ statusCode: 400,
+ errorCode: 'H400',
+ stack: error.stack,
+ error: 'error',
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ error_description: 'error_description',
+ scope: 'scope',
+ state: 'state',
+ });
+ });
+
it('does not copy the details if they are not serializable.', async(): Promise => {
const error = new BadRequestHttpError('error text', { details: { object: BigInt(1) }});
const representation = new BasicRepresentation([ error ], 'internal/error', false);
diff --git a/test/unit/storage/keyvalue/WrappedExpiringStorage.test.ts b/test/unit/storage/keyvalue/WrappedExpiringStorage.test.ts
index a1af5ef88..9cad6b134 100644
--- a/test/unit/storage/keyvalue/WrappedExpiringStorage.test.ts
+++ b/test/unit/storage/keyvalue/WrappedExpiringStorage.test.ts
@@ -121,7 +121,12 @@ describe('A WrappedExpiringStorage', (): void => {
// Disable interval function and simply check it was called with the correct parameters
// Otherwise it gets quite difficult to verify the async interval function gets executed
const mockInterval = jest.spyOn(global, 'setInterval');
- mockInterval.mockImplementation(jest.fn());
+
+ // We only need to call the timer.unref() once when the object is created
+ const mockTimer = { unref: jest.fn() };
+ const mockFn = jest.fn().mockReturnValueOnce(mockTimer);
+ mockInterval.mockImplementationOnce(mockFn);
+
// Timeout of 1 minute
storage = new WrappedExpiringStorage(source, 1);
const data = [
@@ -141,33 +146,12 @@ describe('A WrappedExpiringStorage', (): void => {
// Await the function that should have been executed by the interval
await (mockInterval.mock.calls[0][0] as () => Promise)();
+ // Make sure timer.unref() is called on initialization
+ expect(mockTimer.unref).toHaveBeenCalledTimes(1);
+ // Make sure setSafeInterval has been called once as well
+ expect(mockFn).toHaveBeenCalledTimes(1);
expect(source.delete).toHaveBeenCalledTimes(1);
expect(source.delete).toHaveBeenLastCalledWith('key2');
mockInterval.mockRestore();
});
-
- it('can stop the timer.', async(): Promise => {
- const mockInterval = jest.spyOn(global, 'setInterval');
- const mockClear = jest.spyOn(global, 'clearInterval');
- // Timeout of 1 minute
- storage = new WrappedExpiringStorage(source, 1);
- const data = [
- [ 'key1', createExpires('data1', tomorrow) ],
- [ 'key2', createExpires('data2', yesterday) ],
- [ 'key3', createExpires('data3') ],
- ];
- source.entries.mockImplementationOnce(function* (): any {
- yield* data;
- });
-
- await expect(storage.finalize()).resolves.toBeUndefined();
-
- // Make sure clearInterval was called with the interval timer
- expect(mockClear.mock.calls).toHaveLength(1);
- expect(mockClear.mock.calls[0]).toHaveLength(1);
- expect(mockClear.mock.calls[0][0]).toBe(mockInterval.mock.results[0].value);
-
- mockInterval.mockRestore();
- mockClear.mockRestore();
- });
});
diff --git a/test/unit/util/ResourceUtil.test.ts b/test/unit/util/ResourceUtil.test.ts
index f177f9658..d70b9187b 100644
--- a/test/unit/util/ResourceUtil.test.ts
+++ b/test/unit/util/ResourceUtil.test.ts
@@ -1,9 +1,18 @@
import 'jest-rdf';
+import type { Readable } from 'stream';
import type { NamedNode, Literal } from 'n3';
import { BasicRepresentation } from '../../../src/http/representation/BasicRepresentation';
import type { Representation } from '../../../src/http/representation/Representation';
import { RepresentationMetadata } from '../../../src/http/representation/RepresentationMetadata';
-import { addTemplateMetadata, cloneRepresentation, updateModifiedDate } from '../../../src/util/ResourceUtil';
+import type { Conditions } from '../../../src/storage/Conditions';
+import { NotModifiedHttpError } from '../../../src/util/errors/NotModifiedHttpError';
+import type { Guarded } from '../../../src/util/GuardedStream';
+import {
+ addTemplateMetadata,
+ assertReadConditions,
+ cloneRepresentation,
+ updateModifiedDate,
+} from '../../../src/util/ResourceUtil';
import { CONTENT_TYPE_TERM, DC, SOLID_META, XSD } from '../../../src/util/Vocabularies';
describe('ResourceUtil', (): void => {
@@ -59,4 +68,36 @@ describe('ResourceUtil', (): void => {
expect(representation.metadata.contentType).not.toBe(res.metadata.contentType);
});
});
+
+ describe('#assertReadConditions', (): void => {
+ let data: jest.Mocked>;
+
+ beforeEach(async(): Promise => {
+ data = {
+ destroy: jest.fn(),
+ } as any;
+ representation.data = data;
+ });
+
+ it('does nothing if the conditions are undefined.', async(): Promise => {
+ expect((): any => assertReadConditions(representation)).not.toThrow();
+ expect(data.destroy).toHaveBeenCalledTimes(0);
+ });
+
+ it('does nothing if the conditions match.', async(): Promise => {
+ const conditions: Conditions = {
+ matchesMetadata: (): boolean => true,
+ };
+ expect((): any => assertReadConditions(representation, conditions)).not.toThrow();
+ expect(data.destroy).toHaveBeenCalledTimes(0);
+ });
+
+ it('throws a NotModifiedHttpError if the conditions do not match.', async(): Promise => {
+ const conditions: Conditions = {
+ matchesMetadata: (): boolean => false,
+ };
+ expect((): any => assertReadConditions(representation, conditions)).toThrow(NotModifiedHttpError);
+ expect(data.destroy).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/test/unit/util/errors/HttpError.test.ts b/test/unit/util/errors/HttpError.test.ts
index 46d9a8773..4b2726dd2 100644
--- a/test/unit/util/errors/HttpError.test.ts
+++ b/test/unit/util/errors/HttpError.test.ts
@@ -9,16 +9,19 @@ import { InternalServerError } from '../../../../src/util/errors/InternalServerE
import { MethodNotAllowedHttpError } from '../../../../src/util/errors/MethodNotAllowedHttpError';
import { NotFoundHttpError } from '../../../../src/util/errors/NotFoundHttpError';
import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError';
+import { NotModifiedHttpError } from '../../../../src/util/errors/NotModifiedHttpError';
import { PayloadHttpError } from '../../../../src/util/errors/PayloadHttpError';
import { PreconditionFailedHttpError } from '../../../../src/util/errors/PreconditionFailedHttpError';
import { UnauthorizedHttpError } from '../../../../src/util/errors/UnauthorizedHttpError';
import { UnprocessableEntityHttpError } from '../../../../src/util/errors/UnprocessableEntityHttpError';
import { UnsupportedMediaTypeHttpError } from '../../../../src/util/errors/UnsupportedMediaTypeHttpError';
import { SOLID_ERROR } from '../../../../src/util/Vocabularies';
+
const { literal, namedNode, quad } = DataFactory;
describe('HttpError', (): void => {
const errors: [string, number, HttpErrorClass][] = [
+ [ 'NotModifiedHttpError', 304, NotModifiedHttpError ],
[ 'BadRequestHttpError', 400, BadRequestHttpError ],
[ 'UnauthorizedHttpError', 401, UnauthorizedHttpError ],
[ 'ForbiddenHttpError', 403, ForbiddenHttpError ],
diff --git a/test/unit/util/errors/OAuthHttpError.test.ts b/test/unit/util/errors/OAuthHttpError.test.ts
new file mode 100644
index 000000000..629cdb0e1
--- /dev/null
+++ b/test/unit/util/errors/OAuthHttpError.test.ts
@@ -0,0 +1,24 @@
+import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError';
+
+describe('An OAuthHttpError', (): void => {
+ it('contains relevant information.', async(): Promise => {
+ const error = new OAuthHttpError({ error: 'error!' }, 'InvalidRequest', 400, 'message!');
+ expect(error.mandatoryFields.error).toBe('error!');
+ expect(error.name).toBe('InvalidRequest');
+ expect(error.statusCode).toBe(400);
+ expect(error.message).toBe('message!');
+ });
+
+ it('has optional fields.', async(): Promise => {
+ const error = new OAuthHttpError({ error: 'error!' });
+ expect(error.mandatoryFields.error).toBe('error!');
+ expect(error.name).toBe('OAuthHttpError');
+ expect(error.statusCode).toBe(500);
+ });
+
+ it('can identify OAuth errors.', async(): Promise => {
+ const error = new OAuthHttpError({ error: 'error!' });
+ expect(OAuthHttpError.isInstance('apple')).toBe(false);
+ expect(OAuthHttpError.isInstance(error)).toBe(true);
+ });
+});