From 6f83ac5ead5bb98fae9c09d1996b4b5c2ce2fa51 Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Tue, 1 Mar 2022 14:27:53 +0100 Subject: [PATCH] test: Create permission table to automate tests --- test/integration/PermissionTable.test.ts | 218 +++++++++++++++++++++++ test/util/AclHelper.ts | 79 ++++---- test/util/Util.ts | 6 +- 3 files changed, 265 insertions(+), 38 deletions(-) create mode 100644 test/integration/PermissionTable.test.ts diff --git a/test/integration/PermissionTable.test.ts b/test/integration/PermissionTable.test.ts new file mode 100644 index 000000000..078516db7 --- /dev/null +++ b/test/integration/PermissionTable.test.ts @@ -0,0 +1,218 @@ +import fetch from 'cross-fetch'; +import { v4 } from 'uuid'; +import type { AclPermission } from '../../src/authorization/permissions/AclPermission'; +import { AccessMode as AM } from '../../src/authorization/permissions/Permissions'; +import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; +import type { App } from '../../src/init/App'; +import type { ResourceStore } from '../../src/storage/ResourceStore'; +import { TEXT_TURTLE } from '../../src/util/ContentTypes'; +import { ensureTrailingSlash, joinUrl } from '../../src/util/PathUtil'; +import { AclHelper } from '../util/AclHelper'; +import { getPort } from '../util/Util'; +import { + getDefaultVariables, + getPresetConfigPath, + getTestConfigPath, + getTestFolder, + instantiateFromConfig, removeFolder, +} from './Config'; + +const DEFAULT_BODY = `@prefix solid: . +@prefix ex: . + +ex:custom ex:givenName "Claudia".`; + +const INSERT = `@prefix solid: . +@prefix ex: . +_:patch a solid:InsertDeletePatch; + solid:inserts { ex:custom ex:givenName "Alex". }.`; + +const DELETE = `@prefix solid: . +@prefix ex: . + +_:rename a solid:InsertDeletePatch; + solid:deletes { ex:custom ex:givenName "Claudia". }.`; + +const N3 = 'text/n3'; +const TXT = 'text/plain'; + +const allModes = [ AM.read, AM.append, AM.create, AM.write, AM.delete ]; + +// Based on https://github.com/solid/specification/issues/14#issuecomment-683480525 +// Columns: method, target, C/ permissions, C/R permissions, body, content-type, target exists, target does not exist +// `undefined` implies C/R inherits the permissions of C/ +// For PUT/PATCH/DELETE we return 205 instead of 200/204 +/* eslint-disable no-multi-spaces */ +const table: [string, string, AM[], AM[] | undefined, string, string, number, number][] = [ + // We currently handle OPTIONS before authorization + // [ 'OPTIONS', 'C/R', [], undefined, '', '', 401, 401 ], + // [ 'OPTIONS', 'C/R', [], [ AM.read ], '', '', 200, 404 ], + // [ 'OPTIONS', 'C/R', [ AM.read ], undefined, '', '', 200, 404 ], + + [ 'HEAD', 'C/R', [], undefined, '', '', 401, 401 ], + [ 'HEAD', 'C/R', [], [ AM.read ], '', '', 200, 404 ], + [ 'HEAD', 'C/R', [ AM.read ], undefined, '', '', 200, 404 ], + + [ 'GET', 'C/R', [], undefined, '', '', 401, 401 ], + [ 'GET', 'C/R', [], [ AM.read ], '', '', 200, 404 ], + [ 'GET', 'C/R', [ AM.read ], undefined, '', '', 200, 404 ], + // Agreed upon deviation from the original table; more conservative interpretation allowed. + // Original returns 404 in the case of C/R not existing. + [ 'GET', 'C/R', [ AM.read ], [ AM.write ], '', '', 401, 401 ], + + [ 'POST', 'C/', [], undefined, '', TXT, 401, 401 ], + [ 'POST', 'C/', [], [ AM.read ], '', TXT, 401, 401 ], + [ 'POST', 'C/', [ AM.append ], undefined, '', TXT, 201, 201 ], + [ 'POST', 'C/', [ AM.append ], [ AM.read ], '', TXT, 201, 201 ], + [ 'POST', 'C/', [ AM.read, AM.append ], undefined, '', TXT, 201, 201 ], + [ 'POST', 'C/', [ AM.read, AM.append ], [ AM.read ], '', TXT, 201, 201 ], + + [ 'PUT', 'C/', [], undefined, '', N3, 401, 401 ], + [ 'PUT', 'C/', [ AM.read ], undefined, '', N3, 401, 401 ], + [ 'PUT', 'C/', [ AM.write ], undefined, '', N3, 205, 201 ], + + [ 'PUT', 'C/R', [], undefined, '', TXT, 401, 401 ], + [ 'PUT', 'C/R', [], [ AM.read ], '', TXT, 401, 401 ], + [ 'PUT', 'C/R', [], [ AM.append ], '', TXT, 401, 401 ], + [ 'PUT', 'C/R', [], [ AM.write ], '', TXT, 205, 401 ], + [ 'PUT', 'C/R', [ AM.read ], undefined, '', TXT, 401, 401 ], + [ 'PUT', 'C/R', [ AM.append ], undefined, '', TXT, 401, 401 ], + [ 'PUT', 'C/R', [ AM.write ], undefined, '', TXT, 205, 201 ], + [ 'PUT', 'C/R', [ AM.append ], [ AM.write ], '', TXT, 205, 201 ], + + [ 'PATCH', 'C/R', [], undefined, DELETE, N3, 401, 401 ], + [ 'PATCH', 'C/R', [], [ AM.read ], DELETE, N3, 401, 404 ], + [ 'PATCH', 'C/R', [], [ AM.append ], INSERT, N3, 205, 401 ], + [ 'PATCH', 'C/R', [], [ AM.append ], DELETE, N3, 401, 401 ], + [ 'PATCH', 'C/R', [], [ AM.write ], INSERT, N3, 205, 401 ], + [ 'PATCH', 'C/R', [], [ AM.write ], DELETE, N3, 401, 401 ], + [ 'PATCH', 'C/R', [ AM.append ], [ AM.write ], INSERT, N3, 205, 201 ], + [ 'PATCH', 'C/R', [ AM.append ], [ AM.write ], DELETE, N3, 401, 401 ], + // We currently return 409 instead of 404 in case a PATCH has no inserts and C/R does not exist. + // This is an agreed upon deviation from the original table + [ 'PATCH', 'C/R', [], [ AM.read, AM.write ], DELETE, N3, 205, 409 ], + + [ 'DELETE', 'C/R', [], undefined, '', '', 401, 401 ], + [ 'DELETE', 'C/R', [], [ AM.read ], '', '', 401, 404 ], + [ 'DELETE', 'C/R', [], [ AM.append ], '', '', 401, 401 ], + [ 'DELETE', 'C/R', [], [ AM.write ], '', '', 401, 401 ], + [ 'DELETE', 'C/R', [ AM.read ], undefined, '', '', 401, 404 ], + [ 'DELETE', 'C/R', [ AM.append ], undefined, '', '', 401, 401 ], + [ 'DELETE', 'C/R', [ AM.append ], [ AM.read ], '', '', 401, 404 ], + // We throw a 404 instead of 401 since we don't yet check if the parent container has read permissions + // [ 'DELETE', 'C/R', [ AM.write ], undefined, '', '', 205, 401 ], + [ 'DELETE', 'C/R', [ AM.write ], [ AM.read ], '', '', 401, 404 ], + [ 'DELETE', 'C/R', [ AM.write ], [ AM.append ], '', '', 401, 401 ], + + [ 'DELETE', 'C/', [], undefined, '', '', 401, 401 ], + [ 'DELETE', 'C/', [ AM.read ], undefined, '', '', 401, 404 ], + [ 'DELETE', 'C/', [ AM.append ], undefined, '', '', 401, 401 ], + [ 'DELETE', 'C/', [ AM.write ], undefined, '', '', 401, 401 ], + [ 'DELETE', 'C/', [ AM.read, AM.write ], undefined, '', '', 205, 404 ], +]; +/* eslint-enable no-multi-spaces */ + +function toPermission(modes: AM[]): AclPermission { + return Object.fromEntries(modes.map((mode): [AM, boolean] => [ mode, true ])); +} + +const port = getPort('PermissionTable'); +const baseUrl = `http://localhost:${port}/`; + +const rootFilePath = getTestFolder('permissionTable'); +const stores: [string, any][] = [ + [ 'in-memory storage', { + storeConfig: 'storage/backend/memory.json', + teardown: jest.fn(), + }], + [ 'on-disk storage', { + storeConfig: 'storage/backend/file.json', + teardown: async(): Promise => removeFolder(rootFilePath), + }], +]; + +describe.each(stores)('A request on a server with %s', (name, { storeConfig, teardown }): void => { + let app: App; + let store: ResourceStore; + let aclHelper: AclHelper; + + beforeAll(async(): Promise => { + const variables = { + ...getDefaultVariables(port, baseUrl), + 'urn:solid-server:default:variable:rootFilePath': rootFilePath, + }; + + // Create and start the server + const instances = await instantiateFromConfig( + 'urn:solid-server:test:Instances', + [ + getPresetConfigPath(storeConfig), + getTestConfigPath('ldp-with-auth.json'), + ], + variables, + ) as Record; + ({ app, store } = instances); + + await app.start(); + + // Create test helper for manipulating acl + aclHelper = new AclHelper(store); + + // Set the root acl file to allow everything + await aclHelper.setSimpleAcl(baseUrl, { + permissions: { read: true, write: true, append: true, control: true }, + agentClass: 'agent', + accessTo: true, + default: true, + }); + }); + + afterAll(async(): Promise => { + await teardown(); + await app.stop(); + }); + + describe.each(table)('%s %s with permissions C/: %s and C/R: %s.', (...entry): void => { + const [ method, target, cPerm, crPermTemp, body, contentType, existsCode, notExistsCode ] = entry; + const crPerm = crPermTemp ?? cPerm; + const id = v4(); + const root = ensureTrailingSlash(joinUrl(baseUrl, id)); + const container = ensureTrailingSlash(joinUrl(root, 'container/')); + const resource = joinUrl(container, 'resource'); + const targetingContainer = target !== 'C/R'; + const targetUrl = targetingContainer ? container : resource; + let init: RequestInit; + + beforeEach(async(): Promise => { + // POST is special as the request targets the container but we care about the generated resource + const parent = targetingContainer && method !== 'POST' ? root : container; + + // Create C/ and set up permissions + await store.setRepresentation({ path: parent }, new BasicRepresentation([], TEXT_TURTLE)); + + await aclHelper.setSimpleAcl(parent, [ + // In case we are targeting C/ we assume everything is allowed by the parent + { permissions: toPermission(parent === root ? allModes : cPerm), agentClass: 'agent', accessTo: true }, + { permissions: toPermission(parent === root ? cPerm : crPerm), agentClass: 'agent', default: true }, + ]); + + // Set up fetch parameters + init = { method }; + if (contentType && contentType.length > 0) { + init.body = body; + init.headers = { 'content-type': contentType }; + } + }); + + it('target does not exist.', async(): Promise => { + const response = await fetch(targetUrl, init); + expect(response.status).toBe(notExistsCode); + }); + + it('target exists.', async(): Promise => { + await store.setRepresentation({ path: targetUrl }, new BasicRepresentation(DEFAULT_BODY, TEXT_TURTLE)); + const response = await fetch(targetUrl, init); + expect(response.status).toBe(existsCode); + }); + }); +}); diff --git a/test/util/AclHelper.ts b/test/util/AclHelper.ts index 28771f552..d1ad63a6c 100644 --- a/test/util/AclHelper.ts +++ b/test/util/AclHelper.ts @@ -2,6 +2,14 @@ import type { ResourceStore } from '../../src/'; import { BasicRepresentation } from '../../src/'; import type { AclPermission } from '../../src/authorization/permissions/AclPermission'; +export type AclHelperInput = { + permissions: AclPermission; + agentClass?: 'agent' | 'authenticated'; + agent?: string; + accessTo?: boolean; + default?: boolean; +}; + export class AclHelper { public readonly store: ResourceStore; @@ -11,50 +19,49 @@ export class AclHelper { public async setSimpleAcl( resource: string, - options: { - permissions: AclPermission; - agentClass?: 'agent' | 'authenticated'; - agent?: string; - accessTo?: boolean; - default?: boolean; - }, + options: AclHelperInput | AclHelperInput[], ): Promise { - if (!options.agentClass && !options.agent) { - throw new Error('At least one of agentClass or agent have to be provided.'); - } - if (!options.accessTo && !options.default) { - throw new Error('At least one of accessTo or default have to be true.'); - } + options = Array.isArray(options) ? options : [ options ]; const acl: string[] = [ '@prefix acl: .\n', '@prefix foaf: .\n', - ' a acl:Authorization', ]; - for (const perm of [ 'Read', 'Append', 'Write', 'Control' ]) { - if (options.permissions[perm.toLowerCase() as keyof AclPermission]) { - acl.push(`;\n acl:mode acl:${perm}`); - } - } - if (options.accessTo) { - acl.push(`;\n acl:accessTo <${resource}>`); - } - if (options.default) { - acl.push(`;\n acl:default <${resource}>`); - } - if (options.agentClass) { - acl.push( - `;\n acl:agentClass ${ - options.agentClass === 'agent' ? 'foaf:Agent' : 'foaf:AuthenticatedAgent' - }`, - ); - } - if (options.agent) { - acl.push(`;\n acl:agent ${options.agent}`); - } + for (const [ i, option ] of options.entries()) { + acl.push(`\n a acl:Authorization`); - acl.push('.'); + if (!option.agentClass && !option.agent) { + throw new Error('At least one of agentClass or agent have to be provided.'); + } + if (!option.accessTo && !option.default) { + throw new Error('At least one of accessTo or default have to be true.'); + } + + for (const perm of [ 'Read', 'Append', 'Write', 'Control' ]) { + if (option.permissions[perm.toLowerCase() as keyof AclPermission]) { + acl.push(`;\n acl:mode acl:${perm}`); + } + } + if (option.accessTo) { + acl.push(`;\n acl:accessTo <${resource}>`); + } + if (option.default) { + acl.push(`;\n acl:default <${resource}>`); + } + if (option.agentClass) { + acl.push( + `;\n acl:agentClass ${ + option.agentClass === 'agent' ? 'foaf:Agent' : 'foaf:AuthenticatedAgent' + }`, + ); + } + if (option.agent) { + acl.push(`;\n acl:agent ${option.agent}`); + } + + acl.push('.'); + } await this.store.setRepresentation({ path: `${resource}.acl` }, new BasicRepresentation(acl, 'text/turtle')); } diff --git a/test/util/Util.ts b/test/util/Util.ts index 4003d741f..2a32a3480 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -7,12 +7,15 @@ const portNames = [ 'Conditions', 'ContentNegotiation', 'DynamicPods', + 'GlobalQuota', 'Identity', 'LpdHandlerWithAuth', 'LpdHandlerWithoutAuth', 'Middleware', 'N3Patch', + 'PermissionTable', 'PodCreation', + 'PodQuota', 'RedisResourceLocker', 'RestrictedIdentity', 'ServerFetch', @@ -20,8 +23,7 @@ const portNames = [ 'SparqlStorage', 'Subdomains', 'WebSocketsProtocol', - 'PodQuota', - 'GlobalQuota', + // Unit 'BaseHttpServerFactory', ] as const;