Merge branch 'main' into versions/6.0.0

This commit is contained in:
Joachim Van Herwegen 2022-11-07 09:45:22 +01:00
commit 3328f8dea6
24 changed files with 402 additions and 103 deletions

View File

@ -42,7 +42,7 @@ jobs:
with:
node-version: 16.x
- name: Check out the project
uses: actions/checkout@v3
uses: actions/checkout@v3.1.0
with:
ref: ${{ inputs.branch || github.ref }}
- name: Install dependencies and run build scripts
@ -65,7 +65,7 @@ jobs:
-v "$(pwd)"/reports/css:/reports
--env-file=./test/deploy/conformance.env
--network="host"
solidproject/conformance-test-harness
solidproject/conformance-test-harness:${{ inputs.version }}
--skip-teardown
--output=/reports
--target=https://github.com/solid/conformance-test-harness/css

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
uses: actions/checkout@v3.1.0
- 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
uses: actions/checkout@v3.1.0
- 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.ouputs.major }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3.1.0
- 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@v2
- uses: actions/checkout@v3.1.0
- uses: actions/setup-python@v4
with:
python-version: 3.x

View File

@ -7,7 +7,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v3.1.0
- 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
uses: actions/checkout@v3.1.0
- 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
uses: actions/checkout@v3.1.0
- 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
uses: actions/checkout@v3.1.0
- 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
uses: actions/checkout@v3.1.0
- name: Install dependencies and run build scripts
run: npm ci
- name: Run deploy tests

29
.github/workflows/stale.yml vendored Normal file
View File

@ -0,0 +1,29 @@
name: 'Stale issues and PRs'
on:
schedule:
- cron: '30 1 * * *'
permissions:
issues: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v6
with:
debug-only: true
stale-issue-label: 🏚️ abandoned
stale-issue-message: >
This issue has been automatically marked as `🏚️ abandoned`
because it has not had recent activity. It will be closed if no
further activity occurs.
close-issue-message: >
Closing stale issue. If this issue is still relevant, please reopen it.
days-before-issue-stale: 60
days-before-close: 30
days-before-pr-stale: -1
operations-per-run: 700
exempt-issue-labels: "🐛 bug,☀️ enhancement,📚 documentation, feature,🐌 performance,
test,📝 task,:ant: worker threads,👩🏾‍💻 developer experience"
exempt-all-assignees: true

View File

@ -7,7 +7,7 @@ jobs:
# Build typedocs and publish them to the GH page
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v3.1.0
- uses: actions/setup-node@v3
with:
node-version: '16.x'

View File

@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
## [5.1.0](https://github.com/CommunitySolidServer/CommunitySolidServer/compare/v5.0.0...v5.1.0) (2022-11-03)
### Features
* add additional redis settings to redis locker ([79fa83a](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/79fa83a07ab6dded5c7d601dd7b165fa9178ef26))
* add support for key namespacePrefixes in a RedisLocker instance ([d690cc7](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/d690cc7ed02e2b0ab95fa20b8934ac31c44f8566))
* Allow JSON-LD contexts to be stored locally ([b0924bf](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/b0924bf168500070a287cef300047f874deebe0c))
* Allow multiple configurations to be used during startup ([e050f8b](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/e050f8be93b7ea9596fdaf250de9f0bf32fa4fd8))
### Fixes
* Require create permission for empty PATCH bodies ([68ee964](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/68ee9648e1c78684708f026d9f4b22ee1cc66790))
* Return correct status code when deleting non-existent resource ([ef48660](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/ef48660b482cb954e5b08e9c3799acfdba6d6f23))
* Fix incorrect config import ([e1af8ee](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/e1af8ee66ef21ac9e0fac4379d7836b3338b8343))
* Add missing parameter to `sparql-file-storage.json` configuration ([f7742cf](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/f7742cffefa2875442798a478a5d7a9960dbfa7e))
* Always render OIDC errors correctly ([7884348](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/7884348c2f4e8f646f38cf987c58a8a47135facd))
* Clarify application consent screen. ([7987824](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/79878240682b2f7e335db4edd4c6a4ed8044a170))
* Prevent websockets from being used with worker threads ([327ce74](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/327ce7409ac3f62f8213bd8e300b3726fa848efb))
* update metadata documentation ([abbf3dd](https://github.com/CommunitySolidServer/CommunitySolidServer/commit/abbf3ddeef1494a84aa8e7293108ef63f47ac2d9))
## [5.0.0](https://github.com/CommunitySolidServer/CommunitySolidServer/compare/v4.0.1...v5.0.0) (2022-08-08)
### Features

View File

@ -51,6 +51,13 @@ These changes are relevant if you wrote custom modules for the server that depen
- `CredentialSet` was replaced by a single `Credentials` interface.
This impacts all authentication and authorization related classes.
## v5.1.0
### New features
- The `--config` CLI parameter now accepts multiple configuration paths, which will be combined.
- The `RedisLocker` now accepts more configuration parameters.
## v5.0.0
### New features

View File

@ -26,7 +26,8 @@
"Link",
"Location",
"Updates-Via",
"WAC-Allow"
"WAC-Allow",
"Www-Authenticate"
]
}
]

View File

@ -19,9 +19,15 @@
"@id": "urn:solid-server:default:PatchModesExtractor"
},
{
"comment": "Requires read permissions on parent container when deleting non-existent resource for correct status code.",
"@type": "DeleteParentExtractor",
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" },
"identifierStrategy": { "@id": "urn:solid-server:default:IdentifierStrategy" },
"source": {
"comment": "Extract access modes based on the HTTP method.",
"@type": "MethodModesExtractor",
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" }
}
},
{
"@type": "StaticThrowHandler",
@ -31,6 +37,9 @@
},
{
"@id": "urn:solid-server:default:PatchModesExtractor",
"@type": "CreateModesExtractor",
"resourceSet": { "@id": "urn:solid-server:default:CachedResourceSet" },
"source": {
"@type": "MethodFilterHandler",
"methods": [ "PATCH" ],
"source": {
@ -51,5 +60,6 @@
]
}
}
}
]
}

View File

@ -78,6 +78,7 @@
{
"@id": "urn:solid-server:default:SparqlResourceStore",
"@type": "RepresentationConvertingStore",
"metadataStrategy":{ "@id": "urn:solid-server:default:MetadataStrategy" },
"options_inConverter": { "@id": "urn:solid-server:default:RepresentationConverter" },
"options_inType": "internal/quads",
"source": {

View File

@ -1,9 +1,8 @@
{
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"import": [
"files-scs:config/util/auxiliary/strategies/meta.json"
"css:config/util/auxiliary/strategies/meta.json"
],
"@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld",
"@graph": [
{
"comment": "This will contain references to all the auxiliary strategies (such as the acl one) if they are needed.",

58
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@solid/community-server",
"version": "5.0.0",
"version": "5.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@solid/community-server",
"version": "5.0.0",
"version": "5.1.0",
"license": "MIT",
"dependencies": {
"@comunica/context-entries": "^2.2.0",
@ -3245,18 +3245,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.4.tgz",
@ -8331,18 +8319,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -11891,9 +11867,9 @@
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
"node_modules/minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@ -18098,15 +18074,6 @@
"requires": {
"argparse": "^2.0.1"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
}
}
},
@ -21621,15 +21588,6 @@
"p-locate": "^5.0.0"
}
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"requires": {
"brace-expansion": "^1.1.7"
}
},
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -24745,9 +24703,9 @@
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
"minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"requires": {
"brace-expansion": "^1.1.7"
}

View File

@ -1,6 +1,6 @@
{
"name": "@solid/community-server",
"version": "5.0.0",
"version": "5.1.0",
"description": "Community Solid Server: an open and modular implementation of the Solid specifications",
"keywords": [
"solid",

View File

@ -0,0 +1,34 @@
import type { Operation } from '../../http/Operation';
import type { ResourceSet } from '../../storage/ResourceSet';
import { ModesExtractor } from './ModesExtractor';
import type { AccessMap } from './Permissions';
import { AccessMode } from './Permissions';
/**
* Adds the `create` access mode to the result of the source in case the target resource does not exist.
*/
export class CreateModesExtractor extends ModesExtractor {
private readonly source: ModesExtractor;
private readonly resourceSet: ResourceSet;
public constructor(source: ModesExtractor, resourceSet: ResourceSet) {
super();
this.source = source;
this.resourceSet = resourceSet;
}
public async canHandle(operation: Operation): Promise<void> {
await this.source.canHandle(operation);
}
public async handle(operation: Operation): Promise<AccessMap> {
const accessMap = await this.source.handle(operation);
if (!accessMap.hasEntry(operation.target, AccessMode.create) &&
!await this.resourceSet.hasResource(operation.target)) {
accessMap.add(operation.target, AccessMode.create);
}
return accessMap;
}
}

View File

@ -0,0 +1,44 @@
import type { Operation } from '../../http/Operation';
import type { ResourceSet } from '../../storage/ResourceSet';
import type { IdentifierStrategy } from '../../util/identifiers/IdentifierStrategy';
import { ModesExtractor } from './ModesExtractor';
import type { AccessMap } from './Permissions';
import { AccessMode } from './Permissions';
/**
* In case a resource is being deleted but does not exist,
* the server response code depends on the access modes the agent has on the parent container.
* In case the agent has read access on the parent container, a 404 should be returned,
* otherwise it should be 401/403.
*
* This class adds support for this by requiring read access on the parent container
* in case the target resource does not exist.
*/
export class DeleteParentExtractor extends ModesExtractor {
private readonly source: ModesExtractor;
private readonly resourceSet: ResourceSet;
private readonly identifierStrategy: IdentifierStrategy;
public constructor(source: ModesExtractor, resourceSet: ResourceSet, identifierStrategy: IdentifierStrategy) {
super();
this.source = source;
this.resourceSet = resourceSet;
this.identifierStrategy = identifierStrategy;
}
public async canHandle(operation: Operation): Promise<void> {
await this.source.canHandle(operation);
}
public async handle(operation: Operation): Promise<AccessMap> {
const accessMap = await this.source.handle(operation);
const { target } = operation;
if (accessMap.get(target)?.has(AccessMode.delete) &&
!this.identifierStrategy.isRootContainer(target) &&
!await this.resourceSet.hasResource(target)) {
const parent = this.identifierStrategy.getParentContainer(target);
accessMap.add(parent, new Set([ AccessMode.read ]));
}
return accessMap;
}
}

View File

@ -16,6 +16,8 @@ export * from './authorization/access/AgentGroupAccessChecker';
// Authorization/Permissions
export * from './authorization/permissions/AclPermission';
export * from './authorization/permissions/CreateModesExtractor';
export * from './authorization/permissions/DeleteParentExtractor';
export * from './authorization/permissions/IntermediateCreateExtractor';
export * from './authorization/permissions/ModesExtractor';
export * from './authorization/permissions/MethodModesExtractor';

View File

@ -17,6 +17,17 @@ const attemptDefaults: Required<AttemptSettings> = { retryCount: -1, retryDelay:
const PREFIX_RW = '__RW__';
const PREFIX_LOCK = '__L__';
export interface RedisSettings {
/* Override default namespacePrefixes (used to prefix keys in Redis) */
namespacePrefix: string;
/* Username used for AUTH on the Redis server */
username?: string;
/* Password used for AUTH on the Redis server */
password?: string;
/* The number of the database to use */
db?: number;
}
/**
* A Redis Locker that can be used as both:
* * a Read Write Locker that uses a (single) Redis server to store the locks and counts.
@ -51,11 +62,24 @@ export class RedisLocker implements ReadWriteLocker, ResourceLocker, Initializab
private readonly redisRw: RedisReadWriteLock;
private readonly redisLock: RedisResourceLock;
private readonly attemptSettings: Required<AttemptSettings>;
private readonly namespacePrefix: string;
private finalized = false;
public constructor(redisClient = '127.0.0.1:6379', attemptSettings: AttemptSettings = {}) {
this.redis = this.createRedisClient(redisClient);
/**
* Creates a new RedisClient
* @param redisClient - Redis connection string of a standalone Redis node
* @param attemptSettings - Override default AttemptSettings
* @param redisSettings - Addition settings used to create the Redis client or to interact with the Redis server
*/
public constructor(
redisClient = '127.0.0.1:6379',
attemptSettings: AttemptSettings = {},
redisSettings: RedisSettings = { namespacePrefix: '' },
) {
const { namespacePrefix, ...options } = redisSettings;
this.redis = this.createRedisClient(redisClient, options);
this.attemptSettings = { ...attemptDefaults, ...attemptSettings };
this.namespacePrefix = namespacePrefix;
// Register lua scripts
for (const [ name, script ] of Object.entries(REDIS_LUA_SCRIPTS)) {
@ -71,7 +95,7 @@ export class RedisLocker implements ReadWriteLocker, ResourceLocker, Initializab
* @param redisClientString - A string that contains either a host address and a
* port number like '127.0.0.1:6379' or just a port number like '6379'.
*/
private createRedisClient(redisClientString: string): Redis {
private createRedisClient(redisClientString: string, options: Omit<RedisSettings, 'namespacePrefix'>): Redis {
if (redisClientString.length > 0) {
// Check if port number or ip with port number
// Definitely not perfect, but configuring this is only for experienced users
@ -83,7 +107,7 @@ export class RedisLocker implements ReadWriteLocker, ResourceLocker, Initializab
}
const port = Number(match[2]);
const host = match[1];
return new Redis(port, host);
return new Redis(port, host, options);
}
throw new Error(`Empty redisClientString provided!\n
Please provide a port number like '6379' or a host address and a port number like '127.0.0.1:6379'`);
@ -95,7 +119,7 @@ export class RedisLocker implements ReadWriteLocker, ResourceLocker, Initializab
* @returns A scoped Redis key that allows cleanup afterwards without affecting other keys.
*/
private getReadWriteKey(identifier: ResourceIdentifier): string {
return `${PREFIX_RW}${identifier.path}`;
return `${this.namespacePrefix}${PREFIX_RW}${identifier.path}`;
}
/**
@ -104,7 +128,7 @@ export class RedisLocker implements ReadWriteLocker, ResourceLocker, Initializab
* @returns A scoped Redis key that allows cleanup afterwards without affecting other keys.
*/
private getResourceKey(identifier: ResourceIdentifier): string {
return `${PREFIX_LOCK}${identifier.path}`;
return `${this.namespacePrefix}${PREFIX_LOCK}${identifier.path}`;
}
/* ReadWriteLocker methods */
@ -200,12 +224,12 @@ export class RedisLocker implements ReadWriteLocker, ResourceLocker, Initializab
* Remove any lock still open
*/
private async clearLocks(): Promise<void> {
const keysRw = await this.redisRw.keys(`${PREFIX_RW}*`);
const keysRw = await this.redisRw.keys(`${this.namespacePrefix}${PREFIX_RW}*`);
if (keysRw.length > 0) {
await this.redisRw.del(...keysRw);
}
const keysLock = await this.redisLock.keys(`${PREFIX_LOCK}*`);
const keysLock = await this.redisLock.keys(`${this.namespacePrefix}${PREFIX_LOCK}*`);
if (keysLock.length > 0) {
await this.redisLock.del(...keysLock);
}

View File

@ -133,6 +133,12 @@ describe('An http server with middleware', (): void => {
expect(splitCommaSeparated(exposed)).toContain('Updates-Via');
});
it('exposes the Www-Authenticate header via CORS.', async(): Promise<void> => {
const res = await request(server).get('/').expect(200);
const exposed = res.header['access-control-expose-headers'];
expect(splitCommaSeparated(exposed)).toContain('Www-Authenticate');
});
it('sends incoming requests to the handler.', async(): Promise<void> => {
const response = request(server).get('/').set('Host', 'test.com');
expect(response).toBeDefined();

View File

@ -86,17 +86,20 @@ const table: [string, string, AM[], AM[] | undefined, string, string, number, nu
[ 'PUT', 'C/R', [ AM.write ], undefined, '', TXT, 205, 201 ],
[ 'PUT', 'C/R', [ AM.append ], [ AM.write ], '', TXT, 205, 201 ],
// All PATCH operations with read permissions return 401 instead of 404 if the target does not exist.
// This is a consequence of PATCH always creating a resource in case it does not exist.
// https://solidproject.org/TR/2021/protocol-20211217#n3-patch
// "Start from the RDF dataset in the target document,
// or an empty RDF dataset if the target resource does not exist yet."
[ 'PATCH', 'C/R', [], undefined, DELETE, N3, 401, 401 ],
[ 'PATCH', 'C/R', [], [ AM.read ], DELETE, N3, 401, 404 ],
[ 'PATCH', 'C/R', [], [ AM.read ], DELETE, N3, 401, 401 ],
[ 'PATCH', 'C/R', [], [ AM.append ], INSERT, N3, 205, 401 ],
[ 'PATCH', 'C/R', [], [ AM.append ], DELETE, N3, 401, 401 ],
[ 'PATCH', 'C/R', [], [ AM.write ], INSERT, N3, 205, 401 ],
[ 'PATCH', 'C/R', [], [ AM.write ], DELETE, N3, 401, 401 ],
[ 'PATCH', 'C/R', [ AM.append ], [ AM.write ], INSERT, N3, 205, 201 ],
[ 'PATCH', 'C/R', [ AM.append ], [ AM.write ], DELETE, N3, 401, 401 ],
// We currently return 409 instead of 404 in case a PATCH has no inserts and C/R does not exist.
// This is an agreed upon deviation from the original table
[ 'PATCH', 'C/R', [], [ AM.read, AM.write ], DELETE, N3, 205, 409 ],
[ 'PATCH', 'C/R', [], [ AM.read, AM.write ], DELETE, N3, 205, 401 ],
[ 'DELETE', 'C/R', [], undefined, '', '', 401, 401 ],
[ 'DELETE', 'C/R', [], [ AM.read ], '', '', 401, 404 ],
@ -105,8 +108,7 @@ const table: [string, string, AM[], AM[] | undefined, string, string, number, nu
[ 'DELETE', 'C/R', [ AM.read ], undefined, '', '', 401, 404 ],
[ 'DELETE', 'C/R', [ AM.append ], undefined, '', '', 401, 401 ],
[ 'DELETE', 'C/R', [ AM.append ], [ AM.read ], '', '', 401, 404 ],
// We throw a 404 instead of 401 since we don't yet check if the parent container has read permissions
// [ 'DELETE', 'C/R', [ AM.write ], undefined, '', '', 205, 401 ],
[ 'DELETE', 'C/R', [ AM.write ], undefined, '', '', 205, 401 ],
[ 'DELETE', 'C/R', [ AM.write ], [ AM.read ], '', '', 401, 404 ],
[ 'DELETE', 'C/R', [ AM.write ], [ AM.append ], '', '', 401, 401 ],

View File

@ -2,7 +2,7 @@ import { promises as fsPromises } from 'fs';
import type { Stats } from 'fs';
import fetch from 'cross-fetch';
import type { Response } from 'cross-fetch';
import { pathExists } from 'fs-extra';
import { ensureDir, pathExists } from 'fs-extra';
import { joinFilePath, joinUrl } from '../../src';
import type { App } from '../../src';
import { getPort } from '../util/Util';
@ -73,7 +73,7 @@ describe('A quota server', (): void => {
beforeAll(async(): Promise<void> => {
// We want to use an empty folder as on APFS/Mac folder sizes vary a lot
const tempFolder = getTestFolder('quota-temp');
await fsPromises.mkdir(tempFolder);
await ensureDir(tempFolder);
folderSizeTest = await fsPromises.stat(tempFolder);
await removeFolder(tempFolder);
});

View File

@ -0,0 +1,59 @@
import { CreateModesExtractor } from '../../../../src/authorization/permissions/CreateModesExtractor';
import type { ModesExtractor } from '../../../../src/authorization/permissions/ModesExtractor';
import type { AccessMap } from '../../../../src/authorization/permissions/Permissions';
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier';
import type { ResourceSet } from '../../../../src/storage/ResourceSet';
import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
import { compareMaps } from '../../../util/Util';
describe('A CreateModesExtractor', (): void => {
const target: ResourceIdentifier = { path: 'http://example.com/foo' };
let operation: Operation;
let result: AccessMap;
let resourceSet: jest.Mocked<ResourceSet>;
let source: jest.Mocked<ModesExtractor>;
let extractor: CreateModesExtractor;
beforeEach(async(): Promise<void> => {
operation = {
method: 'PATCH',
target,
body: new BasicRepresentation(),
preferences: {},
};
result = new IdentifierSetMultiMap<AccessMode>([[ target, AccessMode.read ]]);
resourceSet = {
hasResource: jest.fn().mockResolvedValue(true),
};
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(result),
} as any;
extractor = new CreateModesExtractor(source, resourceSet);
});
it('checks if the source can handle the input.', async(): Promise<void> => {
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
source.canHandle.mockRejectedValue(new Error('bad data'));
await expect(extractor.canHandle(operation)).rejects.toThrow('bad data');
});
it('does nothing if the resource exists.', async(): Promise<void> => {
await expect(extractor.handle(operation)).resolves.toBe(result);
compareMaps(result, new IdentifierSetMultiMap([[ target, AccessMode.read ]]));
});
it('adds the create mode if the resource does not exist.', async(): Promise<void> => {
resourceSet.hasResource.mockResolvedValue(false);
await expect(extractor.handle(operation)).resolves.toBe(result);
compareMaps(result, new IdentifierSetMultiMap([[ target, AccessMode.read ], [ target, AccessMode.create ]]));
});
});

View File

@ -0,0 +1,94 @@
import { DeleteParentExtractor } from '../../../../src/authorization/permissions/DeleteParentExtractor';
import type { ModesExtractor } from '../../../../src/authorization/permissions/ModesExtractor';
import type { AccessMap } from '../../../../src/authorization/permissions/Permissions';
import { AccessMode } from '../../../../src/authorization/permissions/Permissions';
import type { Operation } from '../../../../src/http/Operation';
import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation';
import type { ResourceSet } from '../../../../src/storage/ResourceSet';
import type { IdentifierStrategy } from '../../../../src/util/identifiers/IdentifierStrategy';
import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap';
describe('A DeleteParentExtractor', (): void => {
const baseUrl = 'http://example.com/';
const resource = 'http://example.com/foo';
let operation: Operation;
let sourceMap: AccessMap;
let source: jest.Mocked<ModesExtractor>;
let resourceSet: jest.Mocked<ResourceSet>;
let identifierStrategy: jest.Mocked<IdentifierStrategy>;
let extractor: DeleteParentExtractor;
beforeEach(async(): Promise<void> => {
operation = {
target: { path: resource },
method: 'DELETE',
preferences: {},
body: new BasicRepresentation(),
};
sourceMap = new IdentifierSetMultiMap();
source = {
canHandle: jest.fn(),
handle: jest.fn().mockResolvedValue(sourceMap),
} as any;
resourceSet = {
hasResource: jest.fn().mockResolvedValue(true),
};
identifierStrategy = {
isRootContainer: jest.fn().mockReturnValue(false),
getParentContainer: jest.fn().mockReturnValue({ path: baseUrl }),
} as any;
extractor = new DeleteParentExtractor(source, resourceSet, identifierStrategy);
});
it('supports input its source supports.', async(): Promise<void> => {
await expect(extractor.canHandle(operation)).resolves.toBeUndefined();
source.canHandle.mockRejectedValue(new Error('bad data'));
await expect(extractor.canHandle(operation)).rejects.toThrow('bad data');
});
it('adds read permission requirements if all conditions are met.', async(): Promise<void> => {
sourceMap.add({ path: resource }, AccessMode.delete);
identifierStrategy.isRootContainer.mockReturnValue(false);
resourceSet.hasResource.mockResolvedValue(false);
const resultMap = await extractor.handle(operation);
expect([ ...resultMap.entries() ]).toHaveLength(2);
expect(resultMap.get({ path: baseUrl })).toContain(AccessMode.read);
});
it('does not change the results if no delete access is required.', async(): Promise<void> => {
sourceMap.add({ path: resource }, AccessMode.read);
identifierStrategy.isRootContainer.mockReturnValue(false);
resourceSet.hasResource.mockResolvedValue(false);
const resultMap = await extractor.handle(operation);
expect([ ...resultMap.entries() ]).toHaveLength(1);
expect(resultMap.get({ path: baseUrl })).toBeUndefined();
});
it('does not change the results if the target is the root container.', async(): Promise<void> => {
sourceMap.add({ path: resource }, AccessMode.delete);
identifierStrategy.isRootContainer.mockReturnValue(true);
resourceSet.hasResource.mockResolvedValue(false);
const resultMap = await extractor.handle(operation);
expect([ ...resultMap.entries() ]).toHaveLength(1);
expect(resultMap.get({ path: baseUrl })).toBeUndefined();
});
it('does not change the results if the target exists.', async(): Promise<void> => {
sourceMap.add({ path: resource }, AccessMode.delete);
identifierStrategy.isRootContainer.mockReturnValue(false);
resourceSet.hasResource.mockResolvedValue(true);
const resultMap = await extractor.handle(operation);
expect([ ...resultMap.entries() ]).toHaveLength(1);
expect(resultMap.get({ path: baseUrl })).toBeUndefined();
});
});

View File

@ -101,6 +101,15 @@ const redis: jest.Mocked<Redis & RedisResourceLock & RedisReadWriteLock> = {
jest.mock('ioredis', (): any => jest.fn().mockImplementation((): Redis => redis));
describe('A RedisLocker', (): void => {
it('will generate keys with the given namespacePrefix.', async(): Promise<void> => {
const identifier = { path: 'http://test.com/resource' };
const lockerPrefixed = new RedisLocker('6379', {}, { namespacePrefix: 'MY_PREFIX' });
await lockerPrefixed.acquire(identifier);
const allLocksPrefixed = Object.keys(store.internal).every((key): boolean => key.startsWith('MY_PREFIX'));
await lockerPrefixed.release(identifier);
expect(allLocksPrefixed).toBeTruthy();
});
describe('with Read-Write logic', (): void => {
const resource1 = { path: 'http://test.com/resource' };
const resource2 = { path: 'http://test.com/resource2' };
@ -392,8 +401,8 @@ describe('A RedisLocker', (): void => {
const emitter = new EventEmitter();
const promise = locker.withWriteLock(resource1, (): any =>
new Promise<void>((resolve): any => emitter.on('release', resolve)));
await redis.releaseWriteLock(`__RW__${resource1.path}`);
await flushPromises();
await redis.releaseWriteLock(`__RW__${resource1.path}`);
emitter.emit('release');
await expect(promise).rejects.toThrow('Redis operation error detected (value was null).');
});