feat: Update PermissionReaders to support new permission interface

This commit is contained in:
Joachim Van Herwegen
2022-06-29 10:59:12 +02:00
parent 11c0d1d6cf
commit 0ff05fd420
15 changed files with 525 additions and 248 deletions

View File

@@ -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<PermissionSet> {
public async handle({ credentials, requestedModes }: PermissionReaderInput): Promise<PermissionMap> {
const availablePermissions = new IdentifierMap<PermissionSet>();
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;
}

View File

@@ -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<void> {
const resourceAuth = this.getRequiredAuthorization(auxiliaryAuth);
return this.resourceReader.canHandle(resourceAuth);
}
public async handle({ requestedModes, credentials }: PermissionReaderInput): Promise<PermissionMap> {
// Finds all the dependent auxiliary identifiers
const auxiliaries = this.findAuxiliaries(requestedModes);
public async handle(auxiliaryAuth: PermissionReaderInput): Promise<PermissionSet> {
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<PermissionSet> {
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<MapEntry<AccessMap>> {
const auxiliaries = new IdentifierMap<[ResourceIdentifier, ReadonlySet<AccessMode>]>();
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);
}
}

View File

@@ -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<PermissionSet> {
public async handle(input: PermissionReaderInput): Promise<PermissionMap> {
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<void> {
// 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<ResourceIdentifier> {
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 };
}
}

View File

@@ -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<RegExp, PermissionReader>;
@@ -25,30 +28,46 @@ export class PathBasedReader extends PermissionReader {
this.paths = new Map(entries);
}
public async canHandle(input: PermissionReaderInput): Promise<void> {
const reader = this.findReader(input.identifier.path);
await reader.canHandle(input);
public async handle(input: PermissionReaderInput): Promise<PermissionMap> {
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<PermissionSet> {
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<PermissionReader, AccessMap> {
const result = new Map<PermissionReader, AccessMap>();
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.');
}
}

View File

@@ -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<void> {
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<AccessMode>,
permissions: PermissionSet, cause: unknown): Promise<never> {
const exposeExistence = this.hasModePermission(permissions, AccessMode.read);
if (exposeExistence && !modes.has(AccessMode.create) && !await this.resourceSet.hasResource(identifier)) {
throw new NotFoundHttpError();
}
throw cause;
}
/**

View File

@@ -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<PermissionReader> {
super(readers);
}
protected async combine(results: PermissionSet[]): Promise<PermissionSet> {
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<PermissionMap> {
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;
}

View File

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

59
src/util/map/MapUtil.ts Normal file
View File

@@ -0,0 +1,59 @@
import type { SetMultiMap } from './SetMultiMap';
export type MapKey<T> = T extends Map<infer TKey, any> ? TKey : never;
export type MapValue<T> = T extends Map<any, infer TValue> ? TValue : never;
export type MapEntry<T> = T extends Map<any, any> ? [MapKey<T>, MapValue<T>] : never;
/**
* A simplified version of {@link MapConstructor} that only allows creating an empty {@link Map}.
*/
export type EmptyMapConstructor = new() => Map<any, any>;
/**
* Options describing the necessary changes when calling {@link modify}.
*/
export type ModifyOptions<T extends SetMultiMap<any, any>> = {
/**
* Entries that need to be added to the Map.
*/
add?: Iterable<MapEntry<T>>;
/**
* Keys that need to be removed from the Map.
*/
remove?: Iterable<MapKey<T>>;
};
/**
* 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<T extends SetMultiMap<any, any>>(map: T, options: ModifyOptions<T>): 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<TKey, TValue>(map: Map<TKey, TValue>, key: TKey, defaultValue: TValue): TValue {
const value = map.get(key);
if (value) {
return value;
}
map.set(key, defaultValue);
return defaultValue;
}

View File

@@ -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<void> => {
@@ -23,13 +26,13 @@ describe('An AllStaticReader', (): void => {
it('always returns permissions matching the given allow parameter.', async(): Promise<void> => {
let authorizer = new AllStaticReader(true);
await expect(authorizer.handle({ credentials, identifier, modes: new Set() })).resolves.toEqual({
[CredentialGroup.agent]: getPermissions(true),
});
const requestedModes = new IdentifierSetMultiMap<AccessMode>([[ 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) }]]));
});
});

View File

@@ -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<AccessMode>();
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<PermissionReader>;
let strategy: jest.Mocked<AuxiliaryStrategy>;
let reader: AuxiliaryReader;
function handleSafe({ requestedModes }: PermissionReaderInput): PermissionMap {
return new IdentifierMap(map(requestedModes.distinctKeys(), (identifier): [ResourceIdentifier, PermissionSet] =>
[ identifier, permissionSet ]));
}
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<AccessMode>([
[ 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<void> => {
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<void> => {
source.handleSafe.mockResolvedValueOnce(new IdentifierMap());
const requestedModes: AccessMap = new IdentifierSetMultiMap<AccessMode>([
[ 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<void> => {
const requestedModes: AccessMap = new IdentifierSetMultiMap<AccessMode>([
[ 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 ]));
});
});

View File

@@ -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<AccessMode>;
let requestedModes: AccessMap;
let settings: AccountSettings;
let accountStore: jest.Mocked<AccountStore>;
let aclStrategy: jest.Mocked<AuxiliaryIdentifierStrategy>;
const identifierStrategy = new SingleRootIdentifierStrategy('http://example.com/');
let reader: OwnerPermissionReader;
beforeEach(async(): Promise<void> => {
@@ -26,7 +30,7 @@ describe('An OwnerPermissionReader', (): void => {
identifier = { path: `${podBaseUrl}.acl` };
modes = new Set<AccessMode | AclMode>([ AclMode.control ]) as Set<AccessMode>;
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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,
},
});
}},
]]));
});
});

View File

@@ -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<PermissionReader>[];
let reader: PathBasedReader;
beforeEach(async(): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<AccessMode>([
[{ 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 ]]));
});
});

View File

@@ -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<ResourceSet>;
let authorizer: PermissionBasedAuthorizer;
beforeEach(async(): Promise<void> => {
input = {
identifier: { path: 'http://test.com/foo' },
modes: new Set<AccessMode>(),
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<void> => {
input.modes = new Set([ AccessMode.read, AccessMode.write ]);
input.permissionSet = {
input.requestedModes = new IdentifierSetMultiMap<AccessMode>(
[[ 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<void> => {
input.modes = new Set([ AccessMode.read, AccessMode.write ]);
input.permissionSet = {
input.requestedModes = new IdentifierSetMultiMap<AccessMode>(
[[ 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<void> => {
input.credentials = { agent: { webId: 'http://test.com/#me' }};
input.modes = new Set([ AccessMode.read, AccessMode.write ]);
input.permissionSet = {
input.requestedModes = new IdentifierSetMultiMap<AccessMode>(
[[ 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<void> => {
resourceSet.hasResource.mockResolvedValueOnce(false);
input.modes = new Set([ AccessMode.delete ]);
input.permissionSet = {
input.requestedModes = new IdentifierSetMultiMap<AccessMode>([[ 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<void> => {
const identifier2 = { path: 'http://example.com/no-access' };
input.requestedModes = new IdentifierSetMultiMap<AccessMode>([
[ 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<void> => {
const identifier2 = { path: 'http://example.com/no-access' };
input.requestedModes = new IdentifierSetMultiMap<AccessMode>([
[ 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);
});
});

View File

@@ -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<AccessMode>([[ identifier, AccessMode.read ]]) };
let readers: jest.Mocked<PermissionReader>[];
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<void> => {
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<void> => {
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<PermissionSet>([
[ 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<void> => {
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 },
});
}]]));
});
});

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
const map = new Map([[ key1, 123 ]]);
expect(getDefault(map, key2, 999)).toBe(999);
});
});
});