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.
Files changed (40) hide show
  1. package/.github/workflows/publish.yml +5 -2
  2. package/.github/workflows/upgrade-jinaga.yml +51 -0
  3. package/CLAUDE.md +77 -0
  4. package/dist/authorization/authorization-keystore.d.ts +27 -1
  5. package/dist/authorization/authorization-keystore.js +52 -4
  6. package/dist/authorization/authorization-keystore.js.map +1 -1
  7. package/dist/http/router.d.ts +4 -1
  8. package/dist/http/router.js +157 -14
  9. package/dist/http/router.js.map +1 -1
  10. package/dist/jinaga-server.js +18 -1
  11. package/dist/jinaga-server.js.map +1 -1
  12. package/dist/memory/memory-keystore.js +1 -1
  13. package/dist/memory/memory-keystore.js.map +1 -1
  14. package/dist/postgres/query-description.d.ts +1 -0
  15. package/dist/postgres/query-description.js +34 -3
  16. package/dist/postgres/query-description.js.map +1 -1
  17. package/dist/postgres/specification-sql.js +7 -1
  18. package/dist/postgres/specification-sql.js.map +1 -1
  19. package/docs/analysis/feed-builder-nested-existential-bug.md +191 -0
  20. package/integration-test/jinaga-test/distribution-multi-given.test.js +184 -0
  21. package/integration-test/jinaga-test/distribution-negating-feed.test.js +202 -0
  22. package/integration-test/jinaga-test/feed-existential-input.test.js +241 -0
  23. package/integration-test/jinaga-test/feeds-endpoint.test.js +140 -0
  24. package/integration-test/jinaga-test/package-lock.json +4 -4
  25. package/integration-test/jinaga-test/package.json +3 -2
  26. package/integration-test/jinaga-test/subscription-anchor-arrival.test.js +129 -0
  27. package/integration-test/jinaga-test/subscription-helpers.js +60 -0
  28. package/integration-test/jinaga-test/subscription-late-auth.test.js +245 -0
  29. package/integration-test/jinaga-test/subscription-no-rule.test.js +137 -0
  30. package/package.json +2 -2
  31. package/src/authorization/authorization-keystore.ts +87 -5
  32. package/src/http/router.ts +174 -21
  33. package/src/jinaga-server.ts +36 -3
  34. package/src/memory/memory-keystore.ts +1 -1
  35. package/src/postgres/query-description.ts +66 -11
  36. package/src/postgres/specification-sql.ts +7 -1
  37. package/test/authorization/subscriptionIntersectionSpec.ts +208 -0
  38. package/test/http/anchorListenerSpec.ts +117 -0
  39. package/test/http/lateAuthRecoverySpec.ts +245 -0
  40. 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.7.13",
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.7.13",
3199
- "resolved": "https://registry.npmjs.org/jinaga/-/jinaga-6.7.13.tgz",
3200
- "integrity": "sha512-WhSg3bWvUr1WnC4lnpzswJIZ45bMWxRlKXlEhOsofI86jXJSH7tHUqXWKZ9UJihc5/8iGxJUnM65zWZmPa+aFA==",
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.7.13",
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
+ };