Skip to content

Commit ee9caf9

Browse files
committed
test: Add UCP unit tests
1 parent 5b1f3be commit ee9caf9

File tree

5 files changed

+437
-48
lines changed

5 files changed

+437
-48
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { DataFactory as DF, Writer } from 'n3';
2+
import { basicPolicy } from '../../../src/policy/ODRL';
3+
import { UCPPolicy } from '../../../src/policy/UsageControlPolicy';
4+
import { ODRL, RDF, XSD } from '../../../src/util/Vocabularies';
5+
6+
const now = new Date();
7+
vi.useFakeTimers({ now });
8+
9+
describe('ODRL', (): void => {
10+
let policy: UCPPolicy;
11+
12+
beforeEach(async(): Promise<void> => {
13+
policy = {
14+
type: 'http://www.w3.org/ns/odrl/2/Offer',
15+
rules: [{
16+
type: 'http://www.w3.org/ns/odrl/2/Prohibition',
17+
action: 'http://www.w3.org/ns/odrl/2/use',
18+
resource: 'http://example.com/foo',
19+
requestingParty: 'http://example.com/me',
20+
owner: 'http://example.com/owner',
21+
constraints: [{
22+
type: 'temporal',
23+
value: new Date(),
24+
operator: 'http://www.w3.org/ns/odrl/2/gt',
25+
}],
26+
}],
27+
};
28+
});
29+
30+
describe('#basicPolicy', (): void => {
31+
it('creates an RDF representation of a policy.', async(): Promise<void> => {
32+
const iri = 'http://example.com/policy';
33+
const result = basicPolicy(policy, iri);
34+
expect(result.policyIRI).toBe(iri);
35+
expect(result.ruleIRIs).toHaveLength(1);
36+
37+
const store = result.representation;
38+
const policyTerm = DF.namedNode(iri);
39+
const ruleTerm = result.ruleIRIs[0];
40+
const odrlTerm = (value: string) => DF.namedNode(ODRL.namespace + value);
41+
expect(store.countQuads(policyTerm, RDF.terms.type, ODRL.terms.Offer, null)).toBe(1);
42+
expect(store.countQuads(ruleTerm, RDF.terms.type, odrlTerm('Prohibition'), null)).toBe(1);
43+
expect(store.countQuads(ruleTerm, ODRL.terms.action, odrlTerm('use'), null)).toBe(1);
44+
expect(store.countQuads(ruleTerm, ODRL.terms.target, DF.namedNode('http://example.com/foo'), null)).toBe(1);
45+
expect(store.countQuads(ruleTerm, ODRL.terms.assignee, DF.namedNode('http://example.com/me'), null)).toBe(1);
46+
expect(store.countQuads(ruleTerm, ODRL.terms.assigner, DF.namedNode('http://example.com/owner'), null)).toBe(1);
47+
48+
const constraints = result.representation.getObjects(ruleTerm, ODRL.terms.constraint, null);
49+
expect(constraints).toHaveLength(1);
50+
const constraint = constraints[0];
51+
expect(store.countQuads(constraint, ODRL.terms.leftOperand, ODRL.terms.dateTime, null)).toBe(1);
52+
expect(store.countQuads(constraint, ODRL.terms.operator, ODRL.terms.gt, null)).toBe(1);
53+
expect(
54+
store.countQuads(constraint, ODRL.terms.rightOperand, DF.literal(now.toISOString(), XSD.terms.dateTime), null),
55+
).toBe(1);
56+
});
57+
});
58+
});
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import 'jest-rdf';
2+
import { DataFactory as DF, NamedNode, Parser, Store } from 'n3';
3+
import { Mock, vi } from 'vitest';
4+
import { ContainerUCRulesStorage } from '../../../src/storage/ContainerUCRulesStorage';
5+
import { RDF } from '../../../src/util/Vocabularies';
6+
7+
describe('ContainerUCRulesStorage', (): void => {
8+
const containerTtl = `
9+
<> <http://www.w3.org/ns/ldp#contains> <http://example.com/policies/foo1>, <http://example.com/policies/foo2> .
10+
`;
11+
const foo1Ttl = `<> a <http://example.com/type1> .`;
12+
const foo2Ttl = `<> a <http://example.com/type2> .`;
13+
const containerUrl = 'http://example.com/policies/';
14+
const foo1 = DF.namedNode('http://example.com/policies/foo1');
15+
const foo2 = DF.namedNode('http://example.com/policies/foo2');
16+
const type1 = DF.namedNode('http://example.com/type1');
17+
const type2 = DF.namedNode('http://example.com/type2');
18+
let response: Response;
19+
let fetchMock: Mock<typeof fetch>;
20+
let storage: ContainerUCRulesStorage;
21+
22+
beforeEach(async(): Promise<void> => {
23+
response = {
24+
status: 200,
25+
headers: new Headers({ 'content-type': 'text/turtle' }),
26+
text: vi.fn(),
27+
} satisfies Partial<Response> as any;
28+
fetchMock = vi.fn().mockResolvedValue(response);
29+
30+
storage = new ContainerUCRulesStorage(containerUrl, fetchMock);
31+
});
32+
33+
describe('.getStore', (): void => {
34+
it('can return a store containing all policies.', async(): Promise<void> => {
35+
vi.mocked(response.text).mockResolvedValueOnce(containerTtl);
36+
vi.mocked(response.text).mockResolvedValueOnce(foo1Ttl);
37+
vi.mocked(response.text).mockResolvedValueOnce(foo2Ttl);
38+
const store = await storage.getStore();
39+
expect(store.size).toBe(2);
40+
expect(store.countQuads(foo1, RDF.terms.type, type1, null)).toBe(1);
41+
expect(store.countQuads(foo2, RDF.terms.type, type2, null)).toBe(1);
42+
expect(fetchMock).toHaveBeenCalledTimes(3);
43+
expect(fetchMock).toHaveBeenNthCalledWith(1, containerUrl, { headers: { 'accept': 'text/turtle' }});
44+
expect(fetchMock).toHaveBeenNthCalledWith(2, foo1.value, { headers: { 'accept': 'text/turtle' }});
45+
expect(fetchMock).toHaveBeenNthCalledWith(3, foo2.value, { headers: { 'accept': 'text/turtle' }});
46+
});
47+
48+
it('returns no data if a resource could not be found.', async(): Promise<void> => {
49+
(response as any).status = 404;
50+
const result = await storage.getStore();
51+
expect(result.size).toBe(0);
52+
expect(fetchMock).toHaveBeenCalledTimes(1);
53+
expect(fetchMock).toHaveBeenNthCalledWith(1, containerUrl, { headers: { 'accept': 'text/turtle' }});
54+
});
55+
56+
it('errors if an unexpected status code is returned.', async(): Promise<void> => {
57+
(response as any).status = 500;
58+
vi.mocked(response.text).mockResolvedValueOnce('bad data');
59+
await expect(storage.getStore()).rejects.toThrow(`Unable to access policy resource ${containerUrl
60+
}: 500 - bad data`);
61+
});
62+
63+
it('errors if the response is not turtle.', async(): Promise<void> => {
64+
response.headers.set('content-type', 'application/ld+json');
65+
await expect(storage.getStore()).rejects
66+
.toThrow('Only turtle serialization is supported, received application/ld+json');
67+
});
68+
});
69+
70+
describe('.addRule', (): void => {
71+
it('adds the rule through a PATCH request.', async(): Promise<void> => {
72+
const rule = new Store([
73+
DF.quad(foo1, RDF.terms.type, type1),
74+
DF.quad(foo2, RDF.terms.type, type2),
75+
]);
76+
await expect(storage.addRule(rule)).resolves.toBeUndefined();
77+
expect(fetchMock.mock.calls[0][0]).toEqual(expect.stringContaining(containerUrl));
78+
expect(fetchMock.mock.calls[0][1]?.method).toBe('PATCH');
79+
expect(fetchMock.mock.calls[0][1]?.headers).toEqual({ 'content-type': 'text/n3' });
80+
const patch = new Store(new Parser({ format: 'n3' }).parse(fetchMock.mock.calls[0][1]!.body as string));
81+
const subjects = patch.getSubjects(RDF.terms.type, 'http://www.w3.org/ns/solid/terms#InsertDeletePatch', null);
82+
expect(subjects).toHaveLength(1);
83+
const insertFormulas = patch.getObjects(subjects[0], 'http://www.w3.org/ns/solid/terms#inserts', null);
84+
expect(insertFormulas).toHaveLength(1);
85+
const inserts = patch.getQuads(null, null, null, insertFormulas[0]);
86+
expect(inserts).toEqualRdfQuadArray([
87+
DF.quad(foo1, RDF.terms.type, type1, insertFormulas[0] as NamedNode),
88+
DF.quad(foo2, RDF.terms.type, type2, insertFormulas[0] as NamedNode),
89+
]);
90+
});
91+
92+
it('performs no request if there is no input data.', async(): Promise<void> => {
93+
await expect(storage.addRule(new Store())).resolves.toBeUndefined();
94+
expect(fetchMock).toHaveBeenCalledTimes(0);
95+
});
96+
97+
it('errors if the fetch fails.', async(): Promise<void> => {
98+
(response as any).status = 500;
99+
vi.mocked(response.text).mockResolvedValueOnce('bad data');
100+
const rule = new Store([
101+
DF.quad(foo1, RDF.terms.type, type1),
102+
DF.quad(foo2, RDF.terms.type, type2),
103+
]);
104+
await expect(storage.addRule(rule)).rejects.toThrow('Could not add rule to the storage: 500 - bad data');
105+
});
106+
});
107+
108+
describe('.getRule', (): void => {
109+
it('extracts the matching rule.', async(): Promise<void> => {
110+
vi.mocked(response.text).mockResolvedValueOnce(`<> <http://www.w3.org/ns/ldp#contains> <http://example.com/policies/foo1>.`);
111+
vi.mocked(response.text).mockResolvedValueOnce(`
112+
<a> <b> <c> ;
113+
<d> <e> .
114+
<f> <g> <h> .
115+
`);
116+
await expect(storage.getRule(`${containerUrl}a`)).resolves.toBeRdfIsomorphic([
117+
DF.quad(DF.namedNode(`${containerUrl}a`), DF.namedNode(`${containerUrl}b`), DF.namedNode(`${containerUrl}c`)),
118+
DF.quad(DF.namedNode(`${containerUrl}a`), DF.namedNode(`${containerUrl}d`), DF.namedNode(`${containerUrl}e`)),
119+
]);
120+
});
121+
});
122+
123+
describe('.deleteRule', (): void => {
124+
it('is not supported.', async(): Promise<void> => {
125+
await expect(storage.deleteRule('a')).rejects.toThrow('not implemented');
126+
});
127+
});
128+
129+
describe('.removeData', (): void => {
130+
it('sends PATCH requests to all resources with relevant data.', async(): Promise<void> => {
131+
vi.mocked(response.text).mockResolvedValueOnce(containerTtl);
132+
vi.mocked(response.text).mockResolvedValueOnce(foo1Ttl);
133+
vi.mocked(response.text).mockResolvedValueOnce(foo2Ttl);
134+
135+
const removeData = new Store([
136+
DF.quad(foo1, RDF.terms.type, type1),
137+
DF.quad(foo2, RDF.terms.type, type2),
138+
]);
139+
await expect(storage.removeData(removeData)).resolves.toBeUndefined();
140+
expect(fetchMock).toHaveBeenCalledTimes(5);
141+
expect(fetchMock.mock.calls[3][0]).toBe(foo1.value);
142+
expect(fetchMock.mock.calls[4][0]).toBe(foo2.value);
143+
144+
// first patch
145+
expect(fetchMock.mock.calls[3][1]?.method).toBe('PATCH');
146+
expect(fetchMock.mock.calls[3][1]?.headers).toEqual({ 'content-type': 'text/n3' });
147+
let patch = new Store(new Parser({ format: 'n3' }).parse(fetchMock.mock.calls[3][1]!.body as string));
148+
let subjects = patch.getSubjects(RDF.terms.type, 'http://www.w3.org/ns/solid/terms#InsertDeletePatch', null);
149+
expect(subjects).toHaveLength(1);
150+
let insertFormulas = patch.getObjects(subjects[0], 'http://www.w3.org/ns/solid/terms#deletes', null);
151+
expect(insertFormulas).toHaveLength(1);
152+
let inserts = patch.getQuads(null, null, null, insertFormulas[0]);
153+
expect(inserts).toEqualRdfQuadArray([
154+
DF.quad(foo1, RDF.terms.type, type1, insertFormulas[0] as NamedNode),
155+
]);
156+
157+
// second patch
158+
expect(fetchMock.mock.calls[4][1]?.method).toBe('PATCH');
159+
expect(fetchMock.mock.calls[4][1]?.headers).toEqual({ 'content-type': 'text/n3' });
160+
patch = new Store(new Parser({ format: 'n3' }).parse(fetchMock.mock.calls[4][1]!.body as string));
161+
subjects = patch.getSubjects(RDF.terms.type, 'http://www.w3.org/ns/solid/terms#InsertDeletePatch', null);
162+
expect(subjects).toHaveLength(1);
163+
insertFormulas = patch.getObjects(subjects[0], 'http://www.w3.org/ns/solid/terms#deletes', null);
164+
expect(insertFormulas).toHaveLength(1);
165+
inserts = patch.getQuads(null, null, null, insertFormulas[0]);
166+
expect(inserts).toEqualRdfQuadArray([
167+
DF.quad(foo2, RDF.terms.type, type2, insertFormulas[0] as NamedNode),
168+
]);
169+
});
170+
171+
it('only sends requests to resources that have matches.', async(): Promise<void> => {
172+
vi.mocked(response.text).mockResolvedValueOnce(containerTtl);
173+
vi.mocked(response.text).mockResolvedValueOnce(foo1Ttl);
174+
vi.mocked(response.text).mockResolvedValueOnce(foo2Ttl);
175+
176+
const removeData = new Store([ DF.quad(foo2, RDF.terms.type, type2) ]);
177+
await expect(storage.removeData(removeData)).resolves.toBeUndefined();
178+
expect(fetchMock).toHaveBeenCalledTimes(4);
179+
expect(fetchMock.mock.calls[3][0]).toBe(foo2.value);
180+
});
181+
182+
it('does no requests if there is no data to remove.', async(): Promise<void> => {
183+
await expect(storage.removeData(new Store())).resolves.toBeUndefined();
184+
expect(fetchMock).toHaveBeenCalledTimes(0);
185+
});
186+
187+
it('errors if the update fails.', async(): Promise<void> => {
188+
fetchMock.mockResolvedValueOnce({ ...response, text: vi.fn().mockResolvedValue(containerTtl) });
189+
fetchMock.mockResolvedValueOnce({ ...response, text: vi.fn().mockResolvedValue(foo1Ttl) });
190+
fetchMock.mockResolvedValueOnce({ ...response, text: vi.fn().mockResolvedValue(foo2Ttl) });
191+
fetchMock.mockResolvedValueOnce({ ...response, status: 400, text: vi.fn().mockResolvedValue('bad data') });
192+
193+
const removeData = new Store([
194+
DF.quad(foo1, RDF.terms.type, type1),
195+
DF.quad(foo2, RDF.terms.type, type2),
196+
]);
197+
await expect(storage.removeData(removeData)).rejects.toThrow('Could not update rule resource http://example.com/policies/foo1: 400 - bad data');
198+
expect(fetchMock).toHaveBeenCalledTimes(4);
199+
});
200+
});
201+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import 'jest-rdf';
2+
import { DataFactory as DF, Store } from 'n3';
3+
import * as fs from 'node:fs';
4+
import path from 'node:path';
5+
import { DirectoryUCRulesStorage } from '../../../src/storage/DirectoryUCRulesStorage';
6+
import { RDF } from '../../../src/util/Vocabularies';
7+
8+
vi.mock('fs', () => ({
9+
lstatSync: vi.fn().mockReturnValue({ isDirectory: vi.fn().mockReturnValue(true) }),
10+
promises: {
11+
readdir: vi.fn(),
12+
readFile: vi.fn(),
13+
},
14+
}));
15+
16+
describe('DirectoryUCRulesStorage', (): void => {
17+
const directoryPath = '/foo/bar';
18+
const baseIRI = 'http://example.com/';
19+
const readdirMock = vi.spyOn(fs.promises, 'readdir');
20+
const readFileMock = vi.spyOn(fs.promises, 'readFile');
21+
let storage: DirectoryUCRulesStorage;
22+
23+
beforeEach(async(): Promise<void> => {
24+
vi.clearAllMocks();
25+
storage = new DirectoryUCRulesStorage(directoryPath, baseIRI);
26+
});
27+
28+
describe('.getStore', (): void => {
29+
it('returns the policies found in the directory.', async(): Promise<void> => {
30+
readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any);
31+
readFileMock.mockResolvedValueOnce(Buffer.from('<> a <http://example.com/type1> .'));
32+
readFileMock.mockResolvedValueOnce(Buffer.from('<> a <http://example.com/type2> .'));
33+
await expect(storage.getStore()).resolves.toBeRdfIsomorphic([
34+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type1')),
35+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type2')),
36+
]);
37+
expect(readdirMock).toHaveBeenCalledTimes(1);
38+
expect(readdirMock).lastCalledWith(directoryPath);
39+
expect(readFileMock).toHaveBeenCalledTimes(2);
40+
expect(readFileMock).toHaveBeenNthCalledWith(1, path.join('/foo/bar', 'a'));
41+
expect(readFileMock).toHaveBeenNthCalledWith(2, path.join('/foo/bar', 'b'));
42+
});
43+
44+
it('caches the policies in memory.', async(): Promise<void> => {
45+
readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any);
46+
readFileMock.mockResolvedValueOnce(Buffer.from('<> a <http://example.com/type1> .'));
47+
readFileMock.mockResolvedValueOnce(Buffer.from('<> a <http://example.com/type2> .'));
48+
49+
// first store call
50+
await storage.getStore();
51+
await expect(storage.getStore()).resolves.toBeRdfIsomorphic([
52+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type1')),
53+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type2')),
54+
]);
55+
expect(readdirMock).toHaveBeenCalledTimes(1);
56+
expect(readFileMock).toHaveBeenCalledTimes(2);
57+
});
58+
});
59+
60+
it('can add data to the storage.', async(): Promise<void> => {
61+
readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any);
62+
readFileMock.mockResolvedValueOnce(Buffer.from('<> a <http://example.com/type1> .'));
63+
readFileMock.mockResolvedValueOnce(Buffer.from('<> a <http://example.com/type2> .'));
64+
65+
await expect(storage.addRule(new Store([
66+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type3')),
67+
]))).resolves.toBeUndefined();
68+
await expect(storage.getStore()).resolves.toBeRdfIsomorphic([
69+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type1')),
70+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type2')),
71+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type3')),
72+
]);
73+
});
74+
75+
it('can remove data from the storage.', async(): Promise<void> => {
76+
readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any);
77+
readFileMock.mockResolvedValueOnce(Buffer.from('<> a <http://example.com/type1> .'));
78+
readFileMock.mockResolvedValueOnce(Buffer.from('<> a <http://example.com/type2> .'));
79+
80+
await expect(storage.removeData(new Store([
81+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type1')),
82+
]))).resolves.toBeUndefined();
83+
await expect(storage.getStore()).resolves.toBeRdfIsomorphic([
84+
DF.quad(DF.namedNode(baseIRI), RDF.terms.type, DF.namedNode('http://example.com/type2')),
85+
]);
86+
});
87+
88+
it('can return the relevant policy data.', async(): Promise<void> => {
89+
readdirMock.mockResolvedValueOnce([ 'a', 'b' ] as any);
90+
readFileMock.mockResolvedValueOnce(Buffer.from('<a> a <http://example.com/type1> .'));
91+
readFileMock.mockResolvedValueOnce(Buffer.from('<b> a <http://example.com/type2> .'));
92+
93+
await expect(storage.getRule('http://example.com/a')).resolves.toBeRdfIsomorphic([
94+
DF.quad(DF.namedNode(baseIRI + 'a'), RDF.terms.type, DF.namedNode('http://example.com/type1')),
95+
]);
96+
});
97+
98+
it('does not support deleting rules by identifier.', async(): Promise<void> => {
99+
await expect(storage.deleteRule('a')).rejects.toThrow('not implemented');
100+
});
101+
});

0 commit comments

Comments
 (0)