jinaga-server 3.5.4 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +5 -2
- package/.github/workflows/upgrade-jinaga.yml +51 -0
- package/CLAUDE.md +77 -0
- package/dist/authorization/authorization-keystore.d.ts +27 -1
- package/dist/authorization/authorization-keystore.js +52 -4
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/http/router.d.ts +4 -1
- package/dist/http/router.js +157 -14
- package/dist/http/router.js.map +1 -1
- package/dist/jinaga-server.js +18 -1
- package/dist/jinaga-server.js.map +1 -1
- package/dist/memory/memory-keystore.js +1 -1
- package/dist/memory/memory-keystore.js.map +1 -1
- package/dist/postgres/query-description.d.ts +1 -0
- package/dist/postgres/query-description.js +34 -3
- package/dist/postgres/query-description.js.map +1 -1
- package/dist/postgres/specification-sql.js +7 -1
- package/dist/postgres/specification-sql.js.map +1 -1
- package/docs/analysis/feed-builder-nested-existential-bug.md +191 -0
- package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
- package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
- package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
- package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
- package/integration-test/jinaga-test/package-lock.json +4 -4
- package/integration-test/jinaga-test/package.json +3 -2
- package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
- package/integration-test/jinaga-test/subscription-helpers.js +60 -0
- package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
- package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
- package/package.json +2 -2
- package/src/authorization/authorization-keystore.ts +87 -5
- package/src/http/router.ts +174 -21
- package/src/jinaga-server.ts +36 -3
- package/src/memory/memory-keystore.ts +1 -1
- package/src/postgres/query-description.ts +66 -11
- package/src/postgres/specification-sql.ts +7 -1
- package/test/authorization/subscriptionIntersectionSpec.ts +208 -0
- package/test/http/anchorListenerSpec.ts +117 -0
- package/test/http/lateAuthRecoverySpec.ts +245 -0
- package/test/http/streamFeedAnchorSpec.ts +169 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { buildModel, User } = require('jinaga');
|
|
3
|
+
const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
|
|
4
|
+
|
|
5
|
+
// Exercises a PostgresStore feed-SQL bug surfaced by the Copilot review
|
|
6
|
+
// on PR #164: when a path condition inside an existential references a
|
|
7
|
+
// given for the first time, the type+hash filter for that given is
|
|
8
|
+
// registered on `ExistentialConditionDescription.inputs`. The result-SQL
|
|
9
|
+
// generator emits those, but the feed-SQL generator silently drops them.
|
|
10
|
+
// The effect is a notExists that ignores its parameterized filter — the
|
|
11
|
+
// existential matches ANY fact with the right roles regardless of the
|
|
12
|
+
// given's identity, so the outer match is filtered out incorrectly.
|
|
13
|
+
|
|
14
|
+
jest.setTimeout(30000);
|
|
15
|
+
|
|
16
|
+
class Workspace {
|
|
17
|
+
static Type = "ExistInput.Workspace";
|
|
18
|
+
type = Workspace.Type;
|
|
19
|
+
constructor(creator, name) {
|
|
20
|
+
this.creator = creator;
|
|
21
|
+
this.name = name;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class WorkspaceAccess {
|
|
26
|
+
static Type = "ExistInput.WorkspaceAccess";
|
|
27
|
+
type = WorkspaceAccess.Type;
|
|
28
|
+
constructor(workspace, user) {
|
|
29
|
+
this.workspace = workspace;
|
|
30
|
+
this.user = user;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class Document {
|
|
35
|
+
static Type = "ExistInput.Document";
|
|
36
|
+
type = Document.Type;
|
|
37
|
+
constructor(workspace, title) {
|
|
38
|
+
this.workspace = workspace;
|
|
39
|
+
this.title = title;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class Restriction {
|
|
44
|
+
static Type = "ExistInput.Restriction";
|
|
45
|
+
type = Restriction.Type;
|
|
46
|
+
// Marks a document as hidden from a specific user.
|
|
47
|
+
constructor(document, user) {
|
|
48
|
+
this.document = document;
|
|
49
|
+
this.user = user;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const model = buildModel(b => b
|
|
54
|
+
.type(User)
|
|
55
|
+
.type(Workspace, m => m.predecessor("creator", User))
|
|
56
|
+
.type(WorkspaceAccess, m => m
|
|
57
|
+
.predecessor("workspace", Workspace)
|
|
58
|
+
.predecessor("user", User))
|
|
59
|
+
.type(Document, m => m.predecessor("workspace", Workspace))
|
|
60
|
+
.type(Restriction, m => m
|
|
61
|
+
.predecessor("document", Document)
|
|
62
|
+
.predecessor("user", User))
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
function authorization(a) {
|
|
66
|
+
return a
|
|
67
|
+
.any(User)
|
|
68
|
+
.any(Workspace)
|
|
69
|
+
.any(WorkspaceAccess)
|
|
70
|
+
.any(Document)
|
|
71
|
+
.any(Restriction);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2-given rule whose share has the SAME shape as the user's spec
|
|
75
|
+
// (documents in workspace w, except those restricted for user u).
|
|
76
|
+
// The `with` rule grants access to any user that has a
|
|
77
|
+
// WorkspaceAccess for the workspace. The rule's share spec is what
|
|
78
|
+
// has to skeleton-match the user's spec for canDistributeTo to pass.
|
|
79
|
+
function distribution(d) {
|
|
80
|
+
return d
|
|
81
|
+
.share(model.given(Workspace, User).match((w, u, facts) =>
|
|
82
|
+
facts.ofType(Document).join(d => d.workspace, w)
|
|
83
|
+
.notExists(d => facts.ofType(Restriction)
|
|
84
|
+
.join(r => r.document, d)
|
|
85
|
+
.join(r => r.user, u))
|
|
86
|
+
))
|
|
87
|
+
.with(model.given(Workspace, User).match((w, u, facts) =>
|
|
88
|
+
facts.ofType(WorkspaceAccess)
|
|
89
|
+
.join(a => a.workspace, w)
|
|
90
|
+
.join(a => a.user, u)
|
|
91
|
+
.selectMany(a => facts.ofType(User).join(x => x, a.user))
|
|
92
|
+
));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Subscription: documents in workspace p1 that have not been
|
|
96
|
+
// restricted for user p2. The given p2 is referenced FOR THE
|
|
97
|
+
// FIRST TIME inside the notExists — this is what triggers the
|
|
98
|
+
// "existential inputs dropped" bug in the feed-SQL generator.
|
|
99
|
+
function docSpecText(workspaceHash, userHash) {
|
|
100
|
+
return `let p1: ${Workspace.Type} = #${workspaceHash}\n` +
|
|
101
|
+
`let p2: ${User.Type} = #${userHash}\n` +
|
|
102
|
+
`(p1: ${Workspace.Type}, p2: ${User.Type}) {\n` +
|
|
103
|
+
` d: ${Document.Type} [\n` +
|
|
104
|
+
` d->workspace: ${Workspace.Type} = p1\n` +
|
|
105
|
+
` !E {\n` +
|
|
106
|
+
` r: ${Restriction.Type} [\n` +
|
|
107
|
+
` r->document: ${Document.Type} = d\n` +
|
|
108
|
+
` r->user: ${User.Type} = p2\n` +
|
|
109
|
+
` ]\n` +
|
|
110
|
+
` }\n` +
|
|
111
|
+
` ]\n` +
|
|
112
|
+
`} => d`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('Feed SQL: existential references a given referenced only inside the notExists', () => {
|
|
116
|
+
let app, withSession, close;
|
|
117
|
+
let workspaceValue, workspaceHash, documentValue, documentHash;
|
|
118
|
+
let creatorId;
|
|
119
|
+
|
|
120
|
+
beforeEach(async () => {
|
|
121
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
122
|
+
model, authorization, distribution
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
creatorId = 'ei-creator-' + randomSuffix();
|
|
126
|
+
({ workspaceHash, documentHash } = await asUser(withSession, creatorId, async (j) => {
|
|
127
|
+
const { userFact: creatorFact } = await j.login();
|
|
128
|
+
const workspace = await j.fact(new Workspace(creatorFact, 'w-' + randomSuffix()));
|
|
129
|
+
workspaceValue = workspace;
|
|
130
|
+
const document = await j.fact(new Document(workspace, 'doc-' + randomSuffix()));
|
|
131
|
+
documentValue = document;
|
|
132
|
+
return {
|
|
133
|
+
workspaceHash: j.hash(workspace),
|
|
134
|
+
documentHash: j.hash(document)
|
|
135
|
+
};
|
|
136
|
+
}));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(async () => {
|
|
140
|
+
if (close) await close();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('does not let one user\'s Restriction hide a document from a different user', async () => {
|
|
144
|
+
// Alice and Bob both get WorkspaceAccess so the distribution
|
|
145
|
+
// check passes for each of them directly (no intersection).
|
|
146
|
+
const aliceId = 'ei-alice-' + randomSuffix();
|
|
147
|
+
const aliceFact = await asUser(withSession, aliceId, async (j) => {
|
|
148
|
+
const { userFact } = await j.login();
|
|
149
|
+
return userFact;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const bobId = 'ei-bob-' + randomSuffix();
|
|
153
|
+
const bobFact = await asUser(withSession, bobId, async (j) => {
|
|
154
|
+
const { userFact } = await j.login();
|
|
155
|
+
return userFact;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await asUser(withSession, creatorId, async (j) => {
|
|
159
|
+
await j.fact(new WorkspaceAccess(workspaceValue, aliceFact));
|
|
160
|
+
await j.fact(new WorkspaceAccess(workspaceValue, bobFact));
|
|
161
|
+
// Restriction targets Bob ONLY. Alice's view must not be
|
|
162
|
+
// affected — her notExists should remain true.
|
|
163
|
+
await j.fact(new Restriction(documentValue, bobFact));
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const aliceHash = await asUser(withSession, aliceId, async (j) => j.hash(aliceFact));
|
|
167
|
+
|
|
168
|
+
const feedsResponse = await request(app)
|
|
169
|
+
.post('/feeds')
|
|
170
|
+
.set('Content-Type', 'text/plain')
|
|
171
|
+
.set('x-test-user-id', aliceId)
|
|
172
|
+
.send(docSpecText(workspaceHash, aliceHash));
|
|
173
|
+
expect(feedsResponse.status).toBe(200);
|
|
174
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
175
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
176
|
+
|
|
177
|
+
// A spec with notExists splits into a negating feed and a main
|
|
178
|
+
// feed (with the notExists applied). The bug lives in the main
|
|
179
|
+
// feed's SQL, so aggregate references across all feeds.
|
|
180
|
+
const aggregated = [];
|
|
181
|
+
for (const feedHash of feeds) {
|
|
182
|
+
const pollResponse = await request(app)
|
|
183
|
+
.get(`/feeds/${feedHash}`)
|
|
184
|
+
.set('x-test-user-id', aliceId);
|
|
185
|
+
expect(pollResponse.status).toBe(200);
|
|
186
|
+
const body = JSON.parse(pollResponse.text);
|
|
187
|
+
aggregated.push(...(body.references || []));
|
|
188
|
+
}
|
|
189
|
+
const docs = aggregated.filter(r => r.type === Document.Type);
|
|
190
|
+
// Without the fix the main feed's existential ignores its
|
|
191
|
+
// `user = p2` filter and matches Bob's Restriction, so Alice's
|
|
192
|
+
// notExists evaluates false and she gets no documents.
|
|
193
|
+
expect(docs.map(r => r.hash)).toContain(documentHash);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('hides a document from the user whose Restriction exists', async () => {
|
|
197
|
+
// Companion case: when the Restriction IS for the polling user,
|
|
198
|
+
// the notExists must evaluate false and the document must be
|
|
199
|
+
// filtered out.
|
|
200
|
+
const carolId = 'ei-carol-' + randomSuffix();
|
|
201
|
+
const carolFact = await asUser(withSession, carolId, async (j) => {
|
|
202
|
+
const { userFact } = await j.login();
|
|
203
|
+
return userFact;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await asUser(withSession, creatorId, async (j) => {
|
|
207
|
+
await j.fact(new WorkspaceAccess(workspaceValue, carolFact));
|
|
208
|
+
await j.fact(new Restriction(documentValue, carolFact));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const carolHash = await asUser(withSession, carolId, async (j) => j.hash(carolFact));
|
|
212
|
+
|
|
213
|
+
const feedsResponse = await request(app)
|
|
214
|
+
.post('/feeds')
|
|
215
|
+
.set('Content-Type', 'text/plain')
|
|
216
|
+
.set('x-test-user-id', carolId)
|
|
217
|
+
.send(docSpecText(workspaceHash, carolHash));
|
|
218
|
+
expect(feedsResponse.status).toBe(200);
|
|
219
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
220
|
+
|
|
221
|
+
// The main feed must NOT include the document for Carol. The
|
|
222
|
+
// negating feed legitimately surfaces the Restriction (a fact
|
|
223
|
+
// proving the condition false) along with the Document so the
|
|
224
|
+
// client can react — exclude those by only counting Documents
|
|
225
|
+
// returned without an accompanying Restriction.
|
|
226
|
+
let mainDocHashes = [];
|
|
227
|
+
for (const feedHash of feeds) {
|
|
228
|
+
const pollResponse = await request(app)
|
|
229
|
+
.get(`/feeds/${feedHash}`)
|
|
230
|
+
.set('x-test-user-id', carolId);
|
|
231
|
+
expect(pollResponse.status).toBe(200);
|
|
232
|
+
const body = JSON.parse(pollResponse.text);
|
|
233
|
+
const refs = body.references || [];
|
|
234
|
+
const hasRestriction = refs.some(r => r.type === Restriction.Type);
|
|
235
|
+
if (!hasRestriction) {
|
|
236
|
+
mainDocHashes.push(...refs.filter(r => r.type === Document.Type).map(r => r.hash));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
expect(mainDocHashes).not.toContain(documentHash);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { buildModel, User } = require('jinaga');
|
|
3
|
+
const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
|
|
4
|
+
|
|
5
|
+
// Baseline coverage for POST /feeds and GET /feeds/:hash that did not
|
|
6
|
+
// exist in the integration suite before. Independent of PR #163's
|
|
7
|
+
// late-auth contracts, this catches schema/router-wiring regressions on
|
|
8
|
+
// the subscription endpoints.
|
|
9
|
+
|
|
10
|
+
class Root {
|
|
11
|
+
static Type = "FeedsEndpoint.Root";
|
|
12
|
+
type = Root.Type;
|
|
13
|
+
constructor(creator, identifier) {
|
|
14
|
+
this.creator = creator;
|
|
15
|
+
this.identifier = identifier;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class Item {
|
|
20
|
+
static Type = "FeedsEndpoint.Item";
|
|
21
|
+
type = Item.Type;
|
|
22
|
+
constructor(root, name) {
|
|
23
|
+
this.root = root;
|
|
24
|
+
this.name = name;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const model = buildModel(b => b
|
|
29
|
+
.type(User)
|
|
30
|
+
.type(Root, m => m.predecessor("creator", User))
|
|
31
|
+
.type(Item, m => m.predecessor("root", Root))
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
function authorization(a) {
|
|
35
|
+
return a.any(User).any(Root).any(Item);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function itemSpecText(rootHash) {
|
|
39
|
+
return `let r: ${Root.Type} = #${rootHash}\n` +
|
|
40
|
+
`(r: ${Root.Type}) {\n` +
|
|
41
|
+
` i: ${Item.Type} [\n` +
|
|
42
|
+
` i->root: ${Root.Type} = r\n` +
|
|
43
|
+
` ]\n` +
|
|
44
|
+
`} => i`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('Feeds endpoint baseline', () => {
|
|
48
|
+
let app, withSession, close;
|
|
49
|
+
let rootHash;
|
|
50
|
+
const itemHashes = [];
|
|
51
|
+
let writerId;
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
55
|
+
model, authorization
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
writerId = 'feeds-writer-' + randomSuffix();
|
|
59
|
+
rootHash = await asUser(withSession, writerId, async (j) => {
|
|
60
|
+
const { userFact } = await j.login();
|
|
61
|
+
const root = await j.fact(new Root(userFact, 'r-' + randomSuffix()));
|
|
62
|
+
itemHashes.length = 0;
|
|
63
|
+
for (let i = 0; i < 3; i++) {
|
|
64
|
+
const item = await j.fact(new Item(root, `item-${i}`));
|
|
65
|
+
itemHashes.push(j.hash(item));
|
|
66
|
+
}
|
|
67
|
+
return j.hash(root);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(async () => {
|
|
72
|
+
if (close) await close();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('POST /feeds returns a non-empty hash list for a valid spec', async () => {
|
|
76
|
+
const response = await request(app)
|
|
77
|
+
.post('/feeds')
|
|
78
|
+
.set('Content-Type', 'text/plain')
|
|
79
|
+
.set('x-test-user-id', writerId)
|
|
80
|
+
.send(itemSpecText(rootHash));
|
|
81
|
+
|
|
82
|
+
expect(response.status).toBe(200);
|
|
83
|
+
const body = JSON.parse(response.text);
|
|
84
|
+
expect(Array.isArray(body.feeds)).toBe(true);
|
|
85
|
+
expect(body.feeds.length).toBeGreaterThan(0);
|
|
86
|
+
body.feeds.forEach(hash => {
|
|
87
|
+
expect(typeof hash).toBe('string');
|
|
88
|
+
expect(hash.length).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('GET /feeds/:hash returns existing items with a bookmark', async () => {
|
|
93
|
+
const feedsResponse = await request(app)
|
|
94
|
+
.post('/feeds')
|
|
95
|
+
.set('Content-Type', 'text/plain')
|
|
96
|
+
.set('x-test-user-id', writerId)
|
|
97
|
+
.send(itemSpecText(rootHash));
|
|
98
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
99
|
+
|
|
100
|
+
const pollResponse = await request(app)
|
|
101
|
+
.get(`/feeds/${feeds[0]}`)
|
|
102
|
+
.set('x-test-user-id', writerId);
|
|
103
|
+
|
|
104
|
+
expect(pollResponse.status).toBe(200);
|
|
105
|
+
const body = JSON.parse(pollResponse.text);
|
|
106
|
+
const itemRefs = (body.references || []).filter(r => r.type === Item.Type);
|
|
107
|
+
expect(itemRefs.map(r => r.hash).sort()).toEqual([...itemHashes].sort());
|
|
108
|
+
expect(typeof body.bookmark).toBe('string');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('GET /feeds/:hash returns 404 for an unknown hash', async () => {
|
|
112
|
+
const response = await request(app)
|
|
113
|
+
.get('/feeds/this-hash-does-not-exist-' + randomSuffix())
|
|
114
|
+
.set('x-test-user-id', writerId);
|
|
115
|
+
|
|
116
|
+
expect(response.status).toBe(404);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('POST /feeds with malformed text returns 400', async () => {
|
|
120
|
+
const response = await request(app)
|
|
121
|
+
.post('/feeds')
|
|
122
|
+
.set('Content-Type', 'text/plain')
|
|
123
|
+
.set('x-test-user-id', writerId)
|
|
124
|
+
.send('this is not a specification');
|
|
125
|
+
|
|
126
|
+
// SpecificationParser raises an Invalid error which the router
|
|
127
|
+
// translates into a 400.
|
|
128
|
+
expect(response.status).toBe(400);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('OPTIONS /feeds advertises Accept-Post', async () => {
|
|
132
|
+
const response = await request(app)
|
|
133
|
+
.options('/feeds');
|
|
134
|
+
|
|
135
|
+
expect(response.status).toBe(204);
|
|
136
|
+
const acceptPost = response.headers['accept-post'];
|
|
137
|
+
expect(acceptPost).toBeDefined();
|
|
138
|
+
expect(acceptPost).toContain('text/plain');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"express": "^4.21.2",
|
|
15
15
|
"jest": "^29.7.0",
|
|
16
16
|
"jest-compact-reporter": "^1.2.9",
|
|
17
|
-
"jinaga": "^6.
|
|
17
|
+
"jinaga": "^6.8.0",
|
|
18
18
|
"node-forge": "^1.3.0",
|
|
19
19
|
"pg": "^8.13.1"
|
|
20
20
|
},
|
|
@@ -3195,9 +3195,9 @@
|
|
|
3195
3195
|
}
|
|
3196
3196
|
},
|
|
3197
3197
|
"node_modules/jinaga": {
|
|
3198
|
-
"version": "6.
|
|
3199
|
-
"resolved": "https://registry.npmjs.org/jinaga/-/jinaga-6.
|
|
3200
|
-
"integrity": "sha512-
|
|
3198
|
+
"version": "6.8.0",
|
|
3199
|
+
"resolved": "https://registry.npmjs.org/jinaga/-/jinaga-6.8.0.tgz",
|
|
3200
|
+
"integrity": "sha512-YN2CtA3PcIDpLR/2fQQTSX5IeoxynEVXLtvHWfK2bUpDrPzfDjsxc8ALbN7OZlEVMeOZuxvtQdgHYH79qqe0iQ==",
|
|
3201
3201
|
"license": "MIT",
|
|
3202
3202
|
"dependencies": {
|
|
3203
3203
|
"@stablelib/base64": "^1.0.1",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"test:verbose": "jest --silent=false --verbose false"
|
|
9
9
|
},
|
|
10
10
|
"jest": {
|
|
11
|
-
"verbose": false
|
|
11
|
+
"verbose": false,
|
|
12
|
+
"testTimeout": 30000
|
|
12
13
|
},
|
|
13
14
|
"author": "Michael L Perry",
|
|
14
15
|
"license": "MIT",
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
"express": "^4.21.2",
|
|
19
20
|
"jest": "^29.7.0",
|
|
20
21
|
"jest-compact-reporter": "^1.2.9",
|
|
21
|
-
"jinaga": "^6.
|
|
22
|
+
"jinaga": "^6.8.0",
|
|
22
23
|
"node-forge": "^1.3.0",
|
|
23
24
|
"pg": "^8.13.1"
|
|
24
25
|
},
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { buildModel, dehydrateFact, User } = require('jinaga');
|
|
3
|
+
const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
|
|
4
|
+
|
|
5
|
+
// Validates the polling-side contract that subscribing to a spec whose
|
|
6
|
+
// given fact has not yet been stored is accepted, and that the data
|
|
7
|
+
// surfaces once the given (and a matching descendant) lands. In the
|
|
8
|
+
// streaming path this is what the anchor listener wakes up; the polling
|
|
9
|
+
// path achieves the same outcome by re-querying the store, so a Postgres-
|
|
10
|
+
// backed test here guards the store/feed query against a missing given
|
|
11
|
+
// reference.
|
|
12
|
+
|
|
13
|
+
class Note {
|
|
14
|
+
static Type = "AnchorArrival.Note";
|
|
15
|
+
type = Note.Type;
|
|
16
|
+
constructor(author, body) {
|
|
17
|
+
this.author = author;
|
|
18
|
+
this.body = body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const model = buildModel(b => b
|
|
23
|
+
.type(User)
|
|
24
|
+
.type(Note, m => m.predecessor("author", User))
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
function authorization(a) {
|
|
28
|
+
return a.any(User).any(Note);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function noteSpecText(userHash) {
|
|
32
|
+
return `let p: Jinaga.User = #${userHash}\n` +
|
|
33
|
+
`(p: Jinaga.User) {\n` +
|
|
34
|
+
` n: ${Note.Type} [\n` +
|
|
35
|
+
` n->author: Jinaga.User = p\n` +
|
|
36
|
+
` ]\n` +
|
|
37
|
+
`} => n`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Subscription with not-yet-stored given fact', () => {
|
|
41
|
+
let app, withSession, close;
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
45
|
+
model, authorization
|
|
46
|
+
}));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
if (close) await close();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('accepts the subscription and surfaces data once the given and a descendant arrive', async () => {
|
|
54
|
+
// Hand-craft a User whose hash is known but whose fact has not been
|
|
55
|
+
// saved. This stands in for the "given fact arrives later" scenario
|
|
56
|
+
// — the streamFeed path would activate via the anchor listener; the
|
|
57
|
+
// polling path picks it up naturally on the next poll.
|
|
58
|
+
const externalUser = new User('anchor-arrival-key-' + randomSuffix());
|
|
59
|
+
const userHash = dehydrateFact(externalUser)
|
|
60
|
+
.find(f => f.type === 'Jinaga.User').hash;
|
|
61
|
+
|
|
62
|
+
const subscriberId = 'anchor-subscriber-' + randomSuffix();
|
|
63
|
+
await asUser(withSession, subscriberId, async (j) => { await j.login(); });
|
|
64
|
+
|
|
65
|
+
const feedsResponse = await request(app)
|
|
66
|
+
.post('/feeds')
|
|
67
|
+
.set('Content-Type', 'text/plain')
|
|
68
|
+
.set('x-test-user-id', subscriberId)
|
|
69
|
+
.send(noteSpecText(userHash));
|
|
70
|
+
expect(feedsResponse.status).toBe(200);
|
|
71
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
72
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
73
|
+
const feedHash = feeds[0];
|
|
74
|
+
|
|
75
|
+
// Poll #1: the given fact does not exist yet, so there can be no
|
|
76
|
+
// descendants — the page is empty but the server must not 404 or
|
|
77
|
+
// error.
|
|
78
|
+
const firstPoll = await request(app)
|
|
79
|
+
.get(`/feeds/${feedHash}`)
|
|
80
|
+
.set('x-test-user-id', subscriberId);
|
|
81
|
+
expect(firstPoll.status).toBe(200);
|
|
82
|
+
const firstBody = JSON.parse(firstPoll.text);
|
|
83
|
+
expect((firstBody.references || []).filter(r => r.type === Note.Type)).toHaveLength(0);
|
|
84
|
+
const firstBookmark = firstBody.bookmark || '';
|
|
85
|
+
|
|
86
|
+
// Save the given User fact AND a Note that joins to it.
|
|
87
|
+
// j.fact(new Note(externalUser, ...)) recursively saves both, so
|
|
88
|
+
// when polling resumes both the anchor and its descendant exist.
|
|
89
|
+
const writerId = 'anchor-writer-' + randomSuffix();
|
|
90
|
+
let noteHash;
|
|
91
|
+
await asUser(withSession, writerId, async (writerJ) => {
|
|
92
|
+
const note = await writerJ.fact(new Note(externalUser, 'first body'));
|
|
93
|
+
noteHash = writerJ.hash(note);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const secondPoll = await request(app)
|
|
97
|
+
.get(`/feeds/${feedHash}`)
|
|
98
|
+
.query({ b: firstBookmark })
|
|
99
|
+
.set('x-test-user-id', subscriberId);
|
|
100
|
+
expect(secondPoll.status).toBe(200);
|
|
101
|
+
const secondBody = JSON.parse(secondPoll.text);
|
|
102
|
+
const noteRefs = (secondBody.references || []).filter(r => r.type === Note.Type);
|
|
103
|
+
expect(noteRefs.map(r => r.hash)).toContain(noteHash);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns an empty page (not 404) when the given fact never arrives', async () => {
|
|
107
|
+
const phantomUser = new User('phantom-key-' + randomSuffix());
|
|
108
|
+
const userHash = dehydrateFact(phantomUser)
|
|
109
|
+
.find(f => f.type === 'Jinaga.User').hash;
|
|
110
|
+
|
|
111
|
+
const subscriberId = 'phantom-subscriber-' + randomSuffix();
|
|
112
|
+
await asUser(withSession, subscriberId, async (j) => { await j.login(); });
|
|
113
|
+
|
|
114
|
+
const feedsResponse = await request(app)
|
|
115
|
+
.post('/feeds')
|
|
116
|
+
.set('Content-Type', 'text/plain')
|
|
117
|
+
.set('x-test-user-id', subscriberId)
|
|
118
|
+
.send(noteSpecText(userHash));
|
|
119
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
120
|
+
const feedHash = feeds[0];
|
|
121
|
+
|
|
122
|
+
const pollResponse = await request(app)
|
|
123
|
+
.get(`/feeds/${feedHash}`)
|
|
124
|
+
.set('x-test-user-id', subscriberId);
|
|
125
|
+
expect(pollResponse.status).toBe(200);
|
|
126
|
+
const body = JSON.parse(pollResponse.text);
|
|
127
|
+
expect((body.references || []).filter(r => r.type === Note.Type)).toHaveLength(0);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { JinagaServer } = require('./jinaga-server');
|
|
3
|
+
|
|
4
|
+
const host = "db";
|
|
5
|
+
const connectionString = `postgresql://dev:devpw@${host}:5432/integrationtest`;
|
|
6
|
+
|
|
7
|
+
function authMiddleware(req, res, next) {
|
|
8
|
+
const userId = req.header('x-test-user-id');
|
|
9
|
+
if (userId) {
|
|
10
|
+
req.user = {
|
|
11
|
+
provider: 'test',
|
|
12
|
+
id: userId,
|
|
13
|
+
profile: { displayName: userId }
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
next();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function createSubscriptionApp(config) {
|
|
20
|
+
const instance = JinagaServer.create({
|
|
21
|
+
pgKeystore: connectionString,
|
|
22
|
+
pgStore: connectionString,
|
|
23
|
+
...config
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const app = express();
|
|
27
|
+
app.use(express.json());
|
|
28
|
+
app.use(express.text());
|
|
29
|
+
app.use(authMiddleware);
|
|
30
|
+
app.use(instance.handler);
|
|
31
|
+
|
|
32
|
+
await instance.j.local();
|
|
33
|
+
return { app, ...instance };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function asUser(withSession, userId, callback) {
|
|
37
|
+
let result;
|
|
38
|
+
await withSession(
|
|
39
|
+
{ user: { provider: 'test', id: userId, profile: { displayName: userId } } },
|
|
40
|
+
async (j) => {
|
|
41
|
+
// Ensure the user fact is registered in the keystore so AuthenticationSession
|
|
42
|
+
// can sign facts and the router's getUserFact succeeds on later requests.
|
|
43
|
+
await j.login();
|
|
44
|
+
result = await callback(j);
|
|
45
|
+
}
|
|
46
|
+
);
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function randomSuffix() {
|
|
51
|
+
return Math.random().toString(36).substring(2, 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
connectionString,
|
|
56
|
+
authMiddleware,
|
|
57
|
+
createSubscriptionApp,
|
|
58
|
+
asUser,
|
|
59
|
+
randomSuffix
|
|
60
|
+
};
|