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,191 @@
|
|
|
1
|
+
# Bug Analysis: Feed Builder Strips Inner Negative Existential Conditions
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-30
|
|
4
|
+
**Area:** `jinaga` package — `feed-builder.js` / `specification-sql.ts`
|
|
5
|
+
**Symptom:** EventName facts not delivered to clients for events that have been deleted and restored
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Observed Symptom
|
|
10
|
+
|
|
11
|
+
The LaunchKings admin portal Events dropdown shows the raw fact hash prefix (e.g. `65bc55e`) instead of the event's human-readable name ("LATAM test"). The `CodeLaunch.Event.Name` fact exists in the database and the distribution rules authorize it, but it is never delivered to the client via feeds.
|
|
12
|
+
|
|
13
|
+
## Database State That Triggers the Bug
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
CodeLaunch.Event 1 fact — id: 65bc55e9-495a-4418-9744-3dea6f8c8f2a
|
|
17
|
+
CodeLaunch.Event.Name 1 fact — value: "LATAM test" (EXISTS — not missing)
|
|
18
|
+
CodeLaunch.Event.Delete 2 facts — deleted twice
|
|
19
|
+
CodeLaunch.Event.Restore 2 facts — restored twice → event is currently active
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The event is logically active (every delete has a matching restore), but its presence of any `EventDelete` facts triggers the bug.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Root Cause
|
|
27
|
+
|
|
28
|
+
### The Specification
|
|
29
|
+
|
|
30
|
+
The `eventsListInTenant` specification uses `Event.in(tenant)`:
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
static in(tenant: LabelOf<Tenant>) {
|
|
34
|
+
return tenant.successors(Event, event => event.tenant)
|
|
35
|
+
.notExists(event =>
|
|
36
|
+
event.successors(EventDelete, d => d.event)
|
|
37
|
+
.notExists(d =>
|
|
38
|
+
d.successors(EventRestore, r => r.eventDelete)
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The full `!E` condition is:
|
|
45
|
+
```
|
|
46
|
+
!E { EventDelete [ u2->event = u1, !E { EventRestore [ u3->eventDelete = u2 ] } ] }
|
|
47
|
+
```
|
|
48
|
+
Meaning: *no un-restored EventDelete exists for this event*.
|
|
49
|
+
|
|
50
|
+
### What `buildFeeds` Produces
|
|
51
|
+
|
|
52
|
+
`buildFeeds` calls `buildExistentialCondition` to create a simplified version of negative existential conditions for use in feed specifications. The relevant code:
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
// feed-builder.js — buildExistentialCondition()
|
|
56
|
+
for (const match of matches) {
|
|
57
|
+
existentialCondition = {
|
|
58
|
+
...existentialCondition,
|
|
59
|
+
matches: [...existentialCondition.matches, {
|
|
60
|
+
...match,
|
|
61
|
+
conditions: match.conditions.filter(isPathCondition) // strips ALL existential conditions
|
|
62
|
+
}]
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (const innerExistentialCondition of match.conditions.filter(isExistentialCondition)) {
|
|
66
|
+
if (innerExistentialCondition.exists) {
|
|
67
|
+
// ✓ positive inner existentials are recursively included
|
|
68
|
+
}
|
|
69
|
+
// ✗ negative inner existentials (exists=false) are silently dropped
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The inner `!E{EventRestore}` condition has `exists=false` and is therefore **dropped**. The simplified condition produced is:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
!E { EventDelete [ u2->event = u1 ] }
|
|
78
|
+
```
|
|
79
|
+
Meaning: *no EventDelete at all*.
|
|
80
|
+
|
|
81
|
+
### The Five Feeds for `eventsListInTenant`
|
|
82
|
+
|
|
83
|
+
`buildFeeds` produces these feeds:
|
|
84
|
+
|
|
85
|
+
| Feed | Purpose | Condition on Event |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| FeedA | Detect EventRestore arrivals | none (bare join) |
|
|
88
|
+
| FeedB | Detect EventDelete arrivals | none (bare join) |
|
|
89
|
+
| FeedC | Deliver Event facts | `!E{EventDelete}` ← simplified |
|
|
90
|
+
| FeedD | Detect EventName supersession | `!E{EventDelete}` ← inherited |
|
|
91
|
+
| FeedE | **Deliver EventName facts** | `!E{EventDelete}` ← **BUG** |
|
|
92
|
+
|
|
93
|
+
FeedE inherits FeedC's simplified condition via `addProjections(FeedC, ...)`. FeedE's SQL is therefore:
|
|
94
|
+
|
|
95
|
+
```sql
|
|
96
|
+
SELECT f_name.hash, sort(array[f_name.fact_id], 'desc') as bookmark
|
|
97
|
+
FROM fact f_tenant
|
|
98
|
+
JOIN edge e1 ON e1.predecessor_fact_id = f_tenant.fact_id AND e1.role_id = $event_tenant_role
|
|
99
|
+
JOIN fact f_event ON f_event.fact_id = e1.successor_fact_id
|
|
100
|
+
JOIN edge e2 ON e2.predecessor_fact_id = f_event.fact_id AND e2.role_id = $eventname_event_role
|
|
101
|
+
JOIN fact f_name ON f_name.fact_id = e2.successor_fact_id
|
|
102
|
+
WHERE f_tenant.fact_type_id = $tenant_type AND f_tenant.hash = $tenant_hash
|
|
103
|
+
AND NOT EXISTS (
|
|
104
|
+
SELECT 1 FROM edge e_del
|
|
105
|
+
WHERE e_del.predecessor_fact_id = f_event.fact_id
|
|
106
|
+
AND e_del.role_id = $eventdelete_event_role
|
|
107
|
+
-- ↑ checks for ANY EventDelete, not just un-restored ones
|
|
108
|
+
)
|
|
109
|
+
AND NOT EXISTS (
|
|
110
|
+
SELECT 1 FROM edge e_prior
|
|
111
|
+
WHERE e_prior.predecessor_fact_id = f_name.fact_id
|
|
112
|
+
AND e_prior.role_id = $eventname_prior_role
|
|
113
|
+
)
|
|
114
|
+
AND sort(array[f_name.fact_id], 'desc') > $bookmark
|
|
115
|
+
ORDER BY bookmark ASC LIMIT 100
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Because the "LATAM test" event has `EventDelete` facts (regardless of `EventRestore`), the `NOT EXISTS (EventDelete)` clause is false, the event is excluded, and **the query returns zero rows**.
|
|
119
|
+
|
|
120
|
+
### Why the Listener Doesn't Rescue It
|
|
121
|
+
|
|
122
|
+
The observer also registers inverse specification listeners. When FeedA delivers `EventRestore` facts, the listener fires and calls `notifyAdded` — which reads EventName from the **local IndexedDB store**. Because FeedE never delivered EventName to the local store, the re-evaluation finds nothing, and names remain absent.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Impact
|
|
127
|
+
|
|
128
|
+
Any event that has ever been deleted (even if subsequently restored) will have its name and other projection facts permanently withheld from all projection feeds, while the base `Event` fact itself still appears (for example via FeedC). This affects:
|
|
129
|
+
|
|
130
|
+
- `CodeLaunch.Event.Name` — event name not shown in nav dropdown
|
|
131
|
+
- `CodeLaunch.Event.Date` — event dates not shown
|
|
132
|
+
- Any other projected facts on events from `eventsListInTenant` or similar specs
|
|
133
|
+
|
|
134
|
+
In this environment the event was deleted and restored twice, making it the only event and confirming 100% reproduction.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## The Fix
|
|
139
|
+
|
|
140
|
+
### Option A — Support Nested NOT EXISTS in Feed SQL (Correct but Complex)
|
|
141
|
+
|
|
142
|
+
Extend `buildExistentialCondition` to recursively include negative inner existential conditions:
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
// feed-builder.js — buildExistentialCondition()
|
|
146
|
+
for (const innerExistentialCondition of match.conditions.filter(isExistentialCondition)) {
|
|
147
|
+
if (innerExistentialCondition.exists) {
|
|
148
|
+
// existing logic — keep positive existentials
|
|
149
|
+
const { existentialCondition: newEC, ... } = buildExistentialCondition(...);
|
|
150
|
+
existentialCondition = newEC;
|
|
151
|
+
} else {
|
|
152
|
+
// NEW: also recurse into negative existentials
|
|
153
|
+
const { existentialCondition: newEC, ... } = buildExistentialCondition(
|
|
154
|
+
innerExistentialCondition, innerExistentialCondition.matches, ...
|
|
155
|
+
);
|
|
156
|
+
existentialCondition = { ...existentialCondition, innerConditions: [..., newEC] };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This also requires:
|
|
162
|
+
1. Adding a `innerConditions` (or `notExistsConditions`) field to `ExistentialConditionDescription`
|
|
163
|
+
2. Updating `generateNotExistsWhereClause` in `specification-sql.ts` to recurse into nested conditions
|
|
164
|
+
3. Updating `notExistsConditionsEqual` in `distribution-engine.ts` if nested conditions need to be compared
|
|
165
|
+
|
|
166
|
+
### Option B — Omit Outer Not-Exists from Projection Feeds (Simpler)
|
|
167
|
+
|
|
168
|
+
When `addProjections` builds projection feeds (FeedD, FeedE), start from a version of the specification that **drops all not-exists conditions on the base matches**. Projection feeds would then return facts for all events (active or not), and the full specification read handles the correct filtering.
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
// feed-builder.js — buildFeeds()
|
|
172
|
+
const baseFeed = stripNotExistsConditions(finalFeed); // new helper
|
|
173
|
+
const feedsWithProjections = addProjections(baseFeed, unusedGivens, specification.projection.components);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
This is safe because:
|
|
177
|
+
- FeedA and FeedB already handle re-evaluation when delete/restore facts arrive
|
|
178
|
+
- The actual filtering is done by `factManager.read()` using the full specification
|
|
179
|
+
- Feeds are designed to be a **superset** of relevant facts; over-delivery is intentional
|
|
180
|
+
|
|
181
|
+
Option B is simpler, has no SQL changes, and aligns with the documented design principle that "feeds are not executed with nested existential conditions."
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Files Involved
|
|
186
|
+
|
|
187
|
+
| File | Role |
|
|
188
|
+
|---|---|
|
|
189
|
+
| `jinaga/src/specification/feed-builder.ts` | `buildExistentialCondition` — drops inner `!E` conditions |
|
|
190
|
+
| `src/postgres/specification-sql.ts` | `generateNotExistsWhereClause` — only handles flat conditions |
|
|
191
|
+
| `jinaga/src/distribution/distribution-engine.ts` | `notExistsConditionsEqual` — explicitly skips nested comparison |
|
|
@@ -0,0 +1,184 @@
|
|
|
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 share/with specs have multiple
|
|
6
|
+
// `given` labels — the "multi-given distribution rule (#161)" dimension
|
|
7
|
+
// the PR claims is closed by canAuthorizeByComposition.
|
|
8
|
+
//
|
|
9
|
+
// The model intentionally pulls BOTH givens into every match clause
|
|
10
|
+
// (Task has predecessors on Tenant *and* Project, ProjectAccess has
|
|
11
|
+
// predecessors on Tenant, Project, and User). This avoids a separate
|
|
12
|
+
// PostgresStore SQL-builder bug that surfaces when a given is referenced
|
|
13
|
+
// only in a WHERE clause with no JOIN — that issue is unrelated to the
|
|
14
|
+
// distribution-engine work in PR #163 and is worth a dedicated bug
|
|
15
|
+
// report rather than masking here.
|
|
16
|
+
|
|
17
|
+
class Tenant {
|
|
18
|
+
static Type = "MultiGiven.Tenant";
|
|
19
|
+
type = Tenant.Type;
|
|
20
|
+
constructor(creator, identifier) {
|
|
21
|
+
this.creator = creator;
|
|
22
|
+
this.identifier = identifier;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class Project {
|
|
27
|
+
static Type = "MultiGiven.Project";
|
|
28
|
+
type = Project.Type;
|
|
29
|
+
constructor(tenant, identifier) {
|
|
30
|
+
this.tenant = tenant;
|
|
31
|
+
this.identifier = identifier;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class Task {
|
|
36
|
+
static Type = "MultiGiven.Task";
|
|
37
|
+
type = Task.Type;
|
|
38
|
+
constructor(tenant, project, description) {
|
|
39
|
+
this.tenant = tenant;
|
|
40
|
+
this.project = project;
|
|
41
|
+
this.description = description;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class ProjectAccess {
|
|
46
|
+
static Type = "MultiGiven.ProjectAccess";
|
|
47
|
+
type = ProjectAccess.Type;
|
|
48
|
+
constructor(tenant, project, user) {
|
|
49
|
+
this.tenant = tenant;
|
|
50
|
+
this.project = project;
|
|
51
|
+
this.user = user;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const model = buildModel(b => b
|
|
56
|
+
.type(User)
|
|
57
|
+
.type(Tenant, m => m.predecessor("creator", User))
|
|
58
|
+
.type(Project, m => m.predecessor("tenant", Tenant))
|
|
59
|
+
.type(Task, m => m
|
|
60
|
+
.predecessor("tenant", Tenant)
|
|
61
|
+
.predecessor("project", Project))
|
|
62
|
+
.type(ProjectAccess, m => m
|
|
63
|
+
.predecessor("tenant", Tenant)
|
|
64
|
+
.predecessor("project", Project)
|
|
65
|
+
.predecessor("user", User))
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
function authorization(a) {
|
|
69
|
+
return a
|
|
70
|
+
.any(User)
|
|
71
|
+
.any(Tenant)
|
|
72
|
+
.any(Project)
|
|
73
|
+
.any(Task)
|
|
74
|
+
.any(ProjectAccess);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Both share and with use a 2-given (Tenant, Project) and reference
|
|
78
|
+
// both labels in their match clauses.
|
|
79
|
+
function distribution(d) {
|
|
80
|
+
return d
|
|
81
|
+
.share(model.given(Tenant, Project).match((tenant, project, facts) =>
|
|
82
|
+
facts.ofType(Task)
|
|
83
|
+
.join(t => t.tenant, tenant)
|
|
84
|
+
.join(t => t.project, project)
|
|
85
|
+
))
|
|
86
|
+
.with(model.given(Tenant, Project).match((tenant, project, facts) =>
|
|
87
|
+
facts.ofType(ProjectAccess)
|
|
88
|
+
.join(a => a.tenant, tenant)
|
|
89
|
+
.join(a => a.project, project)
|
|
90
|
+
.selectMany(a => facts.ofType(User).join(u => u, a.user))
|
|
91
|
+
));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function taskSpecText(tenantHash, projectHash) {
|
|
95
|
+
return `let p1: ${Tenant.Type} = #${tenantHash}\n` +
|
|
96
|
+
`let p2: ${Project.Type} = #${projectHash}\n` +
|
|
97
|
+
`(p1: ${Tenant.Type}, p2: ${Project.Type}) {\n` +
|
|
98
|
+
` t: ${Task.Type} [\n` +
|
|
99
|
+
` t->tenant: ${Tenant.Type} = p1\n` +
|
|
100
|
+
` t->project: ${Project.Type} = p2\n` +
|
|
101
|
+
` ]\n` +
|
|
102
|
+
`} => t`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('Distribution rule with multi-given share/with', () => {
|
|
106
|
+
let app, withSession, close;
|
|
107
|
+
let tenantValue, projectValue, tenantHash, projectHash, taskHash;
|
|
108
|
+
let creatorId;
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
({ app, withSession, close } = await createSubscriptionApp({
|
|
112
|
+
model, authorization, distribution
|
|
113
|
+
}));
|
|
114
|
+
|
|
115
|
+
creatorId = 'mg-creator-' + randomSuffix();
|
|
116
|
+
({ tenantHash, projectHash, taskHash } = await asUser(withSession, creatorId, async (j) => {
|
|
117
|
+
const { userFact: creatorFact } = await j.login();
|
|
118
|
+
const tenant = await j.fact(new Tenant(creatorFact, 't-' + randomSuffix()));
|
|
119
|
+
tenantValue = tenant;
|
|
120
|
+
const project = await j.fact(new Project(tenant, 'p-' + randomSuffix()));
|
|
121
|
+
projectValue = project;
|
|
122
|
+
const task = await j.fact(new Task(tenant, project, 'do the thing'));
|
|
123
|
+
return {
|
|
124
|
+
tenantHash: j.hash(tenant),
|
|
125
|
+
projectHash: j.hash(project),
|
|
126
|
+
taskHash: j.hash(task)
|
|
127
|
+
};
|
|
128
|
+
}));
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
afterEach(async () => {
|
|
132
|
+
if (close) await close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('delivers tasks to a user with ProjectAccess through a 2-given subscription', async () => {
|
|
136
|
+
const memberId = 'mg-member-' + randomSuffix();
|
|
137
|
+
const memberFact = await asUser(withSession, memberId, async (j) => {
|
|
138
|
+
const { userFact } = await j.login();
|
|
139
|
+
return userFact;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Grant ProjectAccess that covers both the tenant and the project.
|
|
143
|
+
await asUser(withSession, creatorId, async (j) => {
|
|
144
|
+
await j.fact(new ProjectAccess(tenantValue, projectValue, memberFact));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const feedsResponse = await request(app)
|
|
148
|
+
.post('/feeds')
|
|
149
|
+
.set('Content-Type', 'text/plain')
|
|
150
|
+
.set('x-test-user-id', memberId)
|
|
151
|
+
.send(taskSpecText(tenantHash, projectHash));
|
|
152
|
+
expect(feedsResponse.status).toBe(200);
|
|
153
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
154
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
155
|
+
|
|
156
|
+
const pollResponse = await request(app)
|
|
157
|
+
.get(`/feeds/${feeds[0]}`)
|
|
158
|
+
.set('x-test-user-id', memberId);
|
|
159
|
+
expect(pollResponse.status).toBe(200);
|
|
160
|
+
const body = JSON.parse(pollResponse.text);
|
|
161
|
+
const taskRefs = (body.references || []).filter(r => r.type === Task.Type);
|
|
162
|
+
expect(taskRefs.map(r => r.hash)).toContain(taskHash);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('hides tasks from a user without ProjectAccess through the same 2-given subscription', async () => {
|
|
166
|
+
const outsiderId = 'mg-outsider-' + randomSuffix();
|
|
167
|
+
await asUser(withSession, outsiderId, async (j) => { /* login only */ });
|
|
168
|
+
|
|
169
|
+
const feedsResponse = await request(app)
|
|
170
|
+
.post('/feeds')
|
|
171
|
+
.set('Content-Type', 'text/plain')
|
|
172
|
+
.set('x-test-user-id', outsiderId)
|
|
173
|
+
.send(taskSpecText(tenantHash, projectHash));
|
|
174
|
+
expect(feedsResponse.status).toBe(200);
|
|
175
|
+
const { feeds } = JSON.parse(feedsResponse.text);
|
|
176
|
+
|
|
177
|
+
const pollResponse = await request(app)
|
|
178
|
+
.get(`/feeds/${feeds[0]}`)
|
|
179
|
+
.set('x-test-user-id', outsiderId);
|
|
180
|
+
expect(pollResponse.status).toBe(200);
|
|
181
|
+
const body = JSON.parse(pollResponse.text);
|
|
182
|
+
expect((body.references || []).filter(r => r.type === Task.Type)).toHaveLength(0);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -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
|
+
});
|