Merge branch 'main' into versions/6.0.0

# Conflicts:
#	config/ldp/authorization/readers/access-checkers/agent-group.json
This commit is contained in:
Joachim Van Herwegen 2023-04-24 11:21:59 +02:00
commit d6be724a12
50 changed files with 612 additions and 203 deletions

View File

@ -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

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
MIT License
Copyright © 20192022 Inrupt Inc. and imec
Copyright © 20192023 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

View File

@ -8,6 +8,7 @@
[![Node.js version](https://img.shields.io/node/v/@solid/community-server)](https://www.npmjs.com/package/@solid/community-server)
[![Build Status](https://github.com/CommunitySolidServer/CommunitySolidServer/workflows/CI/badge.svg)](https://github.com/CommunitySolidServer/CommunitySolidServer/actions)
[![Coverage Status](https://coveralls.io/repos/github/CommunitySolidServer/CommunitySolidServer/badge.svg)](https://coveralls.io/github/CommunitySolidServer/CommunitySolidServer)
[![DOI](https://zenodo.org/badge/265197208.svg)](https://zenodo.org/badge/latestdoi/265197208)
[![GitHub discussions](https://img.shields.io/github/discussions/CommunitySolidServer/CommunitySolidServer)](https://github.com/CommunitySolidServer/CommunitySolidServer/discussions)
[![Chat on Gitter](https://badges.gitter.im/CommunitySolidServer/community.svg)](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/)

View File

@ -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.

View File

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

View File

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

View File

@ -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
},

View File

@ -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

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 });
};
}

View File

@ -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';

View File

@ -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.`);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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()}`;
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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();
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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

View 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.

View File

@ -17,7 +17,7 @@
</main>
<footer>
<p>
©20192022 <a href="https://inrupt.com/">Inrupt Inc.</a>
©20192023 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>

View File

@ -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>
©20192022 <a href="https://inrupt.com/">Inrupt Inc.</a>
©20192023 <a href="https://inrupt.com/">Inrupt Inc.</a>
and <a href="https://www.imec-int.com/">imec</a>
</p>
</footer>

View File

@ -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);
});
});

View File

@ -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');
});
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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),
});
});

View File

@ -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> => {

View File

@ -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);
});
});

View File

@ -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/');
});
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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);

View File

@ -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);
});
});
});

View File

@ -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');

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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);
});
});
});

View File

@ -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 ],

View 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);
});
});