From 7085252b3f22a856ffb6a257aa8d1b8f64c7163b Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Wed, 29 Jun 2022 10:56:17 +0200 Subject: [PATCH] feat: Update ModesExtractors to support new permission interface --- .../permissions/MethodModesExtractor.ts | 20 ++-- .../permissions/N3PatchModesExtractor.ts | 18 ++-- .../permissions/SparqlUpdateModesExtractor.ts | 20 ++-- src/index.ts | 3 +- .../permissions/MethodModesExtractor.test.ts | 51 ++++++---- .../permissions/N3PatchModesExtractor.test.ts | 24 +++-- .../SparqlUpdateModesExtractor.test.ts | 93 ++++++++++--------- test/util/Util.ts | 12 +++ 8 files changed, 147 insertions(+), 94 deletions(-) diff --git a/src/authorization/permissions/MethodModesExtractor.ts b/src/authorization/permissions/MethodModesExtractor.ts index 944e6a556..0d995ad35 100644 --- a/src/authorization/permissions/MethodModesExtractor.ts +++ b/src/authorization/permissions/MethodModesExtractor.ts @@ -1,8 +1,10 @@ import type { Operation } from '../../http/Operation'; import type { ResourceSet } from '../../storage/ResourceSet'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { IdentifierSetMultiMap } from '../../util/map/IdentifierMap'; import { isContainerIdentifier } from '../../util/PathUtil'; import { ModesExtractor } from './ModesExtractor'; +import type { AccessMap } from './Permissions'; import { AccessMode } from './Permissions'; const READ_METHODS = new Set([ 'OPTIONS', 'GET', 'HEAD' ]); @@ -31,33 +33,33 @@ export class MethodModesExtractor extends ModesExtractor { } } - public async handle({ method, target }: Operation): Promise> { - const modes = new Set(); + public async handle({ method, target }: Operation): Promise { + const requiredModes: AccessMap = new IdentifierSetMultiMap(); // Reading requires Read permissions on the resource if (READ_METHODS.has(method)) { - modes.add(AccessMode.read); + requiredModes.add(target, AccessMode.read); } // Setting a resource's representation requires Write permissions if (method === 'PUT') { - modes.add(AccessMode.write); + requiredModes.add(target, AccessMode.write); // …and, if the resource does not exist yet, Create permissions are required as well if (!await this.resourceSet.hasResource(target)) { - modes.add(AccessMode.create); + requiredModes.add(target, AccessMode.create); } } // Creating a new resource in a container requires Append access to that container if (method === 'POST') { - modes.add(AccessMode.append); + requiredModes.add(target, AccessMode.append); } // Deleting a resource requires Delete access if (method === 'DELETE') { - modes.add(AccessMode.delete); + requiredModes.add(target, AccessMode.delete); // …and, if the target is a container, Read permissions are required as well // as this exposes if a container is empty or not if (isContainerIdentifier(target)) { - modes.add(AccessMode.read); + requiredModes.add(target, AccessMode.read); } } - return modes; + return requiredModes; } } diff --git a/src/authorization/permissions/N3PatchModesExtractor.ts b/src/authorization/permissions/N3PatchModesExtractor.ts index 659346dc1..8f7bb2ca9 100644 --- a/src/authorization/permissions/N3PatchModesExtractor.ts +++ b/src/authorization/permissions/N3PatchModesExtractor.ts @@ -3,7 +3,9 @@ import type { N3Patch } from '../../http/representation/N3Patch'; import { isN3Patch } from '../../http/representation/N3Patch'; import type { ResourceSet } from '../../storage/ResourceSet'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { IdentifierSetMultiMap } from '../../util/map/IdentifierMap'; import { ModesExtractor } from './ModesExtractor'; +import type { AccessMap } from './Permissions'; import { AccessMode } from './Permissions'; /** @@ -33,28 +35,28 @@ export class N3PatchModesExtractor extends ModesExtractor { } } - public async handle({ body, target }: Operation): Promise> { + public async handle({ body, target }: Operation): Promise { const { deletes, inserts, conditions } = body as N3Patch; - const accessModes = new Set(); + const requiredModes: AccessMap = new IdentifierSetMultiMap(); // When ?conditions is non-empty, servers MUST treat the request as a Read operation. if (conditions.length > 0) { - accessModes.add(AccessMode.read); + requiredModes.add(target, AccessMode.read); } // When ?insertions is non-empty, servers MUST (also) treat the request as an Append operation. if (inserts.length > 0) { - accessModes.add(AccessMode.append); + requiredModes.add(target, AccessMode.append); if (!await this.resourceSet.hasResource(target)) { - accessModes.add(AccessMode.create); + requiredModes.add(target, AccessMode.create); } } // When ?deletions is non-empty, servers MUST treat the request as a Read and Write operation. if (deletes.length > 0) { - accessModes.add(AccessMode.read); - accessModes.add(AccessMode.write); + requiredModes.add(target, AccessMode.read); + requiredModes.add(target, AccessMode.write); } - return accessModes; + return requiredModes; } } diff --git a/src/authorization/permissions/SparqlUpdateModesExtractor.ts b/src/authorization/permissions/SparqlUpdateModesExtractor.ts index 41f2f3459..56d789dc7 100644 --- a/src/authorization/permissions/SparqlUpdateModesExtractor.ts +++ b/src/authorization/permissions/SparqlUpdateModesExtractor.ts @@ -4,7 +4,9 @@ import type { Representation } from '../../http/representation/Representation'; import type { SparqlUpdatePatch } from '../../http/representation/SparqlUpdatePatch'; import type { ResourceSet } from '../../storage/ResourceSet'; import { NotImplementedHttpError } from '../../util/errors/NotImplementedHttpError'; +import { IdentifierSetMultiMap } from '../../util/map/IdentifierMap'; import { ModesExtractor } from './ModesExtractor'; +import type { AccessMap } from './Permissions'; import { AccessMode } from './Permissions'; /** @@ -34,31 +36,31 @@ export class SparqlUpdateModesExtractor extends ModesExtractor { } } - public async handle({ body, target }: Operation): Promise> { + public async handle({ body, target }: Operation): Promise { // Verified in `canHandle` call const update = (body as SparqlUpdatePatch).algebra as Algebra.DeleteInsert; - const modes = new Set(); + const requiredModes: AccessMap = new IdentifierSetMultiMap(); if (this.isNop(update)) { - return modes; + return requiredModes; } // Access modes inspired by the requirements on N3 Patch requests if (this.hasConditions(update)) { - modes.add(AccessMode.read); + requiredModes.add(target, AccessMode.read); } if (this.hasInserts(update)) { - modes.add(AccessMode.append); + requiredModes.add(target, AccessMode.append); if (!await this.resourceSet.hasResource(target)) { - modes.add(AccessMode.create); + requiredModes.add(target, AccessMode.create); } } if (this.hasDeletes(update)) { - modes.add(AccessMode.read); - modes.add(AccessMode.write); + requiredModes.add(target, AccessMode.read); + requiredModes.add(target, AccessMode.write); } - return modes; + return requiredModes; } private isSparql(data: Representation): data is SparqlUpdatePatch { diff --git a/src/index.ts b/src/index.ts index bfb942bce..b651a8b45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,10 +15,11 @@ export * from './authorization/access/AgentClassAccessChecker'; export * from './authorization/access/AgentGroupAccessChecker'; // Authorization/Permissions -export * from './authorization/permissions/Permissions'; +export * from './authorization/permissions/AclPermission'; export * from './authorization/permissions/ModesExtractor'; export * from './authorization/permissions/MethodModesExtractor'; export * from './authorization/permissions/N3PatchModesExtractor'; +export * from './authorization/permissions/Permissions'; export * from './authorization/permissions/SparqlUpdateModesExtractor'; // Authorization diff --git a/test/unit/authorization/permissions/MethodModesExtractor.test.ts b/test/unit/authorization/permissions/MethodModesExtractor.test.ts index 3ed968709..3f2822489 100644 --- a/test/unit/authorization/permissions/MethodModesExtractor.test.ts +++ b/test/unit/authorization/permissions/MethodModesExtractor.test.ts @@ -1,13 +1,31 @@ import { MethodModesExtractor } from '../../../../src/authorization/permissions/MethodModesExtractor'; +import type { AccessMap } from '../../../../src/authorization/permissions/Permissions'; import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { ResourceSet } from '../../../../src/storage/ResourceSet'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap'; +import { compareMaps } from '../../../util/Util'; describe('A MethodModesExtractor', (): void => { + const target: ResourceIdentifier = { path: 'http://example.com/foo' }; + const operation: Operation = { + method: 'GET', + target, + preferences: {}, + body: new BasicRepresentation(), + }; let resourceSet: jest.Mocked; let extractor: MethodModesExtractor; + function getMap(modes: AccessMode[], identifier?: ResourceIdentifier): AccessMap { + return new IdentifierSetMultiMap( + modes.map((mode): [ResourceIdentifier, AccessMode] => [ identifier ?? target, mode ]), + ); + } + beforeEach(async(): Promise => { resourceSet = { hasResource: jest.fn().mockResolvedValue(true), @@ -16,44 +34,43 @@ describe('A MethodModesExtractor', (): void => { }); it('can handle HEAD/GET/POST/PUT/DELETE.', async(): Promise => { - await expect(extractor.canHandle({ method: 'HEAD' } as Operation)).resolves.toBeUndefined(); - await expect(extractor.canHandle({ method: 'GET' } as Operation)).resolves.toBeUndefined(); - await expect(extractor.canHandle({ method: 'POST' } as Operation)).resolves.toBeUndefined(); - await expect(extractor.canHandle({ method: 'PUT' } as Operation)).resolves.toBeUndefined(); - await expect(extractor.canHandle({ method: 'DELETE' } as Operation)).resolves.toBeUndefined(); - await expect(extractor.canHandle({ method: 'PATCH' } as Operation)).rejects.toThrow(NotImplementedHttpError); + await expect(extractor.canHandle({ ...operation, method: 'HEAD' })).resolves.toBeUndefined(); + await expect(extractor.canHandle({ ...operation, method: 'GET' })).resolves.toBeUndefined(); + await expect(extractor.canHandle({ ...operation, method: 'POST' })).resolves.toBeUndefined(); + await expect(extractor.canHandle({ ...operation, method: 'PUT' })).resolves.toBeUndefined(); + await expect(extractor.canHandle({ ...operation, method: 'DELETE' })).resolves.toBeUndefined(); + await expect(extractor.canHandle({ ...operation, method: 'PATCH' })).rejects.toThrow(NotImplementedHttpError); }); it('requires read for HEAD operations.', async(): Promise => { - await expect(extractor.handle({ method: 'HEAD' } as Operation)).resolves.toEqual(new Set([ AccessMode.read ])); + compareMaps(await extractor.handle({ ...operation, method: 'HEAD' }), getMap([ AccessMode.read ])); }); it('requires read for GET operations.', async(): Promise => { - await expect(extractor.handle({ method: 'GET' } as Operation)).resolves.toEqual(new Set([ AccessMode.read ])); + compareMaps(await extractor.handle({ ...operation, method: 'GET' }), getMap([ AccessMode.read ])); }); it('requires append for POST operations.', async(): Promise => { - await expect(extractor.handle({ method: 'POST' } as Operation)).resolves.toEqual(new Set([ AccessMode.append ])); + compareMaps(await extractor.handle({ ...operation, method: 'POST' }), getMap([ AccessMode.append ])); }); it('requires write for PUT operations.', async(): Promise => { - await expect(extractor.handle({ method: 'PUT' } as Operation)) - .resolves.toEqual(new Set([ AccessMode.write ])); + compareMaps(await extractor.handle({ ...operation, method: 'PUT' }), getMap([ AccessMode.write ])); }); it('requires create for PUT operations if the target does not exist.', async(): Promise => { resourceSet.hasResource.mockResolvedValueOnce(false); - await expect(extractor.handle({ method: 'PUT' } as Operation)) - .resolves.toEqual(new Set([ AccessMode.write, AccessMode.create ])); + compareMaps(await extractor.handle({ ...operation, method: 'PUT' }), + getMap([ AccessMode.write, AccessMode.create ])); }); it('requires delete for DELETE operations.', async(): Promise => { - await expect(extractor.handle({ method: 'DELETE', target: { path: 'http://example.com/foo' }} as Operation)) - .resolves.toEqual(new Set([ AccessMode.delete ])); + compareMaps(await extractor.handle({ ...operation, method: 'DELETE' }), getMap([ AccessMode.delete ])); }); it('also requires read for DELETE operations on containers.', async(): Promise => { - await expect(extractor.handle({ method: 'DELETE', target: { path: 'http://example.com/foo/' }} as Operation)) - .resolves.toEqual(new Set([ AccessMode.delete, AccessMode.read ])); + const identifier = { path: 'http://example.com/foo/' }; + compareMaps(await extractor.handle({ ...operation, method: 'DELETE', target: identifier }), + getMap([ AccessMode.delete, AccessMode.read ], identifier)); }); }); diff --git a/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts b/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts index e0204f6c2..87d5e21c8 100644 --- a/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts +++ b/test/unit/authorization/permissions/N3PatchModesExtractor.test.ts @@ -1,22 +1,33 @@ import { DataFactory } from 'n3'; import type { Quad } from 'rdf-js'; import { N3PatchModesExtractor } from '../../../../src/authorization/permissions/N3PatchModesExtractor'; +import type { AccessMap } from '../../../../src/authorization/permissions/Permissions'; import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; import type { Operation } from '../../../../src/http/Operation'; import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; import type { N3Patch } from '../../../../src/http/representation/N3Patch'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { ResourceSet } from '../../../../src/storage/ResourceSet'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap'; +import { compareMaps } from '../../../util/Util'; const { quad, namedNode } = DataFactory; describe('An N3PatchModesExtractor', (): void => { + const target: ResourceIdentifier = { path: 'http://example.com/foo' }; const triple: Quad = quad(namedNode('a'), namedNode('b'), namedNode('c')); let patch: N3Patch; let operation: Operation; let resourceSet: jest.Mocked; let extractor: N3PatchModesExtractor; + function getMap(modes: AccessMode[], identifier?: ResourceIdentifier): AccessMap { + return new IdentifierSetMultiMap( + modes.map((mode): [ResourceIdentifier, AccessMode] => [ identifier ?? target, mode ]), + ); + } + beforeEach(async(): Promise => { patch = new BasicRepresentation() as N3Patch; patch.deletes = []; @@ -27,7 +38,7 @@ describe('An N3PatchModesExtractor', (): void => { method: 'PATCH', body: patch, preferences: {}, - target: { path: 'http://example.com/foo' }, + target, }; resourceSet = { @@ -47,30 +58,29 @@ describe('An N3PatchModesExtractor', (): void => { it('requires read access when there are conditions.', async(): Promise => { patch.conditions = [ triple ]; - await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read ])); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.read ])); }); it('requires append access when there are inserts.', async(): Promise => { patch.inserts = [ triple ]; - await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.append ])); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.append ])); }); it('requires create access when there are inserts and the resource does not exist.', async(): Promise => { resourceSet.hasResource.mockResolvedValueOnce(false); patch.inserts = [ triple ]; - await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.append, AccessMode.create ])); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.append, AccessMode.create ])); }); it('requires read and write access when there are inserts.', async(): Promise => { patch.deletes = [ triple ]; - await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.read, AccessMode.write ])); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.read, AccessMode.write ])); }); it('combines required access modes when required.', async(): Promise => { patch.conditions = [ triple ]; patch.inserts = [ triple ]; patch.deletes = [ triple ]; - await expect(extractor.handle(operation)).resolves - .toEqual(new Set([ AccessMode.read, AccessMode.append, AccessMode.write ])); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.read, AccessMode.append, AccessMode.write ])); }); }); diff --git a/test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts b/test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts index a9695ac44..8d8c026d4 100644 --- a/test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts +++ b/test/unit/authorization/permissions/SparqlUpdateModesExtractor.test.ts @@ -1,17 +1,40 @@ import { Factory } from 'sparqlalgebrajs'; +import type { AccessMap } from '../../../../src/authorization/permissions/Permissions'; import { AccessMode } from '../../../../src/authorization/permissions/Permissions'; import { SparqlUpdateModesExtractor } from '../../../../src/authorization/permissions/SparqlUpdateModesExtractor'; import type { Operation } from '../../../../src/http/Operation'; +import { BasicRepresentation } from '../../../../src/http/representation/BasicRepresentation'; +import type { ResourceIdentifier } from '../../../../src/http/representation/ResourceIdentifier'; import type { SparqlUpdatePatch } from '../../../../src/http/representation/SparqlUpdatePatch'; import type { ResourceSet } from '../../../../src/storage/ResourceSet'; import { NotImplementedHttpError } from '../../../../src/util/errors/NotImplementedHttpError'; +import { IdentifierSetMultiMap } from '../../../../src/util/map/IdentifierMap'; +import { compareMaps } from '../../../util/Util'; describe('A SparqlUpdateModesExtractor', (): void => { + const target: ResourceIdentifier = { path: 'http://example.com/foo' }; + let patch: SparqlUpdatePatch; + let operation: Operation; let resourceSet: jest.Mocked; let extractor: SparqlUpdateModesExtractor; const factory = new Factory(); + function getMap(modes: AccessMode[], identifier?: ResourceIdentifier): AccessMap { + return new IdentifierSetMultiMap( + modes.map((mode): [ResourceIdentifier, AccessMode] => [ identifier ?? target, mode ]), + ); + } + beforeEach(async(): Promise => { + patch = new BasicRepresentation() as SparqlUpdatePatch; + + operation = { + method: 'PATCH', + body: patch, + preferences: {}, + target, + }; + resourceSet = { hasResource: jest.fn().mockResolvedValue(true), }; @@ -19,7 +42,7 @@ describe('A SparqlUpdateModesExtractor', (): void => { }); it('can only handle (composite) SPARQL DELETE/INSERT operations.', async(): Promise => { - const operation = { method: 'PATCH', body: { algebra: factory.createDeleteInsert() }} as unknown as Operation; + patch.algebra = factory.createDeleteInsert(); await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); (operation.body as SparqlUpdatePatch).algebra = factory.createCompositeUpdate([ factory.createDeleteInsert() ]); await expect(extractor.canHandle(operation)).resolves.toBeUndefined(); @@ -28,75 +51,59 @@ describe('A SparqlUpdateModesExtractor', (): void => { await expect(result).rejects.toThrow(NotImplementedHttpError); await expect(result).rejects.toThrow('Cannot determine permissions of non-SPARQL patches.'); - result = extractor.canHandle({ ...operation, - body: { algebra: factory.createMove('DEFAULT', 'DEFAULT') } as unknown as SparqlUpdatePatch }); + patch.algebra = factory.createMove('DEFAULT', 'DEFAULT'); + result = extractor.canHandle(operation); await expect(result).rejects.toThrow(NotImplementedHttpError); await expect(result).rejects.toThrow('Can only determine permissions of a PATCH with DELETE/INSERT operations.'); }); it('requires nothing for NOP operations.', async(): Promise => { - const operation = { - method: 'PATCH', - body: { algebra: factory.createNop() }, - } as unknown as Operation; - await expect(extractor.handle(operation)).resolves.toEqual(new Set()); + patch.algebra = factory.createNop(); + compareMaps(await extractor.handle(operation), getMap([])); }); it('requires append for INSERT operations.', async(): Promise => { - const operation = { - method: 'PATCH', - body: { algebra: factory.createDeleteInsert(undefined, [ - factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), - ]) }, - } as unknown as Operation; - await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.append ])); + patch.algebra = factory.createDeleteInsert(undefined, [ + factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), + ]); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.append ])); }); it('requires create for INSERT operations if the resource does not exist.', async(): Promise => { resourceSet.hasResource.mockResolvedValueOnce(false); - const operation = { - method: 'PATCH', - body: { algebra: factory.createDeleteInsert(undefined, [ - factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), - ]) }, - } as unknown as Operation; - await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.append, AccessMode.create ])); + patch.algebra = factory.createDeleteInsert(undefined, [ + factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), + ]); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.append, AccessMode.create ])); }); it('requires read and write for DELETE operations.', async(): Promise => { - const operation = { - method: 'PATCH', - body: { algebra: factory.createDeleteInsert([ - factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), - ]) }, - } as unknown as Operation; - await expect(extractor.handle(operation)) - .resolves.toEqual(new Set([ AccessMode.read, AccessMode.write ])); + patch.algebra = factory.createDeleteInsert([ + factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), + ]); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.read, AccessMode.write ])); }); it('requires read and append for composite operations with an insert and conditions.', async(): Promise => { - const operation = { - method: 'PATCH', - body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [ + patch.algebra = factory.createCompositeUpdate([ + factory.createDeleteInsert(undefined, [ factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), ], factory.createBgp([ factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), - ])) ]) }, - } as unknown as Operation; - await expect(extractor.handle(operation)).resolves.toEqual(new Set([ AccessMode.append, AccessMode.read ])); + ])), + ]); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.append, AccessMode.read ])); }); it('requires read, write and append for composite operations with a delete and insert.', async(): Promise => { - const operation = { - method: 'PATCH', - body: { algebra: factory.createCompositeUpdate([ factory.createDeleteInsert(undefined, [ + patch.algebra = factory.createCompositeUpdate([ + factory.createDeleteInsert(undefined, [ factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), ]), factory.createDeleteInsert([ factory.createPattern(factory.createTerm(''), factory.createTerm('

'), factory.createTerm('')), - ]) ]) }, - } as unknown as Operation; - await expect(extractor.handle(operation)) - .resolves.toEqual(new Set([ AccessMode.append, AccessMode.read, AccessMode.write ])); + ]), + ]); + compareMaps(await extractor.handle(operation), getMap([ AccessMode.append, AccessMode.read, AccessMode.write ])); }); }); diff --git a/test/util/Util.ts b/test/util/Util.ts index bbd8ef54f..92ed731da 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -59,6 +59,18 @@ export async function flushPromises(): Promise { return new Promise(jest.requireActual('timers').setImmediate); } +/** + * Compares the contents of the given two maps. + */ +export function compareMaps(map1: Map, map2: Map): void { + expect(new Set(map1.keys())).toEqual(new Set(map2.keys())); + // Looping like this also allows us to compare SetMultiMaps + for (const key of map1.keys()) { + // Adding key for better error output + expect({ key, value: map1.get(key) }).toEqual({ key, value: map2.get(key) }); + } +} + /** * Mocks (some) functions of the fs system library. * It is important that you call `jest.mock('fs');` in your test file before calling this!!!