jinaga-server 3.5.5 → 3.7.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/dist/authorization/authorization-keystore.d.ts +34 -2
- package/dist/authorization/authorization-keystore.js +59 -4
- package/dist/authorization/authorization-keystore.js.map +1 -1
- package/dist/feeds/feed-stream-session.d.ts +84 -0
- package/dist/feeds/feed-stream-session.js +331 -0
- package/dist/feeds/feed-stream-session.js.map +1 -0
- package/dist/http/router.d.ts +10 -3
- package/dist/http/router.js +186 -184
- package/dist/http/router.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/jinaga-server.d.ts +7 -0
- package/dist/jinaga-server.js +19 -2
- 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/design/feeds-persistent-proxy-plan.md +11 -1
- 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 +106 -5
- package/src/feeds/feed-stream-session.ts +386 -0
- package/src/http/router.ts +212 -237
- package/src/index.ts +2 -1
- package/src/jinaga-server.ts +45 -5
- 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/intermediateProjectionFeedSpec.ts +189 -0
- package/test/authorization/subscriptionIntersectionSpec.ts +220 -0
- package/test/feeds/feedStreamSessionRegressionSpec.ts +227 -0
- package/test/feeds/feedStreamSessionSpec.ts +351 -0
- package/test/http/anchorListenerSpec.ts +117 -0
- package/test/http/feedStreamSessionIntegrationSpec.ts +202 -0
- package/test/http/lateAuthRecoverySpec.ts +435 -0
- package/test/http/streamFeedAnchorSpec.ts +171 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { buildModel, User } = require('jinaga');
|
|
3
|
+
const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
|
|
4
|
+
|
|
5
|
+
// Exercises a distribution rule whose user-spec uses notExists — the
|
|
6
|
+
// "negating-feed authorization (#196)" dimension the PR closes by
|
|
7
|
+
// having DistributionEngine.canDistributeTo fall back to
|
|
8
|
+
// canAuthorizeByComposition. We avoid the late-auth/intersection path
|
|
9
|
+
// here so the test is hitting canDistributeTo (via feedWithDistribution)
|
|
10
|
+
// directly with a negating user-spec.
|
|
11
|
+
|
|
12
|
+
jest.setTimeout(30000);
|
|
13
|
+
|
|
14
|
+
class Group {
|
|
15
|
+
static Type = "NegatingFeed.Group";
|
|
16
|
+
type = Group.Type;
|
|
17
|
+
constructor(creator, name) {
|
|
18
|
+
this.creator = creator;
|
|
19
|
+
this.name = name;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class Member {
|
|
24
|
+
static Type = "NegatingFeed.Member";
|
|
25
|
+
type = Member.Type;
|
|
26
|
+
constructor(group, user) {
|
|
27
|
+
this.group = group;
|
|
28
|
+
this.user = user;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class MemberRemoved {
|
|
33
|
+
static Type = "NegatingFeed.Member.Removed";
|
|
34
|
+
type = MemberRemoved.Type;
|
|
35
|
+
constructor(member) {
|
|
36
|
+
this.member = member;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class Content {
|
|
41
|
+
static Type = "NegatingFeed.Content";
|
|
42
|
+
type = Content.Type;
|
|
43
|
+
constructor(group, body) {
|
|
44
|
+
this.group = group;
|
|
45
|
+
this.body = body;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const model = buildModel(b => b
|
|
50
|
+
.type(User)
|
|
51
|
+
.type(Group, m => m.predecessor("creator", User))
|
|
52
|
+
.type(Member, m => m
|
|
53
|
+
.predecessor("group", Group)
|
|
54
|
+
.predecessor("user", User))
|
|
55
|
+
.type(MemberRemoved, m => m.predecessor("member", Member))
|
|
56
|
+
.type(Content, m => m.predecessor("group", Group))
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
function authorization(a) {
|
|
60
|
+
return a
|
|
61
|
+
.any(User)
|
|
62
|
+
.any(Group)
|
|
63
|
+
.any(Member)
|
|
64
|
+
.any(MemberRemoved)
|
|
65
|
+
.any(Content);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Share Content of a Group with users who are currently Members — i.e.
|
|
69
|
+
// have a Member fact with no MemberRemoved successor. The notExists
|
|
70
|
+
// here is what makes this a "negating-feed" user-spec.
|
|
71
|
+
function distribution(d) {
|
|
72
|
+
return d
|
|
73
|
+
.share(model.given(Group).match((g, facts) =>
|
|
74
|
+
facts.ofType(Content).join(c => c.group, g)
|
|
75
|
+
))
|
|
76
|
+
.with(model.given(Group).match((g, facts) =>
|
|
77
|
+
facts.ofType(Member)
|
|
78
|
+
.join(m => m.group, g)
|
|
79
|
+
.notExists(m => facts.ofType(MemberRemoved).join(r => r.member, m))
|
|
80
|
+
.selectMany(m => facts.ofType(User).join(u => u, m.user))
|
|
81
|
+
));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function contentSpecText(groupHash) {
|
|
85
|
+
return `let p1: ${Group.Type} = #${groupHash}\n` +
|
|
86
|
+
`(p1: ${Group.Type}) {\n` +
|
|
87
|
+
` c: ${Content.Type} [\n` +
|
|
88
|
+
` c->group: ${Group.Type} = p1\n` +
|
|
89
|
+
` ]\n` +
|
|
90
|
+
`} => c`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('Distribution rule with negating feed (notExists)', () => {
|
|
94
|
+
let app, withSession, close;
|
|
95
|
+
let groupValue, groupHash, contentHash;
|
|
96
|
+
let creatorId;
|
|
97
|
+
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
100
|
+
model, authorization, distribution
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
creatorId = 'group-creator-' + randomSuffix();
|
|
104
|
+
({ groupHash, contentHash } = await asUser(withSession, creatorId, async (creatorJ) => {
|
|
105
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
106
|
+
const group = await creatorJ.fact(new Group(creatorFact, 'g-' + randomSuffix()));
|
|
107
|
+
groupValue = group;
|
|
108
|
+
const content = await creatorJ.fact(new Content(group, 'shared body'));
|
|
109
|
+
return {
|
|
110
|
+
groupHash: creatorJ.hash(group),
|
|
111
|
+
contentHash: creatorJ.hash(content)
|
|
112
|
+
};
|
|
113
|
+
}));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(async () => {
|
|
117
|
+
if (close) await close();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('delivers content to a current member', async () => {
|
|
121
|
+
const memberId = 'group-member-' + randomSuffix();
|
|
122
|
+
const memberFact = await asUser(withSession, memberId, async (j) => {
|
|
123
|
+
const { userFact } = await j.login();
|
|
124
|
+
return userFact;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Grant membership BEFORE subscribing so the distribution check
|
|
128
|
+
// (notExists removed → current member) passes and we never hit
|
|
129
|
+
// intersection. This makes the test target canDistributeTo
|
|
130
|
+
// directly via feedWithDistribution.
|
|
131
|
+
await asUser(withSession, creatorId, async (j) => {
|
|
132
|
+
await j.fact(new Member(groupValue, memberFact));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const feedsResponse = await request(app)
|
|
136
|
+
.post('/feeds')
|
|
137
|
+
.set('Content-Type', 'text/plain')
|
|
138
|
+
.set('x-test-user-id', memberId)
|
|
139
|
+
.send(contentSpecText(groupHash));
|
|
140
|
+
expect(feedsResponse.status).toBe(200);
|
|
141
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
142
|
+
|
|
143
|
+
const pollResponse = await request(app)
|
|
144
|
+
.get(`/feeds/${feeds[0]}`)
|
|
145
|
+
.set('x-test-user-id', memberId);
|
|
146
|
+
expect(pollResponse.status).toBe(200);
|
|
147
|
+
const body = JSON.parse(pollResponse.text);
|
|
148
|
+
const visible = (body.references || []).filter(r => r.type === Content.Type);
|
|
149
|
+
expect(visible.map(r => r.hash)).toContain(contentHash);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('hides content from a removed member', async () => {
|
|
153
|
+
const memberId = 'group-removed-' + randomSuffix();
|
|
154
|
+
const memberFact = await asUser(withSession, memberId, async (j) => {
|
|
155
|
+
const { userFact } = await j.login();
|
|
156
|
+
return userFact;
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Add then remove the membership. The notExists branch flips,
|
|
160
|
+
// canDistributeTo denies, and the poll returns an empty page.
|
|
161
|
+
let memberValue;
|
|
162
|
+
await asUser(withSession, creatorId, async (j) => {
|
|
163
|
+
memberValue = await j.fact(new Member(groupValue, memberFact));
|
|
164
|
+
await j.fact(new MemberRemoved(memberValue));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const feedsResponse = await request(app)
|
|
168
|
+
.post('/feeds')
|
|
169
|
+
.set('Content-Type', 'text/plain')
|
|
170
|
+
.set('x-test-user-id', memberId)
|
|
171
|
+
.send(contentSpecText(groupHash));
|
|
172
|
+
expect(feedsResponse.status).toBe(200);
|
|
173
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
174
|
+
|
|
175
|
+
const pollResponse = await request(app)
|
|
176
|
+
.get(`/feeds/${feeds[0]}`)
|
|
177
|
+
.set('x-test-user-id', memberId);
|
|
178
|
+
expect(pollResponse.status).toBe(200);
|
|
179
|
+
const body = JSON.parse(pollResponse.text);
|
|
180
|
+
expect((body.references || []).filter(r => r.type === Content.Type)).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('hides content from a never-member', async () => {
|
|
184
|
+
const outsiderId = 'group-outsider-' + randomSuffix();
|
|
185
|
+
await asUser(withSession, outsiderId, async (j) => { /* login only */ });
|
|
186
|
+
|
|
187
|
+
const feedsResponse = await request(app)
|
|
188
|
+
.post('/feeds')
|
|
189
|
+
.set('Content-Type', 'text/plain')
|
|
190
|
+
.set('x-test-user-id', outsiderId)
|
|
191
|
+
.send(contentSpecText(groupHash));
|
|
192
|
+
expect(feedsResponse.status).toBe(200);
|
|
193
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
194
|
+
|
|
195
|
+
const pollResponse = await request(app)
|
|
196
|
+
.get(`/feeds/${feeds[0]}`)
|
|
197
|
+
.set('x-test-user-id', outsiderId);
|
|
198
|
+
expect(pollResponse.status).toBe(200);
|
|
199
|
+
const body = JSON.parse(pollResponse.text);
|
|
200
|
+
expect((body.references || []).filter(r => r.type === Content.Type)).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -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.3",
|
|
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.3",
|
|
3199
|
+
"resolved": "https://registry.npmjs.org/jinaga/-/jinaga-6.8.3.tgz",
|
|
3200
|
+
"integrity": "sha512-v3dvRQKlXj++uT9D/AxFfRIeKwzitqYAs9Fxx5Q+wbCyYM+wHv4eRH9qCRcOIjv/jnl8iO/3vGGFZz+z2eMQgw==",
|
|
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.3",
|
|
22
23
|
"node-forge": "^1.3.0",
|
|
23
24
|
"pg": "^8.13.1"
|
|
24
25
|
},
|