mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
test: Add ACP integration tests
This commit is contained in:
parent
db680740b5
commit
728617ac77
121
test/integration/AcpServer.test.ts
Normal file
121
test/integration/AcpServer.test.ts
Normal file
@ -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<void> => 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<void> => {
|
||||||
|
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<string, any>;
|
||||||
|
({ app, store } = instances);
|
||||||
|
|
||||||
|
await app.start();
|
||||||
|
|
||||||
|
// Create test helper for manipulating acl
|
||||||
|
acpHelper = new AcpHelper(store);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async(): Promise<void> => {
|
||||||
|
await teardown();
|
||||||
|
await app.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides no access if no ACRs are defined.', async(): Promise<void> => {
|
||||||
|
const response = await fetch(baseUrl);
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provides access if the correct ACRs are defined.', async(): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
48
test/integration/config/ldp-with-acp.json
Normal file
48
test/integration/config/ldp-with-acp.json
Normal file
@ -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" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
202
test/util/AcpHelper.ts
Normal file
202
test/util/AcpHelper.ts
Normal file
@ -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<IMatcher>;
|
||||||
|
anyOf?: Iterable<IMatcher>;
|
||||||
|
noneOf?: Iterable<IMatcher>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateAcrInput = {
|
||||||
|
resource: string | ResourceIdentifier;
|
||||||
|
policies?: Iterable<IPolicy>;
|
||||||
|
memberPolicies?: Iterable<IPolicy>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<T extends string>(modes: Iterable<T>):
|
||||||
|
Iterable<`acl:${Capitalize<T>}`> {
|
||||||
|
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<void> {
|
||||||
|
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: <http://www.w3.org/ns/solid/acp#>.',
|
||||||
|
'@prefix acl: <http://www.w3.org/ns/auth/acl#>.',
|
||||||
|
];
|
||||||
|
|
||||||
|
const added = new Set<string>();
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,7 @@ import Describe = jest.Describe;
|
|||||||
|
|
||||||
const portNames = [
|
const portNames = [
|
||||||
// Integration
|
// Integration
|
||||||
|
'AcpServer',
|
||||||
'Conditions',
|
'Conditions',
|
||||||
'ContentNegotiation',
|
'ContentNegotiation',
|
||||||
'DynamicPods',
|
'DynamicPods',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user