jinaga-server 3.5.5 → 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/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/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,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
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { buildModel, User } = require('jinaga');
|
|
3
|
+
const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
|
|
4
|
+
|
|
5
|
+
class Company {
|
|
6
|
+
static Type = "LateAuth.Company";
|
|
7
|
+
type = Company.Type;
|
|
8
|
+
constructor(creator, identifier) {
|
|
9
|
+
this.creator = creator;
|
|
10
|
+
this.identifier = identifier;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class Office {
|
|
15
|
+
static Type = "LateAuth.Office";
|
|
16
|
+
type = Office.Type;
|
|
17
|
+
constructor(company, name) {
|
|
18
|
+
this.company = company;
|
|
19
|
+
this.name = name;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class Administrator {
|
|
24
|
+
static Type = "LateAuth.Administrator";
|
|
25
|
+
type = Administrator.Type;
|
|
26
|
+
constructor(company, user, createdAt) {
|
|
27
|
+
this.company = company;
|
|
28
|
+
this.user = user;
|
|
29
|
+
this.createdAt = createdAt;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const model = buildModel(b => b
|
|
34
|
+
.type(User)
|
|
35
|
+
.type(Company, m => m.predecessor("creator", User))
|
|
36
|
+
.type(Office, m => m.predecessor("company", Company))
|
|
37
|
+
.type(Administrator, m => m
|
|
38
|
+
.predecessor("company", Company)
|
|
39
|
+
.predecessor("user", User))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
function authorization(a) {
|
|
43
|
+
return a
|
|
44
|
+
.any(User)
|
|
45
|
+
.any(Company)
|
|
46
|
+
.any(Office)
|
|
47
|
+
.any(Administrator);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function distribution(d) {
|
|
51
|
+
return d
|
|
52
|
+
.share(model.given(Company).match((c, facts) =>
|
|
53
|
+
facts.ofType(Office).join(o => o.company, c)
|
|
54
|
+
))
|
|
55
|
+
.with(model.given(Company).match((c, facts) =>
|
|
56
|
+
facts.ofType(Administrator)
|
|
57
|
+
.join(a => a.company, c)
|
|
58
|
+
.selectMany(a => facts.ofType(User).join(u => u, a.user))
|
|
59
|
+
));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function officeSpecText(companyHash) {
|
|
63
|
+
// The `p1` label name mirrors what `model.given(Company)` produces
|
|
64
|
+
// so the distribution engine can lift the rule's path conditions onto
|
|
65
|
+
// the subscription's given during intersectForSubscribe.
|
|
66
|
+
return `let p1: ${Company.Type} = #${companyHash}\n` +
|
|
67
|
+
`(p1: ${Company.Type}) {\n` +
|
|
68
|
+
` o: ${Office.Type} [\n` +
|
|
69
|
+
` o->company: ${Company.Type} = p1\n` +
|
|
70
|
+
` ]\n` +
|
|
71
|
+
`} => o`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
jest.setTimeout(30000);
|
|
75
|
+
|
|
76
|
+
describe('Subscription late-auth recovery', () => {
|
|
77
|
+
let app, withSession, close;
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
81
|
+
model, authorization, distribution
|
|
82
|
+
}));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(async () => {
|
|
86
|
+
if (close) await close();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('accepts the subscription with an empty page when the subscriber is not yet authorized', async () => {
|
|
90
|
+
const { companyHash } = await asUser(withSession, 'creator-1-' + randomSuffix(), async (creatorJ) => {
|
|
91
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
92
|
+
const company = await creatorJ.fact(new Company(creatorFact, 'Acme-' + randomSuffix()));
|
|
93
|
+
await creatorJ.fact(new Office(company, 'HQ'));
|
|
94
|
+
return { companyHash: creatorJ.hash(company) };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const subscriberId = 'subscriber-1-' + randomSuffix();
|
|
98
|
+
await asUser(withSession, subscriberId, async (j) => { await j.login(); });
|
|
99
|
+
|
|
100
|
+
// POST /feeds must NOT 403: distribution rule denies the subscriber,
|
|
101
|
+
// but the server intersects the spec so the subscription can be
|
|
102
|
+
// activated once the authorizing fact arrives.
|
|
103
|
+
const feedsResponse = await request(app)
|
|
104
|
+
.post('/feeds')
|
|
105
|
+
.set('Content-Type', 'text/plain')
|
|
106
|
+
.set('x-test-user-id', subscriberId)
|
|
107
|
+
.send(officeSpecText(companyHash));
|
|
108
|
+
|
|
109
|
+
expect(feedsResponse.status).toBe(200);
|
|
110
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
111
|
+
expect(Array.isArray(feeds)).toBe(true);
|
|
112
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
113
|
+
|
|
114
|
+
// Polling the cached hash returns an empty page — the pre-existing
|
|
115
|
+
// Office is hidden until the lifted auth condition is satisfied.
|
|
116
|
+
const pollResponse = await request(app)
|
|
117
|
+
.get(`/feeds/${feeds[0]}`)
|
|
118
|
+
.set('x-test-user-id', subscriberId);
|
|
119
|
+
|
|
120
|
+
expect(pollResponse.status).toBe(200);
|
|
121
|
+
const body = JSON.parse(pollResponse.text);
|
|
122
|
+
const officeRefs = (body.references || []).filter(r => r.type === Office.Type);
|
|
123
|
+
expect(officeRefs).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('surfaces existing offices once the authorizing fact arrives', async () => {
|
|
127
|
+
let companyValue;
|
|
128
|
+
let creatorFactValue;
|
|
129
|
+
const creatorId = 'creator-2-' + randomSuffix();
|
|
130
|
+
const { companyHash, officeHash } = await asUser(withSession, creatorId, async (creatorJ) => {
|
|
131
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
132
|
+
creatorFactValue = creatorFact;
|
|
133
|
+
const company = await creatorJ.fact(new Company(creatorFact, 'Acme-' + randomSuffix()));
|
|
134
|
+
companyValue = company;
|
|
135
|
+
const office = await creatorJ.fact(new Office(company, 'HQ'));
|
|
136
|
+
return {
|
|
137
|
+
companyHash: creatorJ.hash(company),
|
|
138
|
+
officeHash: creatorJ.hash(office)
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const subscriberId = 'subscriber-2-' + randomSuffix();
|
|
143
|
+
const subscriberFact = await asUser(withSession, subscriberId, async (j) => {
|
|
144
|
+
const { userFact } = await j.login();
|
|
145
|
+
return userFact;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Subscribe BEFORE the authorizing fact exists so the cached hash is
|
|
149
|
+
// an intersected (lifted) spec — feedPreVerified will then serve it.
|
|
150
|
+
const feedsResponse = await request(app)
|
|
151
|
+
.post('/feeds')
|
|
152
|
+
.set('Content-Type', 'text/plain')
|
|
153
|
+
.set('x-test-user-id', subscriberId)
|
|
154
|
+
.send(officeSpecText(companyHash));
|
|
155
|
+
expect(feedsResponse.status).toBe(200);
|
|
156
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
157
|
+
const feedHash = feeds[0];
|
|
158
|
+
|
|
159
|
+
// Initial poll: empty (lifted condition not yet satisfied).
|
|
160
|
+
const firstPoll = await request(app)
|
|
161
|
+
.get(`/feeds/${feedHash}`)
|
|
162
|
+
.set('x-test-user-id', subscriberId);
|
|
163
|
+
expect(firstPoll.status).toBe(200);
|
|
164
|
+
const firstBody = JSON.parse(firstPoll.text);
|
|
165
|
+
expect((firstBody.references || []).filter(r => r.type === Office.Type)).toHaveLength(0);
|
|
166
|
+
|
|
167
|
+
// Creator grants the subscriber the Administrator role.
|
|
168
|
+
await asUser(withSession, creatorId, async (creatorJ) => {
|
|
169
|
+
await creatorJ.fact(new Administrator(companyValue, subscriberFact, new Date('2026-05-27')));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Re-poll without propagating the prior bookmark. The intersected
|
|
173
|
+
// spec is 2-given, and the bookmark-extended SQL path currently
|
|
174
|
+
// generates a WHERE constraint on the second given without a
|
|
175
|
+
// corresponding JOIN (a separate PostgresStore bug). Re-querying
|
|
176
|
+
// from the beginning sidesteps that and still validates the
|
|
177
|
+
// contract — the office surfaces once the lifted condition holds.
|
|
178
|
+
const secondPoll = await request(app)
|
|
179
|
+
.get(`/feeds/${feedHash}`)
|
|
180
|
+
.set('x-test-user-id', subscriberId);
|
|
181
|
+
expect(secondPoll.status).toBe(200);
|
|
182
|
+
const secondBody = JSON.parse(secondPoll.text);
|
|
183
|
+
const officeRefs = (secondBody.references || []).filter(r => r.type === Office.Type);
|
|
184
|
+
expect(officeRefs.map(r => r.hash)).toContain(officeHash);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('does not let a different authenticated user reuse an intersected feed hash', async () => {
|
|
188
|
+
let companyValue;
|
|
189
|
+
const creatorId = 'creator-3-' + randomSuffix();
|
|
190
|
+
const { companyHash } = await asUser(withSession, creatorId, async (creatorJ) => {
|
|
191
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
192
|
+
const company = await creatorJ.fact(new Company(creatorFact, 'Acme-' + randomSuffix()));
|
|
193
|
+
companyValue = company;
|
|
194
|
+
await creatorJ.fact(new Office(company, 'HQ'));
|
|
195
|
+
return { companyHash: creatorJ.hash(company) };
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const aliceId = 'alice-3-' + randomSuffix();
|
|
199
|
+
const aliceFact = await asUser(withSession, aliceId, async (j) => {
|
|
200
|
+
const { userFact } = await j.login();
|
|
201
|
+
return userFact;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Mallory must exist in the keystore for the polling auth path to
|
|
205
|
+
// resolve her user fact during the distribution check.
|
|
206
|
+
const malloryId = 'mallory-3-' + randomSuffix();
|
|
207
|
+
await asUser(withSession, malloryId, async (j) => { await j.login(); });
|
|
208
|
+
|
|
209
|
+
// Alice subscribes while NOT yet an admin so the cached hash is the
|
|
210
|
+
// intersected (Alice-owned) spec.
|
|
211
|
+
const feedsResponse = await request(app)
|
|
212
|
+
.post('/feeds')
|
|
213
|
+
.set('Content-Type', 'text/plain')
|
|
214
|
+
.set('x-test-user-id', aliceId)
|
|
215
|
+
.send(officeSpecText(companyHash));
|
|
216
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
217
|
+
const intersectedHash = feeds[0];
|
|
218
|
+
|
|
219
|
+
// Now grant Alice the Administrator role so her intersected spec
|
|
220
|
+
// actually produces rows.
|
|
221
|
+
await asUser(withSession, creatorId, async (creatorJ) => {
|
|
222
|
+
await creatorJ.fact(new Administrator(companyValue, aliceFact, new Date('2026-05-27')));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Alice can read her own intersected feed and sees the office.
|
|
226
|
+
const alicePoll = await request(app)
|
|
227
|
+
.get(`/feeds/${intersectedHash}`)
|
|
228
|
+
.set('x-test-user-id', aliceId);
|
|
229
|
+
expect(alicePoll.status).toBe(200);
|
|
230
|
+
const aliceBody = JSON.parse(alicePoll.text);
|
|
231
|
+
expect((aliceBody.references || []).some(r => r.type === Office.Type)).toBe(true);
|
|
232
|
+
|
|
233
|
+
// Mallory polls the same hash. The router routes her through the
|
|
234
|
+
// normal distribution check (cached owner is Alice, not Mallory),
|
|
235
|
+
// which denies her — the polling path returns an empty page rather
|
|
236
|
+
// than leaking Alice's data.
|
|
237
|
+
const malloryPoll = await request(app)
|
|
238
|
+
.get(`/feeds/${intersectedHash}`)
|
|
239
|
+
.set('x-test-user-id', malloryId);
|
|
240
|
+
expect(malloryPoll.status).toBe(200);
|
|
241
|
+
const malloryBody = JSON.parse(malloryPoll.text);
|
|
242
|
+
const malloryOffices = (malloryBody.references || []).filter(r => r.type === Office.Type);
|
|
243
|
+
expect(malloryOffices).toHaveLength(0);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const request = require('supertest');
|
|
2
|
+
const { buildModel, User } = require('jinaga');
|
|
3
|
+
const { createSubscriptionApp, asUser, randomSuffix } = require('./subscription-helpers');
|
|
4
|
+
|
|
5
|
+
// Validates the "keep subscriptions alive across distribution failures"
|
|
6
|
+
// contract: when no distribution rule covers the requested spec, the
|
|
7
|
+
// /feeds endpoint accepts the subscription and polling returns an empty
|
|
8
|
+
// page rather than 403. /read still 403s because reads are one-shot.
|
|
9
|
+
|
|
10
|
+
class Company {
|
|
11
|
+
static Type = "NoRule.Company";
|
|
12
|
+
type = Company.Type;
|
|
13
|
+
constructor(creator, identifier) {
|
|
14
|
+
this.creator = creator;
|
|
15
|
+
this.identifier = identifier;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class Office {
|
|
20
|
+
static Type = "NoRule.Office";
|
|
21
|
+
type = Office.Type;
|
|
22
|
+
constructor(company, name) {
|
|
23
|
+
this.company = company;
|
|
24
|
+
this.name = name;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class Administrator {
|
|
29
|
+
static Type = "NoRule.Administrator";
|
|
30
|
+
type = Administrator.Type;
|
|
31
|
+
constructor(company, user, createdAt) {
|
|
32
|
+
this.company = company;
|
|
33
|
+
this.user = user;
|
|
34
|
+
this.createdAt = createdAt;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const model = buildModel(b => b
|
|
39
|
+
.type(User)
|
|
40
|
+
.type(Company, m => m.predecessor("creator", User))
|
|
41
|
+
.type(Office, m => m.predecessor("company", Company))
|
|
42
|
+
.type(Administrator, m => m
|
|
43
|
+
.predecessor("company", Company)
|
|
44
|
+
.predecessor("user", User))
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function authorization(a) {
|
|
48
|
+
return a
|
|
49
|
+
.any(User)
|
|
50
|
+
.any(Company)
|
|
51
|
+
.any(Office)
|
|
52
|
+
.any(Administrator);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Distribution covers Office only. Administrator queries fall outside
|
|
56
|
+
// any rule.
|
|
57
|
+
function distribution(d) {
|
|
58
|
+
return d
|
|
59
|
+
.share(model.given(Company).match((c, facts) =>
|
|
60
|
+
facts.ofType(Office).join(o => o.company, c)
|
|
61
|
+
))
|
|
62
|
+
.with(model.given(Company).match((c, facts) =>
|
|
63
|
+
facts.ofType(Administrator)
|
|
64
|
+
.join(a => a.company, c)
|
|
65
|
+
.selectMany(a => facts.ofType(User).join(u => u, a.user))
|
|
66
|
+
));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function administratorSpecText(companyHash) {
|
|
70
|
+
return `let p1: ${Company.Type} = #${companyHash}\n` +
|
|
71
|
+
`(p1: ${Company.Type}) {\n` +
|
|
72
|
+
` a: ${Administrator.Type} [\n` +
|
|
73
|
+
` a->company: ${Company.Type} = p1\n` +
|
|
74
|
+
` ]\n` +
|
|
75
|
+
`} => a`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('Subscription when no distribution rule applies', () => {
|
|
79
|
+
let app, withSession, close;
|
|
80
|
+
let companyHash;
|
|
81
|
+
let subscriberId;
|
|
82
|
+
|
|
83
|
+
beforeEach(async () => {
|
|
84
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
85
|
+
model, authorization, distribution
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const creatorId = 'creator-norule-' + randomSuffix();
|
|
89
|
+
({ companyHash } = await asUser(withSession, creatorId, async (creatorJ) => {
|
|
90
|
+
const { userFact: creatorFact } = await creatorJ.login();
|
|
91
|
+
const company = await creatorJ.fact(new Company(creatorFact, 'Acme-' + randomSuffix()));
|
|
92
|
+
return { companyHash: creatorJ.hash(company) };
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
subscriberId = 'subscriber-norule-' + randomSuffix();
|
|
96
|
+
await asUser(withSession, subscriberId, async (j) => { await j.login(); });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(async () => {
|
|
100
|
+
if (close) await close();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('accepts /feeds and returns an empty page on poll', async () => {
|
|
104
|
+
// No rule covers Administrator. intersectForSubscribe also has
|
|
105
|
+
// nothing to intersect against. The contract is: /feeds succeeds,
|
|
106
|
+
// polling returns an empty page so the client keeps waiting.
|
|
107
|
+
const feedsResponse = await request(app)
|
|
108
|
+
.post('/feeds')
|
|
109
|
+
.set('Content-Type', 'text/plain')
|
|
110
|
+
.set('x-test-user-id', subscriberId)
|
|
111
|
+
.send(administratorSpecText(companyHash));
|
|
112
|
+
|
|
113
|
+
expect(feedsResponse.status).toBe(200);
|
|
114
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
115
|
+
expect(Array.isArray(feeds)).toBe(true);
|
|
116
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
117
|
+
|
|
118
|
+
const pollResponse = await request(app)
|
|
119
|
+
.get(`/feeds/${feeds[0]}`)
|
|
120
|
+
.set('x-test-user-id', subscriberId);
|
|
121
|
+
expect(pollResponse.status).toBe(200);
|
|
122
|
+
const body = JSON.parse(pollResponse.text);
|
|
123
|
+
expect(body.references || []).toEqual([]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('still returns 403 from /read for the same spec', async () => {
|
|
127
|
+
// Reads are one-shot — there is no "wait until authorized later"
|
|
128
|
+
// semantic, so Forbidden propagates as 403.
|
|
129
|
+
const readResponse = await request(app)
|
|
130
|
+
.post('/read')
|
|
131
|
+
.set('Content-Type', 'text/plain')
|
|
132
|
+
.set('x-test-user-id', subscriberId)
|
|
133
|
+
.send(administratorSpecText(companyHash));
|
|
134
|
+
|
|
135
|
+
expect(readResponse.status).toBe(403);
|
|
136
|
+
});
|
|
137
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jinaga-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "Data management for web and mobile applications.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"csv-stringify": "^6.6.0",
|
|
51
51
|
"express": "^4.21.2",
|
|
52
|
-
"jinaga": "^6.
|
|
52
|
+
"jinaga": "^6.8.0",
|
|
53
53
|
"pg": "^8.13.1"
|
|
54
54
|
}
|
|
55
55
|
}
|