mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
Merge branch 'main' into versions/6.0.0
# Conflicts: # config/ldp/authorization/readers/access-checkers/agent-group.json
This commit is contained in:
commit
d6be724a12
2
.github/workflows/cth-test.yml
vendored
2
.github/workflows/cth-test.yml
vendored
@ -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
|
||||
|
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@ -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
|
||||
|
6
.github/workflows/mkdocs.yml
vendored
6
.github/workflows/mkdocs.yml
vendored
@ -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'
|
||||
|
10
.github/workflows/npm-test.yml
vendored
10
.github/workflows/npm-test.yml
vendored
@ -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
|
||||
|
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
@ -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/)
|
||||
|
@ -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.
|
||||
|
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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" }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.
|
||||
|
38
package-lock.json
generated
38
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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<ResponseDescription> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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 });
|
||||
};
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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.`);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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()}`;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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<T> = { 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<TKey, TValue> implements ExpiringStorage<TKey, TValue>, Finalizable {
|
||||
export class WrappedExpiringStorage<TKey, TValue> implements ExpiringStorage<TKey, TValue> {
|
||||
protected readonly logger = getLoggerFor(this);
|
||||
private readonly source: KeyValueStorage<TKey, Expires<TValue>>;
|
||||
private readonly timer: NodeJS.Timeout;
|
||||
@ -28,6 +27,7 @@ export class WrappedExpiringStorage<TKey, TValue> implements ExpiringStorage<TKe
|
||||
'Failed to remove expired entries',
|
||||
this.removeExpiredEntries.bind(this),
|
||||
timeout * 60 * 1000);
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
public async get(key: TKey): Promise<TValue | undefined> {
|
||||
@ -121,11 +121,4 @@ export class WrappedExpiringStorage<TKey, TValue> implements ExpiringStorage<TKe
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the continuous cleanup timer.
|
||||
*/
|
||||
public async finalize(): Promise<void> {
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
14
src/util/errors/NotModifiedHttpError.ts
Normal file
14
src/util/errors/NotModifiedHttpError.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
36
src/util/errors/OAuthHttpError.ts
Normal file
36
src/util/errors/OAuthHttpError.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
@ -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
|
||||
|
26
templates/error/descriptions/E0003.md.hbs
Normal file
26
templates/error/descriptions/E0003.md.hbs
Normal file
@ -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.
|
@ -17,7 +17,7 @@
|
||||
</main>
|
||||
<footer>
|
||||
<p>
|
||||
©2019–2022 <a href="https://inrupt.com/">Inrupt Inc.</a>
|
||||
©2019–2023 <a href="https://inrupt.com/">Inrupt Inc.</a>
|
||||
and <a href="https://www.imec-int.com/">imec</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
@ -30,13 +30,20 @@
|
||||
If you want to keep data permanently,
|
||||
choose a configuration that saves data to disk instead.
|
||||
</p>
|
||||
<p>
|
||||
To learn more about how this server can be used,
|
||||
have a look at the
|
||||
<a href="https://github.com/CommunitySolidServer/tutorials/blob/main/getting-started.md">getting started tutorial</a>.
|
||||
</p>
|
||||
|
||||
<h2 id="developers">Getting started as a <em>developer</em></h2>
|
||||
<p>
|
||||
<a href="./setup">Run the setup</a> to configure your server.
|
||||
<br>
|
||||
The default configuration includes
|
||||
the <strong>ready-to-use root Pod</strong> you're currently looking at.
|
||||
<br>
|
||||
Besides the provided configurations,
|
||||
you can also fine-tune your own custom configuration using the
|
||||
<a href="https://communitysolidserver.github.io/configuration-generator/">configuration generator</a>.
|
||||
</p>
|
||||
<p>
|
||||
You can easily choose any folder on your disk
|
||||
@ -58,7 +65,7 @@
|
||||
</main>
|
||||
<footer>
|
||||
<p>
|
||||
©2019–2022 <a href="https://inrupt.com/">Inrupt Inc.</a>
|
||||
©2019–2023 <a href="https://inrupt.com/">Inrupt Inc.</a>
|
||||
and <a href="https://www.imec-int.com/">imec</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
@ -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<void> => {
|
||||
// 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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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<void> => {
|
||||
operation = { method: 'GET', target: { path: 'http://test.com/foo' }, preferences, conditions, body };
|
||||
data = { destroy: jest.fn() } as any;
|
||||
store = {
|
||||
getRepresentation: jest.fn(async(): Promise<Representation> =>
|
||||
({ 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<void> => {
|
||||
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<void> => {
|
||||
operation.conditions = {
|
||||
matchesMetadata: (): boolean => false,
|
||||
};
|
||||
await expect(handler.handle({ operation })).rejects.toThrow(NotModifiedHttpError);
|
||||
expect(data.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
@ -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<void> => {
|
||||
operation = { method: 'HEAD', target: { path: 'http://test.com/foo' }, preferences, conditions, body };
|
||||
data = { destroy: jest.fn() } as any;
|
||||
store = {
|
||||
getRepresentation: jest.fn(async(): Promise<Representation> =>
|
||||
({ 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<void> => {
|
||||
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<void> => {
|
||||
operation.conditions = {
|
||||
matchesMetadata: (): boolean => false,
|
||||
};
|
||||
await expect(handler.handle({ operation })).rejects.toThrow(NotModifiedHttpError);
|
||||
expect(data.destroy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
@ -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<void> => {
|
||||
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),
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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<void> => {
|
||||
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<void> => {
|
||||
|
@ -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<void> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
@ -24,4 +24,8 @@ describe('A BaseUrlExtractor', (): void => {
|
||||
it('defaults to port 3000.', async(): Promise<void> => {
|
||||
await expect(computer.handle({})).resolves.toBe('http://localhost:3000/');
|
||||
});
|
||||
|
||||
it('does not add the port if it is 80.', async(): Promise<void> => {
|
||||
await expect(computer.handle({ port: 80 })).resolves.toBe('http://localhost/');
|
||||
});
|
||||
});
|
||||
|
@ -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<ResourceStore>;
|
||||
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,
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
});
|
||||
|
||||
|
@ -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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
|
@ -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<void> => {
|
||||
it('creates an ETag based on the date last modified and content-type.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
it('returns undefined if no date or content-type was found.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
const metadata = new RepresentationMetadata();
|
||||
expect(isCurrentETag('"ETag"', metadata)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -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<void> => {
|
||||
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<void> => {
|
||||
const error = new BadRequestHttpError('error text', { details: { object: BigInt(1) }});
|
||||
const representation = new BasicRepresentation([ error ], 'internal/error', false);
|
||||
|
@ -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<void>)();
|
||||
|
||||
// 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<void> => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
@ -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<Guarded<Readable>>;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
data = {
|
||||
destroy: jest.fn(),
|
||||
} as any;
|
||||
representation.data = data;
|
||||
});
|
||||
|
||||
it('does nothing if the conditions are undefined.', async(): Promise<void> => {
|
||||
expect((): any => assertReadConditions(representation)).not.toThrow();
|
||||
expect(data.destroy).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does nothing if the conditions match.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
const conditions: Conditions = {
|
||||
matchesMetadata: (): boolean => false,
|
||||
};
|
||||
expect((): any => assertReadConditions(representation, conditions)).toThrow(NotModifiedHttpError);
|
||||
expect(data.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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 ],
|
||||
|
24
test/unit/util/errors/OAuthHttpError.test.ts
Normal file
24
test/unit/util/errors/OAuthHttpError.test.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { OAuthHttpError } from '../../../../src/util/errors/OAuthHttpError';
|
||||
|
||||
describe('An OAuthHttpError', (): void => {
|
||||
it('contains relevant information.', async(): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
const error = new OAuthHttpError({ error: 'error!' });
|
||||
expect(OAuthHttpError.isInstance('apple')).toBe(false);
|
||||
expect(OAuthHttpError.isInstance(error)).toBe(true);
|
||||
});
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user