From 0ff05fd420f869e187743163a2bce746100116f1 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 29 Jun 2022 10:59:12 +0200 Subject: [PATCH] feat: Update PermissionReaders to support new permission interface --- src/authorization/AllStaticReader.ts | 22 ++-- src/authorization/AuxiliaryReader.ts | 71 ++++++----- src/authorization/OwnerPermissionReader.ts | 63 ++++++---- src/authorization/PathBasedReader.ts | 45 +++++-- .../PermissionBasedAuthorizer.ts | 46 ++++--- src/authorization/UnionPermissionReader.ts | 28 +++-- src/index.ts | 1 + src/util/map/MapUtil.ts | 59 +++++++++ .../authorization/AllStaticReader.test.ts | 17 +-- .../authorization/AuxiliaryReader.test.ts | 116 ++++++++++-------- .../OwnerPermissionReader.test.ts | 37 +++--- .../authorization/PathBasedReader.test.ts | 70 ++++++----- .../PermissionBasedAuthorizer.test.ts | 75 ++++++++--- .../UnionPermissionReader.test.ts | 68 ++++++---- test/unit/util/map/MapUtil.test.ts | 55 +++++++++ 15 files changed, 525 insertions(+), 248 deletions(-) create mode 100644 src/util/map/MapUtil.ts create mode 100644 test/unit/util/map/MapUtil.test.ts diff --git a/src/authorization/AllStaticReader.ts b/src/authorization/AllStaticReader.ts index 9b9822624..4b88d43ce 100644 --- a/src/authorization/AllStaticReader.ts +++ b/src/authorization/AllStaticReader.ts @@ -1,7 +1,8 @@ -import type { CredentialGroup } from '../authentication/Credentials'; +import type { CredentialGroup, CredentialSet } from '../authentication/Credentials'; +import { IdentifierMap } from '../util/map/IdentifierMap'; import type { PermissionReaderInput } from './PermissionReader'; import { PermissionReader } from './PermissionReader'; -import type { Permission, PermissionSet } from './permissions/Permissions'; +import type { Permission, PermissionMap, PermissionSet } from './permissions/Permissions'; /** * PermissionReader which sets all permissions to true or false @@ -21,12 +22,19 @@ export class AllStaticReader extends PermissionReader { }); } - public async handle({ credentials }: PermissionReaderInput): Promise { + public async handle({ credentials, requestedModes }: PermissionReaderInput): Promise { + const availablePermissions = new IdentifierMap(); + const permissions = this.createPermissions(credentials); + for (const [ identifier ] of requestedModes) { + availablePermissions.set(identifier, permissions); + } + return availablePermissions; + } + + private createPermissions(credentials: CredentialSet): PermissionSet { const result: PermissionSet = {}; - for (const [ key, value ] of Object.entries(credentials) as [CredentialGroup, Permission][]) { - if (value) { - result[key] = this.permissions; - } + for (const group of Object.keys(credentials) as CredentialGroup[]) { + result[group] = this.permissions; } return result; } diff --git a/src/authorization/AuxiliaryReader.ts b/src/authorization/AuxiliaryReader.ts index 092a24bc5..6356dd2ac 100644 --- a/src/authorization/AuxiliaryReader.ts +++ b/src/authorization/AuxiliaryReader.ts @@ -1,56 +1,63 @@ import type { AuxiliaryStrategy } from '../http/auxiliary/AuxiliaryStrategy'; +import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; -import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; +import { IdentifierMap, IdentifierSetMultiMap } from '../util/map/IdentifierMap'; +import type { MapEntry } from '../util/map/MapUtil'; +import { modify } from '../util/map/MapUtil'; import type { PermissionReaderInput } from './PermissionReader'; import { PermissionReader } from './PermissionReader'; -import type { PermissionSet } from './permissions/Permissions'; +import type { AccessMap, AccessMode, PermissionMap } from './permissions/Permissions'; /** - * A PermissionReader for auxiliary resources such as acl or shape resources. - * By default, the access permissions of an auxiliary resource depend on those of its subject resource. - * This authorizer calls the source authorizer with the identifier of the subject resource. + * Determines the permissions of auxiliary resources by finding those of the corresponding subject resources. */ export class AuxiliaryReader extends PermissionReader { protected readonly logger = getLoggerFor(this); - private readonly resourceReader: PermissionReader; + private readonly reader: PermissionReader; private readonly auxiliaryStrategy: AuxiliaryStrategy; - public constructor(resourceReader: PermissionReader, auxiliaryStrategy: AuxiliaryStrategy) { + public constructor(reader: PermissionReader, auxiliaryStrategy: AuxiliaryStrategy) { super(); - this.resourceReader = resourceReader; + this.reader = reader; this.auxiliaryStrategy = auxiliaryStrategy; } - public async canHandle(auxiliaryAuth: PermissionReaderInput): Promise { - const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); - return this.resourceReader.canHandle(resourceAuth); - } + public async handle({ requestedModes, credentials }: PermissionReaderInput): Promise { + // Finds all the dependent auxiliary identifiers + const auxiliaries = this.findAuxiliaries(requestedModes); - public async handle(auxiliaryAuth: PermissionReaderInput): Promise { - const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); - this.logger.debug(`Checking auth request for ${auxiliaryAuth.identifier.path} on ${resourceAuth.identifier.path}`); - return this.resourceReader.handle(resourceAuth); - } + // Replaces the dependent auxiliary identifies with the corresponding subject identifiers + const updatedMap = modify(new IdentifierSetMultiMap(requestedModes), + { add: auxiliaries.values(), remove: auxiliaries.keys() }); + const result = await this.reader.handleSafe({ requestedModes: updatedMap, credentials }); - public async handleSafe(auxiliaryAuth: PermissionReaderInput): Promise { - const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth); - this.logger.debug(`Checking auth request for ${auxiliaryAuth.identifier.path} to ${resourceAuth.identifier.path}`); - return this.resourceReader.handleSafe(resourceAuth); - } - - private getRequiredAuthorization(auxiliaryAuth: PermissionReaderInput): PermissionReaderInput { - if (!this.auxiliaryStrategy.isAuxiliaryIdentifier(auxiliaryAuth.identifier)) { - throw new NotImplementedHttpError('AuxiliaryAuthorizer only supports auxiliary resources.'); + // Extracts the auxiliary permissions based on the subject permissions + for (const [ identifier, [ subject ]] of auxiliaries) { + this.logger.debug(`Mapping ${subject.path} permissions to ${identifier.path}`); + result.set(identifier, result.get(subject) ?? {}); } + return result; + } - if (this.auxiliaryStrategy.usesOwnAuthorization(auxiliaryAuth.identifier)) { - throw new NotImplementedHttpError('Auxiliary resource uses its own permissions.'); + /** + * Maps auxiliary resources that do not have their own authorization checks to their subject resource. + */ + private findAuxiliaries(requestedModes: AccessMap): IdentifierMap> { + const auxiliaries = new IdentifierMap<[ResourceIdentifier, ReadonlySet]>(); + for (const [ identifier, modes ] of requestedModes.entrySets()) { + if (this.isDependentAuxiliary(identifier)) { + auxiliaries.set(identifier, [ this.auxiliaryStrategy.getSubjectIdentifier(identifier), modes ]); + } } + return auxiliaries; + } - return { - ...auxiliaryAuth, - identifier: this.auxiliaryStrategy.getSubjectIdentifier(auxiliaryAuth.identifier), - }; + /** + * Checks if the identifier is an auxiliary resource that uses subject permissions. + */ + private isDependentAuxiliary(identifier: ResourceIdentifier): boolean { + return this.auxiliaryStrategy.isAuxiliaryIdentifier(identifier) && + !this.auxiliaryStrategy.usesOwnAuthorization(identifier); } } diff --git a/src/authorization/OwnerPermissionReader.ts b/src/authorization/OwnerPermissionReader.ts index c752cd1c4..5a43ed888 100644 --- a/src/authorization/OwnerPermissionReader.ts +++ b/src/authorization/OwnerPermissionReader.ts @@ -1,13 +1,18 @@ +import type { CredentialSet } from '../authentication/Credentials'; import { CredentialGroup } from '../authentication/Credentials'; import type { AuxiliaryIdentifierStrategy } from '../http/auxiliary/AuxiliaryIdentifierStrategy'; +import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import type { AccountSettings, AccountStore } from '../identity/interaction/email-password/storage/AccountStore'; import { getLoggerFor } from '../logging/LogUtil'; import { createErrorMessage } from '../util/errors/ErrorUtil'; import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; +import type { IdentifierStrategy } from '../util/identifiers/IdentifierStrategy'; +import { filter } from '../util/IterableUtil'; +import { IdentifierMap } from '../util/map/IdentifierMap'; import type { PermissionReaderInput } from './PermissionReader'; import { PermissionReader } from './PermissionReader'; import type { AclPermission } from './permissions/AclPermission'; -import type { PermissionSet } from './permissions/Permissions'; +import type { PermissionMap } from './permissions/Permissions'; /** * Allows control access if the request is being made by the owner of the pod containing the resource. @@ -17,40 +22,54 @@ export class OwnerPermissionReader extends PermissionReader { private readonly accountStore: AccountStore; private readonly aclStrategy: AuxiliaryIdentifierStrategy; + private readonly identifierStrategy: IdentifierStrategy; - public constructor(accountStore: AccountStore, aclStrategy: AuxiliaryIdentifierStrategy) { + public constructor(accountStore: AccountStore, aclStrategy: AuxiliaryIdentifierStrategy, + identifierStrategy: IdentifierStrategy) { super(); this.accountStore = accountStore; this.aclStrategy = aclStrategy; + this.identifierStrategy = identifierStrategy; } - public async handle(input: PermissionReaderInput): Promise { + public async handle(input: PermissionReaderInput): Promise { + const result: PermissionMap = new IdentifierMap(); + const requestedResources = input.requestedModes.distinctKeys(); + const acls = [ ...filter(requestedResources, (id): boolean => this.aclStrategy.isAuxiliaryIdentifier(id)) ]; + if (acls.length === 0) { + this.logger.debug(`No ACL resources found that need an ownership check.`); + return result; + } + + let podBaseUrl: ResourceIdentifier; try { - await this.ensurePodOwner(input); + podBaseUrl = await this.findPodBaseUrl(input.credentials); } catch (error: unknown) { this.logger.debug(`No pod owner Control permissions: ${createErrorMessage(error)}`); - return {}; + return result; } - this.logger.debug(`Granting Control permissions to owner on ${input.identifier.path}`); - return { [CredentialGroup.agent]: { - read: true, - write: true, - append: true, - create: true, - delete: true, - control: true, - } as AclPermission }; + for (const acl of acls) { + if (this.identifierStrategy.contains(podBaseUrl, acl, true)) { + this.logger.debug(`Granting Control permissions to owner on ${acl.path}`); + result.set(acl, { [CredentialGroup.agent]: { + read: true, + write: true, + append: true, + create: true, + delete: true, + control: true, + } as AclPermission }); + } + } + return result; } /** - * Verify that all conditions are fulfilled to give the owner access. + * Find the base URL of the pod the given credentials own. + * Will throw an error if none can be found. */ - private async ensurePodOwner({ credentials, identifier }: PermissionReaderInput): Promise { - // We only check ownership when an ACL resource is targeted to reduce the number of storage calls - if (!this.aclStrategy.isAuxiliaryIdentifier(identifier)) { - throw new NotImplementedHttpError('Exception is only granted when accessing ACL resources'); - } + private async findPodBaseUrl(credentials: CredentialSet): Promise { if (!credentials.agent?.webId) { throw new NotImplementedHttpError('Only authenticated agents could be owners'); } @@ -63,8 +82,6 @@ export class OwnerPermissionReader extends PermissionReader { if (!settings.podBaseUrl) { throw new NotImplementedHttpError('This agent has no pod on the server'); } - if (!identifier.path.startsWith(settings.podBaseUrl)) { - throw new NotImplementedHttpError('Not targeting the pod owned by this agent'); - } + return { path: settings.podBaseUrl }; } } diff --git a/src/authorization/PathBasedReader.ts b/src/authorization/PathBasedReader.ts index 7e8ea1e2b..6d858a1b5 100644 --- a/src/authorization/PathBasedReader.ts +++ b/src/authorization/PathBasedReader.ts @@ -1,19 +1,22 @@ -import { NotImplementedHttpError } from '../util/errors/NotImplementedHttpError'; +import { getLoggerFor } from '../logging/LogUtil'; +import { concat } from '../util/IterableUtil'; +import { IdentifierMap, IdentifierSetMultiMap } from '../util/map/IdentifierMap'; import { ensureTrailingSlash, trimTrailingSlashes } from '../util/PathUtil'; - import type { PermissionReaderInput } from './PermissionReader'; import { PermissionReader } from './PermissionReader'; -import type { PermissionSet } from './permissions/Permissions'; +import type { AccessMap, PermissionMap } from './permissions/Permissions'; /** * Redirects requests to specific PermissionReaders based on their identifier. - * The keys in the input map will be converted to regular expressions. + * The keys are regular expression strings. * The regular expressions should all start with a slash * and will be evaluated relative to the base URL. * * Will error if no match is found. */ export class PathBasedReader extends PermissionReader { + protected readonly logger = getLoggerFor(this); + private readonly baseUrl: string; private readonly paths: Map; @@ -25,30 +28,46 @@ export class PathBasedReader extends PermissionReader { this.paths = new Map(entries); } - public async canHandle(input: PermissionReaderInput): Promise { - const reader = this.findReader(input.identifier.path); - await reader.canHandle(input); + public async handle(input: PermissionReaderInput): Promise { + const results: PermissionMap[] = []; + for (const [ reader, readerModes ] of this.matchReaders(input.requestedModes)) { + results.push(await reader.handleSafe({ credentials: input.credentials, requestedModes: readerModes })); + } + return new IdentifierMap(concat(results)); } - public async handle(input: PermissionReaderInput): Promise { - const reader = this.findReader(input.identifier.path); - return reader.handle(input); + /** + * Returns for each reader the matching part of the access map. + */ + private matchReaders(accessMap: AccessMap): Map { + const result = new Map(); + for (const [ identifier, modes ] of accessMap) { + const reader = this.findReader(identifier.path); + if (reader) { + let matches = result.get(reader); + if (!matches) { + matches = new IdentifierSetMultiMap(); + result.set(reader, matches); + } + matches.set(identifier, modes); + } + } + return result; } /** * Find the PermissionReader corresponding to the given path. - * Errors if there is no match. */ - private findReader(path: string): PermissionReader { + private findReader(path: string): PermissionReader | undefined { if (path.startsWith(this.baseUrl)) { // We want to keep the leading slash const relative = path.slice(trimTrailingSlashes(this.baseUrl).length); for (const [ regex, reader ] of this.paths) { if (regex.test(relative)) { + this.logger.debug(`Permission reader found for ${path}`); return reader; } } } - throw new NotImplementedHttpError('No regex matches the given path.'); } } diff --git a/src/authorization/PermissionBasedAuthorizer.ts b/src/authorization/PermissionBasedAuthorizer.ts index d71eacce3..0171f8a8f 100644 --- a/src/authorization/PermissionBasedAuthorizer.ts +++ b/src/authorization/PermissionBasedAuthorizer.ts @@ -1,4 +1,5 @@ import type { CredentialSet } from '../authentication/Credentials'; +import type { ResourceIdentifier } from '../http/representation/ResourceIdentifier'; import { getLoggerFor } from '../logging/LogUtil'; import type { ResourceSet } from '../storage/ResourceSet'; import { ForbiddenHttpError } from '../util/errors/ForbiddenHttpError'; @@ -31,28 +32,39 @@ export class PermissionBasedAuthorizer extends Authorizer { } public async handle(input: AuthorizerInput): Promise { - const { credentials, modes, identifier, permissionSet } = input; - - const modeString = [ ...modes ].join(','); - this.logger.debug(`Checking if ${credentials.agent?.webId} has ${modeString} permissions for ${identifier.path}`); + const { credentials, requestedModes, availablePermissions } = input; // Ensure all required modes are within the agent's permissions. - for (const mode of modes) { - try { - this.requireModePermission(credentials, permissionSet, mode); - } catch (error: unknown) { - // If we know the operation will return a 404 regardless (= resource does not exist and is not being created), - // and the agent is allowed to know about its existence (= the agent has Read permissions), - // then immediately send the 404 here, as it makes any other agent permissions irrelevant. - const exposeExistence = this.hasModePermission(permissionSet, AccessMode.read); - if (exposeExistence && !modes.has(AccessMode.create) && !await this.resourceSet.hasResource(identifier)) { - throw new NotFoundHttpError(); + for (const [ identifier, modes ] of requestedModes.entrySets()) { + const modeString = [ ...modes ].join(','); + this.logger.debug(`Checking if ${credentials.agent?.webId} has ${modeString} permissions for ${identifier.path}`); + const permissions = availablePermissions.get(identifier) ?? {}; + for (const mode of modes) { + try { + this.requireModePermission(credentials, permissions, mode); + } catch (error: unknown) { + await this.reportAccessError(identifier, modes, permissions, error); } - // Otherwise, deny access based on existing grounds. - throw error; } + this.logger.debug(`${JSON.stringify(credentials)} has ${modeString} permissions for ${identifier.path}`); } - this.logger.debug(`${JSON.stringify(credentials)} has ${modeString} permissions for ${identifier.path}`); + } + + /** + * If we know the operation will return a 404 regardless (= resource does not exist and is not being created), + * and the agent is allowed to know about its existence (= the agent has Read permissions), + * then immediately send the 404 here, as it makes any other agent permissions irrelevant. + * + * Otherwise, deny access based on existing grounds. + */ + private async reportAccessError(identifier: ResourceIdentifier, modes: ReadonlySet, + permissions: PermissionSet, cause: unknown): Promise { + const exposeExistence = this.hasModePermission(permissions, AccessMode.read); + if (exposeExistence && !modes.has(AccessMode.create) && !await this.resourceSet.hasResource(identifier)) { + throw new NotFoundHttpError(); + } + + throw cause; } /** diff --git a/src/authorization/UnionPermissionReader.ts b/src/authorization/UnionPermissionReader.ts index 34bd41743..e74dc551f 100644 --- a/src/authorization/UnionPermissionReader.ts +++ b/src/authorization/UnionPermissionReader.ts @@ -1,7 +1,9 @@ import type { CredentialGroup } from '../authentication/Credentials'; import { UnionHandler } from '../util/handlers/UnionHandler'; +import { IdentifierMap } from '../util/map/IdentifierMap'; +import { getDefault } from '../util/map/MapUtil'; import type { PermissionReader } from './PermissionReader'; -import type { Permission, PermissionSet } from './permissions/Permissions'; +import type { Permission, PermissionMap } from './permissions/Permissions'; /** * Combines the results of multiple PermissionReaders. @@ -12,20 +14,30 @@ export class UnionPermissionReader extends UnionHandler { super(readers); } - protected async combine(results: PermissionSet[]): Promise { - const result: PermissionSet = {}; - for (const permissionSet of results) { - for (const [ key, value ] of Object.entries(permissionSet) as [ CredentialGroup, Permission | undefined ][]) { - result[key] = this.applyPermissions(value, result[key]); - } + protected async combine(results: PermissionMap[]): Promise { + const result: PermissionMap = new IdentifierMap(); + for (const permissionMap of results) { + this.mergePermissionMaps(permissionMap, result); } return result; } + /** + * Merges all entries of the given map into the result map. + */ + private mergePermissionMaps(permissionMap: PermissionMap, result: PermissionMap): void { + for (const [ identifier, permissionSet ] of permissionMap) { + for (const [ credential, permission ] of Object.entries(permissionSet) as [CredentialGroup, Permission][]) { + const resultSet = getDefault(result, identifier, {}); + resultSet[credential] = this.mergePermissions(permission, resultSet[credential]); + } + } + } + /** * Adds the given permissions to the result object according to the combination rules of the class. */ - private applyPermissions(permissions?: Permission, result: Permission = {}): Permission { + private mergePermissions(permissions?: Permission, result: Permission = {}): Permission { if (!permissions) { return result; } diff --git a/src/index.ts b/src/index.ts index 079e89400..866dc4c05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -441,6 +441,7 @@ export * from './util/locking/VoidLocker'; // Util/Map export * from './util/map/HashMap'; export * from './util/map/IdentifierMap'; +export * from './util/map/MapUtil'; export * from './util/map/SetMultiMap'; export * from './util/map/WrappedSetMultiMap'; diff --git a/src/util/map/MapUtil.ts b/src/util/map/MapUtil.ts new file mode 100644 index 000000000..07d9af6ca --- /dev/null +++ b/src/util/map/MapUtil.ts @@ -0,0 +1,59 @@ +import type { SetMultiMap } from './SetMultiMap'; + +export type MapKey = T extends Map ? TKey : never; +export type MapValue = T extends Map ? TValue : never; +export type MapEntry = T extends Map ? [MapKey, MapValue] : never; + +/** + * A simplified version of {@link MapConstructor} that only allows creating an empty {@link Map}. + */ +export type EmptyMapConstructor = new() => Map; + +/** + * Options describing the necessary changes when calling {@link modify}. + */ +export type ModifyOptions> = { + /** + * Entries that need to be added to the Map. + */ + add?: Iterable>; + /** + * Keys that need to be removed from the Map. + */ + remove?: Iterable>; +}; + +/** + * Modifies a {@link SetMultiMap} in place by removing and adding the requested entries. + * Removals happen before additions. + * + * @param map - Map to start from. + * @param options - {@link ModifyOptions} describing the necessary changes. + */ +export function modify>(map: T, options: ModifyOptions): T { + for (const key of options.remove ?? []) { + map.delete(key); + } + for (const [ key, val ] of options.add ?? []) { + map.add(key, val); + } + return map; +} + +/** + * Finds the result of calling `map.get(key)`. + * If there is no result, it instead returns the default value. + * The Map will also be updated to assign that default value to the given key. + * + * @param map - Map to use. + * @param key - Key to find the value for. + * @param defaultValue - Value to insert and return if no result was found. + */ +export function getDefault(map: Map, key: TKey, defaultValue: TValue): TValue { + const value = map.get(key); + if (value) { + return value; + } + map.set(key, defaultValue); + return defaultValue; +} diff --git a/test/unit/authorization/AllStaticReader.test.ts b/test/unit/authorization/AllStaticReader.test.ts index 057eb69b5..b921e540e 100644 --- a/test/unit/authorization/AllStaticReader.test.ts +++ b/test/unit/authorization/AllStaticReader.test.ts @@ -1,6 +1,9 @@ import { CredentialGroup } from '../../../src/authentication/Credentials'; import { AllStaticReader } from '../../../src/authorization/AllStaticReader'; import type { Permission } from '../../../src/authorization/permissions/Permissions'; +import { AccessMode } from '../../../src/authorization/permissions/Permissions'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { compareMaps } from '../../util/Util'; function getPermissions(allow: boolean): Permission { return { @@ -13,7 +16,7 @@ function getPermissions(allow: boolean): Permission { } describe('An AllStaticReader', (): void => { - const credentials = { [CredentialGroup.agent]: {}, [CredentialGroup.public]: undefined }; + const credentials = { [CredentialGroup.agent]: {}}; const identifier = { path: 'http://test.com/resource' }; it('can handle everything.', async(): Promise => { @@ -23,13 +26,13 @@ describe('An AllStaticReader', (): void => { it('always returns permissions matching the given allow parameter.', async(): Promise => { let authorizer = new AllStaticReader(true); - await expect(authorizer.handle({ credentials, identifier, modes: new Set() })).resolves.toEqual({ - [CredentialGroup.agent]: getPermissions(true), - }); + const requestedModes = new IdentifierSetMultiMap([[ identifier, AccessMode.read ]]); + let result = await authorizer.handle({ credentials, requestedModes }); + compareMaps(result, new IdentifierMap([[ identifier, { [CredentialGroup.agent]: getPermissions(true) }]])); authorizer = new AllStaticReader(false); - await expect(authorizer.handle({ credentials, identifier, modes: new Set() })).resolves.toEqual({ - [CredentialGroup.agent]: getPermissions(false), - }); + + result = await authorizer.handle({ credentials, requestedModes }); + compareMaps(result, new IdentifierMap([[ identifier, { [CredentialGroup.agent]: getPermissions(false) }]])); }); }); diff --git a/test/unit/authorization/AuxiliaryReader.test.ts b/test/unit/authorization/AuxiliaryReader.test.ts index 6d35ea35a..e70baea5a 100644 --- a/test/unit/authorization/AuxiliaryReader.test.ts +++ b/test/unit/authorization/AuxiliaryReader.test.ts @@ -1,83 +1,91 @@ import { CredentialGroup } from '../../../src/authentication/Credentials'; import { AuxiliaryReader } from '../../../src/authorization/AuxiliaryReader'; -import type { PermissionReader } from '../../../src/authorization/PermissionReader'; -import type { AccessMode, PermissionSet } from '../../../src/authorization/permissions/Permissions'; +import type { PermissionReaderInput, PermissionReader } from '../../../src/authorization/PermissionReader'; +import type { AccessMap, PermissionMap, PermissionSet } from '../../../src/authorization/permissions/Permissions'; +import { AccessMode } from '../../../src/authorization/permissions/Permissions'; import type { AuxiliaryStrategy } from '../../../src/http/auxiliary/AuxiliaryStrategy'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; -import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; +import { map } from '../../../src/util/IterableUtil'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { compareMaps } from '../../util/Util'; describe('An AuxiliaryReader', (): void => { - const suffix = '.dummy'; + const suffix1 = '.dummy1'; + const suffix2 = '.dummy2'; const credentials = {}; - const modes = new Set(); const subjectIdentifier = { path: 'http://test.com/foo' }; - const auxiliaryIdentifier = { path: 'http://test.com/foo.dummy' }; + const auxiliaryIdentifier1 = { path: 'http://test.com/foo.dummy1' }; + const auxiliaryIdentifier2 = { path: 'http://test.com/foo.dummy2' }; const permissionSet: PermissionSet = { [CredentialGroup.agent]: { read: true }}; let source: jest.Mocked; let strategy: jest.Mocked; let reader: AuxiliaryReader; + function handleSafe({ requestedModes }: PermissionReaderInput): PermissionMap { + return new IdentifierMap(map(requestedModes.distinctKeys(), (identifier): [ResourceIdentifier, PermissionSet] => + [ identifier, permissionSet ])); + } + beforeEach(async(): Promise => { source = { - canHandle: jest.fn(), - handle: jest.fn().mockResolvedValue(permissionSet), - handleSafe: jest.fn().mockResolvedValue(permissionSet), - }; + handleSafe: jest.fn(handleSafe), + } as any; strategy = { - isAuxiliaryIdentifier: jest.fn((identifier: ResourceIdentifier): boolean => identifier.path.endsWith(suffix)), + isAuxiliaryIdentifier: jest.fn((identifier: ResourceIdentifier): boolean => + identifier.path.endsWith(suffix1) || identifier.path.endsWith(suffix2)), getSubjectIdentifier: jest.fn((identifier: ResourceIdentifier): ResourceIdentifier => - ({ path: identifier.path.slice(0, -suffix.length) })), + ({ path: identifier.path.slice(0, -suffix1.length) })), usesOwnAuthorization: jest.fn().mockReturnValue(false), } as any; reader = new AuxiliaryReader(source, strategy); }); - it('can handle auxiliary resources if the source supports the subject resource.', async(): Promise => { - await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials, modes })) - .resolves.toBeUndefined(); - expect(source.canHandle).toHaveBeenLastCalledWith( - { identifier: subjectIdentifier, credentials, modes }, - ); - await expect(reader.canHandle({ identifier: subjectIdentifier, credentials, modes })) - .rejects.toThrow(NotImplementedHttpError); - - strategy.usesOwnAuthorization.mockReturnValueOnce(true); - await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials, modes })) - .rejects.toThrow(NotImplementedHttpError); - - source.canHandle.mockRejectedValue(new Error('no source support')); - await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials, modes })) - .rejects.toThrow('no source support'); - }); - it('handles resources by sending the updated parameters to the source.', async(): Promise => { - await expect(reader.handle({ identifier: auxiliaryIdentifier, credentials, modes })) - .resolves.toBe(permissionSet); - expect(source.handle).toHaveBeenLastCalledWith( - { identifier: subjectIdentifier, credentials, modes }, - ); - // Safety checks are not present when calling `handle` - await expect(reader.handle({ identifier: subjectIdentifier, credentials, modes })) - .rejects.toThrow(NotImplementedHttpError); + const requestedModes: AccessMap = new IdentifierSetMultiMap([ + [ auxiliaryIdentifier1, AccessMode.delete ], + [{ path: 'http://example.com/other' }, AccessMode.read ], + ]); + const permissionMap: PermissionMap = new IdentifierMap([ + [ subjectIdentifier, permissionSet ], + [{ path: 'http://example.com/other' }, permissionSet ], + [ auxiliaryIdentifier1, permissionSet ], + ]); + compareMaps(await reader.handle({ credentials, requestedModes }), permissionMap); + const mock = source.handleSafe.mock.calls[0][0]; + expect(mock.credentials).toBe(credentials); + expect(mock.requestedModes.get(subjectIdentifier)).toEqual(new Set([ AccessMode.delete ])); + expect(mock.requestedModes.get({ path: 'http://example.com/other' })).toEqual(new Set([ AccessMode.read ])); + expect(mock.requestedModes.size).toBe(2); + expect(mock.requestedModes).not.toEqual(requestedModes); }); - it('combines both checking and handling when calling handleSafe.', async(): Promise => { - await expect(reader.handleSafe({ identifier: auxiliaryIdentifier, credentials, modes })) - .resolves.toBe(permissionSet); - expect(source.handleSafe).toHaveBeenLastCalledWith( - { identifier: subjectIdentifier, credentials, modes }, - ); + it('applies an empty PermissionSet if no permissions were found for the subject.', async(): Promise => { + source.handleSafe.mockResolvedValueOnce(new IdentifierMap()); + const requestedModes: AccessMap = new IdentifierSetMultiMap([ + [ auxiliaryIdentifier1, AccessMode.delete ], + ]); + const permissionMap: PermissionMap = new IdentifierMap([ + [ auxiliaryIdentifier1, {}], + ]); + compareMaps(await reader.handle({ credentials, requestedModes }), permissionMap); + }); - await expect(reader.handleSafe({ identifier: subjectIdentifier, credentials, modes })) - .rejects.toThrow(NotImplementedHttpError); - - strategy.usesOwnAuthorization.mockReturnValueOnce(true); - await expect(reader.canHandle({ identifier: auxiliaryIdentifier, credentials, modes })) - .rejects.toThrow(NotImplementedHttpError); - - source.handleSafe.mockRejectedValue(new Error('no source support')); - await expect(reader.handleSafe({ identifier: auxiliaryIdentifier, credentials, modes })) - .rejects.toThrow('no source support'); + it('combines modes if multiple different auxiliary resources have the same subject.', async(): Promise => { + const requestedModes: AccessMap = new IdentifierSetMultiMap([ + [ auxiliaryIdentifier1, AccessMode.write ], + [ auxiliaryIdentifier2, AccessMode.read ], + [ subjectIdentifier, AccessMode.delete ], + ]); + const resultSet = { [CredentialGroup.agent]: { read: true, write: true, delete: true }}; + source.handleSafe.mockResolvedValueOnce(new IdentifierMap([[ subjectIdentifier, resultSet ]])); + const permissionMap: PermissionMap = new IdentifierMap([ + [ subjectIdentifier, resultSet ], + [ auxiliaryIdentifier1, resultSet ], + [ auxiliaryIdentifier2, resultSet ], + ]); + compareMaps(await reader.handle({ credentials, requestedModes }), permissionMap); + expect(source.handleSafe.mock.calls[0][0].requestedModes.get(subjectIdentifier)) + .toEqual(new Set([ AccessMode.write, AccessMode.read, AccessMode.delete ])); }); }); diff --git a/test/unit/authorization/OwnerPermissionReader.test.ts b/test/unit/authorization/OwnerPermissionReader.test.ts index 9d01788b6..701bc3fd8 100644 --- a/test/unit/authorization/OwnerPermissionReader.test.ts +++ b/test/unit/authorization/OwnerPermissionReader.test.ts @@ -2,23 +2,27 @@ import type { CredentialSet } from '../../../src/authentication/Credentials'; import { CredentialGroup } from '../../../src/authentication/Credentials'; import { OwnerPermissionReader } from '../../../src/authorization/OwnerPermissionReader'; import { AclMode } from '../../../src/authorization/permissions/AclPermission'; -import type { AccessMode } from '../../../src/authorization/permissions/Permissions'; +import type { AccessMap } from '../../../src/authorization/permissions/Permissions'; import type { AuxiliaryIdentifierStrategy } from '../../../src/http/auxiliary/AuxiliaryIdentifierStrategy'; import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; import type { AccountSettings, AccountStore, } from '../../../src/identity/interaction/email-password/storage/AccountStore'; +import { SingleRootIdentifierStrategy } from '../../../src/util/identifiers/SingleRootIdentifierStrategy'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { compareMaps } from '../../util/Util'; describe('An OwnerPermissionReader', (): void => { - const owner = 'http://test.com/alice/profile/card#me'; - const podBaseUrl = 'http://test.com/alice/'; + const owner = 'http://example.com/alice/profile/card#me'; + const podBaseUrl = 'http://example.com/alice/'; let credentials: CredentialSet; let identifier: ResourceIdentifier; - let modes: Set; + let requestedModes: AccessMap; let settings: AccountSettings; let accountStore: jest.Mocked; let aclStrategy: jest.Mocked; + const identifierStrategy = new SingleRootIdentifierStrategy('http://example.com/'); let reader: OwnerPermissionReader; beforeEach(async(): Promise => { @@ -26,7 +30,7 @@ describe('An OwnerPermissionReader', (): void => { identifier = { path: `${podBaseUrl}.acl` }; - modes = new Set([ AclMode.control ]) as Set; + requestedModes = new IdentifierSetMultiMap([[ identifier, AclMode.control ]]) as any; settings = { useIdp: true, @@ -47,44 +51,45 @@ describe('An OwnerPermissionReader', (): void => { isAuxiliaryIdentifier: jest.fn((id): boolean => id.path.endsWith('.acl')), } as any; - reader = new OwnerPermissionReader(accountStore, aclStrategy); + reader = new OwnerPermissionReader(accountStore, aclStrategy, identifierStrategy); }); it('returns empty permissions for non-ACL resources.', async(): Promise => { identifier.path = podBaseUrl; - await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({}); + compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); it('returns empty permissions if there is no agent WebID.', async(): Promise => { credentials = {}; - await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({}); + compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); it('returns empty permissions if the agent has no account.', async(): Promise => { - credentials.agent!.webId = 'http://test.com/someone/else'; - await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({}); + credentials.agent!.webId = 'http://example.com/someone/else'; + compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); it('returns empty permissions if the account has no pod.', async(): Promise => { delete settings.podBaseUrl; - await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({}); + compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); it('returns empty permissions if the target identifier is not in the pod.', async(): Promise => { identifier.path = 'http://somewhere.else/.acl'; - await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({}); + compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap()); }); it('returns full permissions if the owner is accessing an ACL resource in their pod.', async(): Promise => { - await expect(reader.handle({ credentials, identifier, modes })).resolves.toEqual({ - [CredentialGroup.agent]: { + compareMaps(await reader.handle({ credentials, requestedModes }), new IdentifierMap([[ + identifier, + { [CredentialGroup.agent]: { read: true, write: true, append: true, create: true, delete: true, control: true, - }, - }); + }}, + ]])); }); }); diff --git a/test/unit/authorization/PathBasedReader.test.ts b/test/unit/authorization/PathBasedReader.test.ts index d051aac4f..29abccd79 100644 --- a/test/unit/authorization/PathBasedReader.test.ts +++ b/test/unit/authorization/PathBasedReader.test.ts @@ -1,26 +1,29 @@ import { CredentialGroup } from '../../../src/authentication/Credentials'; import { PathBasedReader } from '../../../src/authorization/PathBasedReader'; import type { PermissionReader, PermissionReaderInput } from '../../../src/authorization/PermissionReader'; -import type { PermissionSet } from '../../../src/authorization/permissions/Permissions'; -import { NotImplementedHttpError } from '../../../src/util/errors/NotImplementedHttpError'; +import type { PermissionMap, PermissionSet } from '../../../src/authorization/permissions/Permissions'; +import { AccessMode } from '../../../src/authorization/permissions/Permissions'; +import type { ResourceIdentifier } from '../../../src/http/representation/ResourceIdentifier'; +import { map } from '../../../src/util/IterableUtil'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { joinUrl } from '../../../src/util/PathUtil'; +import { compareMaps } from '../../util/Util'; describe('A PathBasedReader', (): void => { const baseUrl = 'http://test.com/foo/'; const permissionSet: PermissionSet = { [CredentialGroup.agent]: { read: true }}; - let input: PermissionReaderInput; let readers: jest.Mocked[]; let reader: PathBasedReader; - beforeEach(async(): Promise => { - input = { - identifier: { path: `${baseUrl}first` }, - credentials: {}, - modes: new Set(), - }; + function handleSafe({ requestedModes }: PermissionReaderInput): PermissionMap { + return new IdentifierMap(map(requestedModes.distinctKeys(), (identifier): [ResourceIdentifier, PermissionSet] => + [ identifier, permissionSet ])); + } + beforeEach(async(): Promise => { readers = [ - { canHandle: jest.fn(), handle: jest.fn().mockResolvedValue(permissionSet) }, - { canHandle: jest.fn(), handle: jest.fn().mockResolvedValue(permissionSet) }, + { canHandle: jest.fn(), handleSafe: jest.fn(handleSafe) }, + { canHandle: jest.fn(), handleSafe: jest.fn(handleSafe) }, ] as any; const paths = { '/first': readers[0], @@ -29,26 +32,31 @@ describe('A PathBasedReader', (): void => { reader = new PathBasedReader(baseUrl, paths); }); - it('can only handle requests with a matching path.', async(): Promise => { - input.identifier.path = 'http://wrongsite/'; - await expect(reader.canHandle(input)).rejects.toThrow(NotImplementedHttpError); - input.identifier.path = `${baseUrl}third`; - await expect(reader.canHandle(input)).rejects.toThrow(NotImplementedHttpError); - input.identifier.path = `${baseUrl}first`; - await expect(reader.canHandle(input)).resolves.toBeUndefined(); - input.identifier.path = `${baseUrl}second`; - await expect(reader.canHandle(input)).resolves.toBeUndefined(); - }); - - it('can only handle requests supported by the stored readers.', async(): Promise => { - await expect(reader.canHandle(input)).resolves.toBeUndefined(); - readers[0].canHandle.mockRejectedValueOnce(new Error('not supported')); - await expect(reader.canHandle(input)).rejects.toThrow('not supported'); - }); - it('passes the handle requests to the matching reader.', async(): Promise => { - await expect(reader.handle(input)).resolves.toBe(permissionSet); - expect(readers[0].handle).toHaveBeenCalledTimes(1); - expect(readers[0].handle).toHaveBeenLastCalledWith(input); + const input: PermissionReaderInput = { + credentials: {}, + requestedModes: new IdentifierSetMultiMap([ + [{ path: joinUrl(baseUrl, 'first') }, AccessMode.read ], + [{ path: joinUrl(baseUrl, 'second') }, AccessMode.read ], + [{ path: joinUrl(baseUrl, 'nothere') }, AccessMode.read ], + [{ path: 'http://wrongsite' }, AccessMode.read ], + ]), + }; + + const result = new IdentifierMap([ + [{ path: joinUrl(baseUrl, 'first') }, permissionSet ], + [{ path: joinUrl(baseUrl, 'second') }, permissionSet ], + ]); + + await expect(reader.handle(input)).resolves.toEqual(result); + expect(readers[0].handleSafe).toHaveBeenCalledTimes(1); + expect(readers[0].handleSafe.mock.calls[0][0].credentials).toEqual({}); + compareMaps(readers[0].handleSafe.mock.calls[0][0].requestedModes, + new IdentifierSetMultiMap([[{ path: joinUrl(baseUrl, 'first') }, AccessMode.read ]])); + + expect(readers[1].handleSafe).toHaveBeenCalledTimes(1); + expect(readers[1].handleSafe.mock.calls[0][0].credentials).toEqual({}); + compareMaps(readers[1].handleSafe.mock.calls[0][0].requestedModes, + new IdentifierSetMultiMap([[{ path: joinUrl(baseUrl, 'second') }, AccessMode.read ]])); }); }); diff --git a/test/unit/authorization/PermissionBasedAuthorizer.test.ts b/test/unit/authorization/PermissionBasedAuthorizer.test.ts index cae015848..e10b8af83 100644 --- a/test/unit/authorization/PermissionBasedAuthorizer.test.ts +++ b/test/unit/authorization/PermissionBasedAuthorizer.test.ts @@ -6,17 +6,18 @@ import type { ResourceSet } from '../../../src/storage/ResourceSet'; import { ForbiddenHttpError } from '../../../src/util/errors/ForbiddenHttpError'; import { NotFoundHttpError } from '../../../src/util/errors/NotFoundHttpError'; import { UnauthorizedHttpError } from '../../../src/util/errors/UnauthorizedHttpError'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; describe('A PermissionBasedAuthorizer', (): void => { + const identifier = { path: 'http://example.com/foo' }; let input: AuthorizerInput; let resourceSet: jest.Mocked; let authorizer: PermissionBasedAuthorizer; beforeEach(async(): Promise => { input = { - identifier: { path: 'http://test.com/foo' }, - modes: new Set(), - permissionSet: {}, + requestedModes: new IdentifierSetMultiMap(), + availablePermissions: new IdentifierMap(), credentials: {}, }; resourceSet = { @@ -31,28 +32,34 @@ describe('A PermissionBasedAuthorizer', (): void => { }); it('allows access if the permissions are matched by the reader output.', async(): Promise => { - input.modes = new Set([ AccessMode.read, AccessMode.write ]); - input.permissionSet = { + input.requestedModes = new IdentifierSetMultiMap( + [[ identifier, AccessMode.read ], [ identifier, AccessMode.write ]], + ); + input.availablePermissions = new IdentifierMap([[ identifier, { [CredentialGroup.public]: { read: true, write: false }, [CredentialGroup.agent]: { write: true }, - }; + }]]); await expect(authorizer.handle(input)).resolves.toBeUndefined(); }); it('throws an UnauthorizedHttpError when an unauthenticated request has no access.', async(): Promise => { - input.modes = new Set([ AccessMode.read, AccessMode.write ]); - input.permissionSet = { + input.requestedModes = new IdentifierSetMultiMap( + [[ identifier, AccessMode.read ], [ identifier, AccessMode.write ]], + ); + input.availablePermissions = new IdentifierMap([[ identifier, { [CredentialGroup.public]: { read: true, write: false }, - }; + }]]); await expect(authorizer.handle(input)).rejects.toThrow(UnauthorizedHttpError); }); it('throws a ForbiddenHttpError when an authenticated request has no access.', async(): Promise => { input.credentials = { agent: { webId: 'http://test.com/#me' }}; - input.modes = new Set([ AccessMode.read, AccessMode.write ]); - input.permissionSet = { + input.requestedModes = new IdentifierSetMultiMap( + [[ identifier, AccessMode.read ], [ identifier, AccessMode.write ]], + ); + input.availablePermissions = new IdentifierMap([[ identifier, { [CredentialGroup.public]: { read: true, write: false }, - }; + }]]); await expect(authorizer.handle(input)).rejects.toThrow(ForbiddenHttpError); }); @@ -62,10 +69,48 @@ describe('A PermissionBasedAuthorizer', (): void => { it('throws a 404 in case the target resource does not exist and would not be written to.', async(): Promise => { resourceSet.hasResource.mockResolvedValueOnce(false); - input.modes = new Set([ AccessMode.delete ]); - input.permissionSet = { + input.requestedModes = new IdentifierSetMultiMap([[ identifier, AccessMode.delete ]]); + input.availablePermissions = new IdentifierMap([[ identifier, { [CredentialGroup.public]: { read: true }, - }; + }]]); await expect(authorizer.handle(input)).rejects.toThrow(NotFoundHttpError); }); + + it('throws a ForbiddenHttpError if only some identifiers are authorized.', async(): Promise => { + const identifier2 = { path: 'http://example.com/no-access' }; + input.requestedModes = new IdentifierSetMultiMap([ + [ identifier, AccessMode.read ], + [ identifier, AccessMode.write ], + [ identifier2, AccessMode.read ], + [ identifier2, AccessMode.write ], + ]); + input.availablePermissions = new IdentifierMap([ + [ identifier, { + [CredentialGroup.public]: { read: true, write: false }, + [CredentialGroup.agent]: { write: true }, + }], + [ identifier2, { + [CredentialGroup.public]: { read: false }, + [CredentialGroup.agent]: { write: true }, + }], + ]); + await expect(authorizer.handle(input)).rejects.toThrow(UnauthorizedHttpError); + }); + + it('throws a ForbiddenHttpError if identifiers have no PermissionMap entry.', async(): Promise => { + const identifier2 = { path: 'http://example.com/no-access' }; + input.requestedModes = new IdentifierSetMultiMap([ + [ identifier, AccessMode.read ], + [ identifier, AccessMode.write ], + [ identifier2, AccessMode.read ], + [ identifier2, AccessMode.write ], + ]); + input.availablePermissions = new IdentifierMap([ + [ identifier, { + [CredentialGroup.public]: { read: true, write: false }, + [CredentialGroup.agent]: { write: true }, + }], + ]); + await expect(authorizer.handle(input)).rejects.toThrow(UnauthorizedHttpError); + }); }); diff --git a/test/unit/authorization/UnionPermissionReader.test.ts b/test/unit/authorization/UnionPermissionReader.test.ts index 0b0bbea65..570859217 100644 --- a/test/unit/authorization/UnionPermissionReader.test.ts +++ b/test/unit/authorization/UnionPermissionReader.test.ts @@ -1,10 +1,15 @@ import { CredentialGroup } from '../../../src/authentication/Credentials'; import type { PermissionReader, PermissionReaderInput } from '../../../src/authorization/PermissionReader'; +import type { PermissionSet } from '../../../src/authorization/permissions/Permissions'; +import { AccessMode } from '../../../src/authorization/permissions/Permissions'; import { UnionPermissionReader } from '../../../src/authorization/UnionPermissionReader'; +import { IdentifierMap, IdentifierSetMultiMap } from '../../../src/util/map/IdentifierMap'; +import { compareMaps } from '../../util/Util'; describe('A UnionPermissionReader', (): void => { - const input: PermissionReaderInput = - { credentials: {}, identifier: { path: 'http://test.com/foo' }, modes: new Set() }; + const identifier = { path: 'http://example.com/foo' }; + const input: PermissionReaderInput = { credentials: {}, + requestedModes: new IdentifierSetMultiMap([[ identifier, AccessMode.read ]]) }; let readers: jest.Mocked[]; let unionReader: UnionPermissionReader; @@ -12,11 +17,11 @@ describe('A UnionPermissionReader', (): void => { readers = [ { canHandle: jest.fn(), - handle: jest.fn().mockResolvedValue({}), + handle: jest.fn().mockResolvedValue(new IdentifierMap()), } as any, { canHandle: jest.fn(), - handle: jest.fn().mockResolvedValue({}), + handle: jest.fn().mockResolvedValue(new IdentifierMap()), } as any, ]; @@ -25,33 +30,46 @@ describe('A UnionPermissionReader', (): void => { it('only uses the results of readers that can handle the input.', async(): Promise => { readers[0].canHandle.mockRejectedValue(new Error('bad request')); - readers[0].handle.mockResolvedValue({ [CredentialGroup.agent]: { read: true }}); - readers[1].handle.mockResolvedValue({ [CredentialGroup.agent]: { write: true }}); - await expect(unionReader.handle(input)).resolves.toEqual({ [CredentialGroup.agent]: { write: true }}); + readers[0].handle.mockResolvedValue( + new IdentifierMap([[ identifier, { [CredentialGroup.agent]: { read: true }}]]), + ); + readers[1].handle.mockResolvedValue( + new IdentifierMap([[ identifier, { [CredentialGroup.agent]: { write: true }}]]), + ); + compareMaps(await unionReader.handle(input), + new IdentifierMap([[ identifier, { [CredentialGroup.agent]: { write: true }}]])); }); it('combines results.', async(): Promise => { - readers[0].handle.mockResolvedValue( - { [CredentialGroup.agent]: { read: true }, [CredentialGroup.public]: undefined }, - ); - readers[1].handle.mockResolvedValue( - { [CredentialGroup.agent]: { write: true }, [CredentialGroup.public]: { read: false }}, - ); - await expect(unionReader.handle(input)).resolves.toEqual({ - [CredentialGroup.agent]: { read: true, write: true }, - [CredentialGroup.public]: { read: false }, - }); + const identifier2 = { path: 'http://example.com/foo2' }; + const identifier3 = { path: 'http://example.com/foo3' }; + readers[0].handle.mockResolvedValue(new IdentifierMap([ + [ identifier, { [CredentialGroup.agent]: { read: true }, [CredentialGroup.public]: undefined }], + [ identifier2, { [CredentialGroup.agent]: { write: true }}], + [ identifier3, { [CredentialGroup.agent]: { append: false }, [CredentialGroup.public]: { delete: true }}], + ])); + readers[1].handle.mockResolvedValue(new IdentifierMap([ + [ identifier, { [CredentialGroup.agent]: { write: true }, [CredentialGroup.public]: { read: false }}], + [ identifier2, { [CredentialGroup.public]: { read: false }}], + ])); + compareMaps(await unionReader.handle(input), new IdentifierMap([ + [ identifier, { [CredentialGroup.agent]: { read: true, write: true }, [CredentialGroup.public]: { read: false }}], + [ identifier2, { [CredentialGroup.agent]: { write: true }, [CredentialGroup.public]: { read: false }}], + [ identifier3, { [CredentialGroup.agent]: { append: false }, [CredentialGroup.public]: { delete: true }}], + ])); }); it('merges same fields using false > true > undefined.', async(): Promise => { - readers[0].handle.mockResolvedValue( - { [CredentialGroup.agent]: { read: true, write: false, append: undefined, create: true, delete: undefined }}, - ); - readers[1].handle.mockResolvedValue( - { [CredentialGroup.agent]: { read: false, write: true, append: true, create: true, delete: undefined }}, - ); - await expect(unionReader.handle(input)).resolves.toEqual({ + readers[0].handle.mockResolvedValue(new IdentifierMap( + [[ identifier, + { [CredentialGroup.agent]: { read: true, write: false, append: undefined, create: true, delete: undefined }}]], + )); + readers[1].handle.mockResolvedValue(new IdentifierMap( + [[ identifier, { [CredentialGroup.agent]: + { read: false, write: true, append: true, create: true, delete: undefined }}]], + )); + compareMaps(await unionReader.handle(input), new IdentifierMap([[ identifier, { [CredentialGroup.agent]: { read: false, write: false, append: true, create: true }, - }); + }]])); }); }); diff --git a/test/unit/util/map/MapUtil.test.ts b/test/unit/util/map/MapUtil.test.ts new file mode 100644 index 000000000..d9fcc808e --- /dev/null +++ b/test/unit/util/map/MapUtil.test.ts @@ -0,0 +1,55 @@ +import { getDefault, modify } from '../../../../src/util/map/MapUtil'; +import { WrappedSetMultiMap } from '../../../../src/util/map/WrappedSetMultiMap'; +import { compareMaps } from '../../../util/Util'; + +describe('MapUtil', (): void => { + const key1 = 'key1'; + const key2 = 'key2'; + const key3 = 'key3'; + + describe('#modify', (): void => { + it('modifies the map as specified.', async(): Promise => { + const map = new WrappedSetMultiMap(undefined, [ + [ key1, 123 ], + [ key2, 123 ], + ]); + const add: Iterable<[string, number]> = [[ key1, 456 ], [ key3, 123 ]]; + const remove = [ key2 ]; + + const expected = new WrappedSetMultiMap(undefined, [ + [ key1, 123 ], + [ key1, 456 ], + [ key3, 123 ], + ]); + + modify(map, { add, remove }); + compareMaps(map, expected); + }); + it('defaults to empty add and delete Iterables.', async(): Promise => { + const map = new WrappedSetMultiMap(undefined, [ + [ key1, 123 ], + [ key2, 123 ], + ]); + + const expected = new WrappedSetMultiMap(undefined, [ + [ key1, 123 ], + [ key2, 123 ], + ]); + + modify(map, {}); + compareMaps(map, expected); + }); + }); + + describe('#getDefault', (): void => { + it('returns the value it finds in the Map for the given key.', async(): Promise => { + const map = new Map([[ key1, 123 ]]); + expect(getDefault(map, key1, 999)).toBe(123); + }); + + it('returns the default value if it finds no value for the given key.', async(): Promise => { + const map = new Map([[ key1, 123 ]]); + expect(getDefault(map, key2, 999)).toBe(999); + }); + }); +});