-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathapp-paths.test.js
More file actions
151 lines (136 loc) · 5.6 KB
/
Copy pathapp-paths.test.js
File metadata and controls
151 lines (136 loc) · 5.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
* appPaths — WAC-exempt application mount points (#582).
*
* The global WAC preHandler authorizes every URL against pod ACLs and
* rejects before routes run; the only escapes are its hardcoded prefix
* list (/storage/, /db, /mcp, …). That means a third-party app plugin
* registering routes on the returned fastify instance (#206's plugin-zero
* pattern, e.g. a game mounted at /tideholm) has its POSTs swallowed by
* WAC with no way to opt out.
*
* createServer({ appPaths: ['/myapp'] }) declares URL prefixes owned by
* registered applications: requests at or below an app path skip the WAC
* hook, and the app owns authentication and authorization under its
* prefix — exactly the deal the bundled pseudo-plugins already have.
*
* Tests verify the seam end-to-end: an app route mounted on the returned
* instance receives unauthenticated requests (WAC stays out), sibling LDP
* paths keep full WAC enforcement, and malformed appPaths entries are
* dropped rather than becoming accidental holes.
*/
import { describe, it, before, after, afterEach } from 'node:test';
import assert from 'node:assert';
import { createServer } from '../src/server.js';
import fs from 'fs-extra';
const TEST_DATA_DIR = './test-data-app-paths';
let server;
let baseUrl;
let originalDataRoot;
async function startWith(appPaths) {
await fs.emptyDir(TEST_DATA_DIR);
server = createServer({
logger: false,
forceCloseConnections: true,
root: TEST_DATA_DIR,
appPaths,
});
// An app in the #206 plugin-zero shape: routes registered on the returned
// instance, answering with its own status codes (its own "auth").
server.all('/myapp', echo);
server.all('/myapp/*', echo);
async function echo(request, reply) {
reply.code(200).send({
app: true,
method: request.method,
url: request.url,
webId: request.webId ?? null, // hook skipped -> never set
});
}
await server.listen({ port: 0, host: '127.0.0.1' });
const address = server.server.address();
baseUrl = `http://127.0.0.1:${address.port}`;
}
describe('appPaths application mount points (#582)', () => {
before(() => {
// createServer({ root }) mutates process.env.DATA_ROOT; snapshot so the
// test dir doesn't leak into later suites.
originalDataRoot = process.env.DATA_ROOT;
});
afterEach(async () => {
if (server) {
await server.close();
server = null;
}
await fs.remove(TEST_DATA_DIR);
});
after(() => {
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
else process.env.DATA_ROOT = originalDataRoot;
});
it('unauthenticated POST below an app path reaches the app handler', async () => {
await startWith(['/myapp']);
const res = await fetch(`${baseUrl}/myapp/api/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hello: 'world' }),
});
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.app, true);
assert.strictEqual(body.method, 'POST');
});
it('the bare app path and query-string forms are exempt too', async () => {
await startWith(['/myapp']);
for (const path of ['/myapp', '/myapp?tab=map']) {
const res = await fetch(`${baseUrl}${path}`, { method: 'POST' });
assert.strictEqual(res.status, 200, `${path} should reach the app`);
}
});
it('the WAC hook never sets request.webId on app-path requests', async () => {
await startWith(['/myapp']);
const res = await fetch(`${baseUrl}/myapp/whoami`);
const body = await res.json();
assert.strictEqual(body.webId, null);
});
it('sibling LDP paths keep full WAC enforcement', async () => {
await startWith(['/myapp']);
// Writing outside the app prefix without auth must still be rejected.
const res = await fetch(`${baseUrl}/notes.jsonld`, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({ '@id': '', name: 'x' }),
});
assert.ok(res.status === 401 || res.status === 403,
`expected WAC rejection, got ${res.status}`);
});
it('a prefix match is a path-segment match, not a string prefix', async () => {
await startWith(['/myapp']);
// /myapplication must NOT be exempt just because it shares characters.
const res = await fetch(`${baseUrl}/myapplication.jsonld`, {
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify({ '@id': '', name: 'x' }),
});
assert.ok(res.status === 401 || res.status === 403,
`expected WAC rejection, got ${res.status}`);
});
it('trailing-slash entries are normalized, children still exempt', async () => {
await startWith(['/myapp/']);
const res = await fetch(`${baseUrl}/myapp/api/action`, { method: 'POST' });
assert.strictEqual(res.status, 200);
});
it('malformed appPaths entries are dropped, not accidental holes', async () => {
// No leading slash and bare '/' are both invalid; with them filtered out
// the route registrations still exist but WAC fires first.
await startWith(['myapp', '/', '///', ' ']);
const res = await fetch(`${baseUrl}/myapp/api/action`, { method: 'POST' });
assert.ok(res.status === 401 || res.status === 403,
`expected WAC rejection (invalid entries dropped), got ${res.status}`);
});
it('omitting appPaths changes nothing (default off)', async () => {
await startWith(undefined);
const res = await fetch(`${baseUrl}/myapp/api/action`, { method: 'POST' });
assert.ok(res.status === 401 || res.status === 403,
`expected WAC rejection with no appPaths, got ${res.status}`);
});
});