From 728617ac77a3aa44d18553e51e6e68fd78d262cf Mon Sep 17 00:00:00 2001 From: Joachim Van Herwegen Date: Thu, 18 Aug 2022 11:35:38 +0200 Subject: [PATCH] test: Add ACP integration tests --- test/integration/AcpServer.test.ts | 121 +++++++++++++ test/integration/config/ldp-with-acp.json | 48 +++++ test/util/AcpHelper.ts | 202 ++++++++++++++++++++++ test/util/Util.ts | 1 + 4 files changed, 372 insertions(+) create mode 100644 test/integration/AcpServer.test.ts create mode 100644 test/integration/config/ldp-with-acp.json create mode 100644 test/util/AcpHelper.ts diff --git a/test/integration/AcpServer.test.ts b/test/integration/AcpServer.test.ts new file mode 100644 index 000000000..e7e26ef53 --- /dev/null +++ b/test/integration/AcpServer.test.ts @@ -0,0 +1,121 @@ +import fetch from 'cross-fetch'; +import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; +import type { App } from '../../src/init/App'; +import type { ResourceStore } from '../../src/storage/ResourceStore'; +import { joinUrl } from '../../src/util/PathUtil'; +import { AcpHelper } from '../util/AcpHelper'; +import { getPort } from '../util/Util'; +import { + getDefaultVariables, + getPresetConfigPath, + getTestConfigPath, + getTestFolder, + instantiateFromConfig, removeFolder, +} from './Config'; + +const port = getPort('AcpServer'); +const baseUrl = `http://localhost:${port}/`; + +const rootFilePath = getTestFolder('full-config-acp'); +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)('An LDP handler with ACP using %s', (name, { storeConfig, teardown }): void => { + let app: App; + let store: ResourceStore; + let acpHelper: AcpHelper; + + 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-acp.json'), + ], + variables, + ) as Record; + ({ app, store } = instances); + + await app.start(); + + // Create test helper for manipulating acl + acpHelper = new AcpHelper(store); + }); + + afterAll(async(): Promise => { + await teardown(); + await app.stop(); + }); + + it('provides no access if no ACRs are defined.', async(): Promise => { + const response = await fetch(baseUrl); + expect(response.status).toBe(401); + }); + + it('provides access if the correct ACRs are defined.', async(): Promise => { + await acpHelper.setAcp(baseUrl, acpHelper.createAcr({ resource: baseUrl, + policies: [ acpHelper.createPolicy({ + allow: [ 'read' ], + anyOf: [ acpHelper.createMatcher({ publicAgent: true }) ], + }) ]})); + const response = await fetch(baseUrl); + expect(response.status).toBe(200); + }); + + it('uses ACP inheritance.', async(): Promise => { + const target = joinUrl(baseUrl, 'foo'); + await store.setRepresentation({ path: target }, new BasicRepresentation('test', 'text/plain')); + await acpHelper.setAcp(baseUrl, acpHelper.createAcr({ resource: baseUrl, + memberPolicies: [ acpHelper.createPolicy({ + allow: [ 'read' ], + anyOf: [ acpHelper.createMatcher({ publicAgent: true }) ], + }) ]})); + await acpHelper.setAcp(target, acpHelper.createAcr({ resource: baseUrl, + policies: [ acpHelper.createPolicy({ + allow: [ 'write' ], + anyOf: [ acpHelper.createMatcher({ publicAgent: true }) ], + }) ]})); + const response = await fetch(target); + expect(response.status).toBe(200); + }); + + it('requires control permissions to access ACRs.', async(): Promise => { + const baseAcr = joinUrl(baseUrl, '.acr'); + const turtle = acpHelper.toTurtle(acpHelper.createAcr({ resource: baseUrl, + policies: [ acpHelper.createPolicy({ + allow: [ 'read' ], + anyOf: [ acpHelper.createMatcher({ publicAgent: true }) ], + }) ]})); + let response = await fetch(baseAcr); + expect(response.status).toBe(401); + response = await fetch(baseAcr, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: turtle }); + expect(response.status).toBe(401); + + await acpHelper.setAcp(baseUrl, acpHelper.createAcr({ resource: baseUrl, + policies: [ acpHelper.createPolicy({ + allow: [ 'control' ], + anyOf: [ acpHelper.createMatcher({ publicAgent: true }) ], + }) ]})); + response = await fetch(baseAcr); + expect(response.status).toBe(200); + response = await fetch(baseAcr, { method: 'PUT', headers: { 'content-type': 'text/turtle' }, body: turtle }); + expect(response.status).toBe(205); + // Can now also read root container due to updated permissions + response = await fetch(baseUrl); + expect(response.status).toBe(200); + }); +}); diff --git a/test/integration/config/ldp-with-acp.json b/test/integration/config/ldp-with-acp.json new file mode 100644 index 000000000..880e83925 --- /dev/null +++ b/test/integration/config/ldp-with-acp.json @@ -0,0 +1,48 @@ +{ + "@context": "https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld", + "import": [ + "css:config/app/main/default.json", + "css:config/app/init/default.json", + "css:config/app/setup/disabled.json", + "css:config/http/handler/simple.json", + "css:config/http/middleware/no-websockets.json", + "css:config/http/server-factory/no-websockets.json", + "css:config/http/static/default.json", + "css:config/identity/access/public.json", + "css:config/identity/handler/default.json", + "css:config/identity/ownership/token.json", + "css:config/identity/pod/static.json", + "css:config/ldp/authentication/debug-auth-header.json", + "css:config/ldp/authorization/acp.json", + "css:config/ldp/handler/default.json", + "css:config/ldp/metadata-parser/default.json", + "css:config/ldp/metadata-writer/default.json", + "css:config/ldp/modes/default.json", + "css:config/storage/key-value/memory.json", + "css:config/storage/middleware/default.json", + "css:config/util/auxiliary/acr.json", + "css:config/util/identifiers/suffix.json", + "css:config/util/index/default.json", + "css:config/util/logging/winston.json", + "css:config/util/representation-conversion/default.json", + "css:config/util/resource-locker/memory.json", + "css:config/util/variables/default.json" + ], + "@graph": [ + { + "comment": "An HTTP server with only the LDP handler as HttpHandler and an unsecure authenticator.", + "@id": "urn:solid-server:test:Instances", + "@type": "RecordObject", + "record": [ + { + "RecordObject:_record_key": "app", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:App" } + }, + { + "RecordObject:_record_key": "store", + "RecordObject:_record_value": { "@id": "urn:solid-server:default:ResourceStore" } + } + ] + } + ] +} diff --git a/test/util/AcpHelper.ts b/test/util/AcpHelper.ts new file mode 100644 index 000000000..ba73297e7 --- /dev/null +++ b/test/util/AcpHelper.ts @@ -0,0 +1,202 @@ +import type { IAccessControl } from '@solid/access-control-policy/dist/type/i_access_control'; +import type { IAccessControlledResource } from '@solid/access-control-policy/dist/type/i_access_controlled_resource'; +import type { IMatcher } from '@solid/access-control-policy/dist/type/i_matcher'; +import type { IPolicy } from '@solid/access-control-policy/dist/type/i_policy'; +import { v4 } from 'uuid'; +import { BasicRepresentation } from '../../src/http/representation/BasicRepresentation'; +import type { ResourceIdentifier } from '../../src/http/representation/ResourceIdentifier'; +import type { ResourceStore } from '../../src/storage/ResourceStore'; +import { joinUrl } from '../../src/util/PathUtil'; + +export type CreateMatcherInput = { publicAgent: true } | { agent: string }; + +export type CreatePolicyInput = { + allow?: Iterable<'read' | 'append' | 'write' | 'control'>; + deny?: Iterable<'read' | 'append' | 'write' | 'control'>; + allOf?: Iterable; + anyOf?: Iterable; + noneOf?: Iterable; +}; + +export type CreateAcrInput = { + resource: string | ResourceIdentifier; + policies?: Iterable; + memberPolicies?: Iterable; +}; + +const baseUrl = 'http://acp.example.com/'; + +/** + * Helper class for setting permissions through ACP. + */ +export class AcpHelper { + public readonly store: ResourceStore; + + public constructor(store: ResourceStore) { + this.store = store; + } + + public createMatcher(input: CreateMatcherInput): IMatcher { + return { + iri: joinUrl(baseUrl, v4()), + // Prefixed URI as this will be inserted into turtle below + agent: (input as any).publicAgent ? [ 'acp:PublicAgent' ] : [ (input as any).agent ], + client: [], + issuer: [], + vc: [], + }; + } + + public createPolicy({ allow, deny, allOf, anyOf, noneOf }: CreatePolicyInput): IPolicy { + return { + iri: joinUrl(baseUrl, v4()), + // Using the wrong identifiers so the turtle generated below uses the prefixed version + allow: new Set(this.convertModes(allow ?? []) as any), + deny: new Set(this.convertModes(deny ?? []) as any), + allOf: [ ...allOf ?? [] ], + anyOf: [ ...anyOf ?? [] ], + noneOf: [ ...noneOf ?? [] ], + }; + } + + private* convertModes(modes: Iterable): + Iterable<`acl:${Capitalize}`> { + for (const mode of modes) { + // Node.js typings aren't fancy enough yet to correctly type this + yield `acl:${mode.charAt(0).toUpperCase() + mode.slice(1)}` as any; + } + } + + public createAcr({ resource, policies, memberPolicies }: CreateAcrInput): IAccessControlledResource { + return { + iri: (resource as ResourceIdentifier).path ?? resource, + accessControlResource: { + iri: joinUrl(baseUrl, v4()), + accessControl: policies ? + [{ + iri: joinUrl(baseUrl, v4()), + policy: [ ...policies ], + }] : + [], + memberAccessControl: memberPolicies ? + [{ + iri: joinUrl(baseUrl, v4()), + policy: [ ...memberPolicies ], + }] : + [], + }, + }; + } + + public async setAcp(id: string | ResourceIdentifier, + resources: IAccessControlledResource[] | IAccessControlledResource): Promise { + const turtle = this.toTurtle(resources); + await this.store.setRepresentation({ path: `${(id as ResourceIdentifier).path ?? id}.acr` }, + new BasicRepresentation(turtle, 'text/turtle')); + } + + public toTurtle(resources: IAccessControlledResource[] | IAccessControlledResource): string { + if (!Array.isArray(resources)) { + resources = [ resources ]; + } + const result: string[] = [ + '@prefix acp: .', + '@prefix acl: .', + ]; + + const added = new Set(); + const acs: IAccessControl[] = []; + const policies: IPolicy[] = []; + const matchers: IMatcher[] = []; + + for (const resource of resources) { + result.push(`<${resource.accessControlResource.iri}> a acp:AccessControlResource`); + result.push(` ; acp:resource <${resource.iri}>`); + for (const key of [ 'accessControl', 'memberAccessControl' ] as const) { + if (resource.accessControlResource[key].length > 0) { + result.push(` ; acp:${key} ${resource.accessControlResource[key].map((ac): string => { + acs.push(ac); + return `<${ac.iri}>`; + }).join(', ')}`); + } + } + result.push(' .'); + } + + for (const ac of acs) { + if (added.has(ac.iri)) { + continue; + } + + result.push(`<${ac.iri}> a acp:AccessControl`); + result.push(` ; acp:apply ${ac.policy.map((policy): string => { + policies.push(policy); + return `<${policy.iri}>`; + }).join(', ')}`); + result.push(' .'); + added.add(ac.iri); + } + + for (const policy of policies) { + if (added.has(policy.iri)) { + continue; + } + + const { policyString, requiredMatchers } = this.policyToTurtle(policy); + result.push(policyString); + matchers.push(...requiredMatchers); + added.add(policy.iri); + } + + for (const matcher of matchers) { + if (added.has(matcher.iri)) { + continue; + } + + result.push(this.matcherToTurtle(matcher)); + added.add(matcher.iri); + } + + return result.join('\n'); + } + + private policyToTurtle(policy: IPolicy): { policyString: string; requiredMatchers: IMatcher[] } { + const result: string[] = []; + + result.push(`<${policy.iri}> a acp:Policy`); + + for (const key of [ 'allow', 'deny' ] as const) { + if (policy[key].size > 0) { + result.push(` ; acp:${key} ${[ ...policy[key] ].join(', ')}`); + } + } + + const requiredMatchers: IMatcher[] = []; + for (const key of [ 'allOf', 'anyOf', 'noneOf' ] as const) { + if (policy[key].length > 0) { + result.push(` ; acp:${key} ${policy[key].map((matcher): string => { + requiredMatchers.push(matcher); + return `<${matcher.iri}>`; + }).join(', ')}`); + } + } + + result.push(' .'); + + return { policyString: result.join('\n'), requiredMatchers }; + } + + private matcherToTurtle(matcher: IMatcher): string { + const result: string[] = []; + + result.push(`<${matcher.iri}> a acp:Matcher`); + for (const key of [ 'agent', 'client', 'issuer', 'vc' ] as const) { + if (matcher[key].length > 0) { + result.push(` ; acp:${key} ${matcher[key].join(', ')}`); + } + } + result.push(' .'); + + return result.join('\n'); + } +} diff --git a/test/util/Util.ts b/test/util/Util.ts index 92ed731da..3ced94099 100644 --- a/test/util/Util.ts +++ b/test/util/Util.ts @@ -5,6 +5,7 @@ import Describe = jest.Describe; const portNames = [ // Integration + 'AcpServer', 'Conditions', 'ContentNegotiation', 'DynamicPods',