@nxgt/janus 0.1.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/LICENSE +21 -0
- package/README.md +554 -0
- package/dist/auth/config.d.ts +201 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/auth/context.d.ts +87 -0
- package/dist/auth/context.d.ts.map +1 -0
- package/dist/auth/hashers.d.ts +35 -0
- package/dist/auth/hashers.d.ts.map +1 -0
- package/dist/auth/index.d.ts +9 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/janus.d.ts +38 -0
- package/dist/auth/janus.d.ts.map +1 -0
- package/dist/auth/outage.d.ts +61 -0
- package/dist/auth/outage.d.ts.map +1 -0
- package/dist/auth/port/assert-stores.d.ts +16 -0
- package/dist/auth/port/assert-stores.d.ts.map +1 -0
- package/dist/auth/port/memory.d.ts +25 -0
- package/dist/auth/port/memory.d.ts.map +1 -0
- package/dist/auth/port/types.d.ts +393 -0
- package/dist/auth/port/types.d.ts.map +1 -0
- package/dist/auth/secrets.d.ts +17 -0
- package/dist/auth/secrets.d.ts.map +1 -0
- package/dist/auth/sessions.d.ts +31 -0
- package/dist/auth/sessions.d.ts.map +1 -0
- package/dist/auth/standard-schema.d.ts +41 -0
- package/dist/auth/standard-schema.d.ts.map +1 -0
- package/dist/auth/types.d.ts +353 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/users.d.ts +9 -0
- package/dist/auth/users.d.ts.map +1 -0
- package/dist/chunks/index-658b6mr2.js +269 -0
- package/dist/chunks/index-658b6mr2.js.map +11 -0
- package/dist/chunks/index-6dytvy3h.js +93 -0
- package/dist/chunks/index-6dytvy3h.js.map +11 -0
- package/dist/chunks/index-6p56fpbe.js +147 -0
- package/dist/chunks/index-6p56fpbe.js.map +12 -0
- package/dist/chunks/index-fgb3t64y.js +77 -0
- package/dist/chunks/index-fgb3t64y.js.map +10 -0
- package/dist/conformance/assert.d.ts +36 -0
- package/dist/conformance/assert.d.ts.map +1 -0
- package/dist/conformance/cases/outage.d.ts +3 -0
- package/dist/conformance/cases/outage.d.ts.map +1 -0
- package/dist/conformance/cases/sessions.d.ts +3 -0
- package/dist/conformance/cases/sessions.d.ts.map +1 -0
- package/dist/conformance/cases/tokens.d.ts +3 -0
- package/dist/conformance/cases/tokens.d.ts.map +1 -0
- package/dist/conformance/cases/users.d.ts +3 -0
- package/dist/conformance/cases/users.d.ts.map +1 -0
- package/dist/conformance/describe.d.ts +74 -0
- package/dist/conformance/describe.d.ts.map +1 -0
- package/dist/conformance/fixtures.d.ts +11 -0
- package/dist/conformance/fixtures.d.ts.map +1 -0
- package/dist/conformance/index.d.ts +33 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +1113 -0
- package/dist/conformance/index.js.map +18 -0
- package/dist/conformance/reference.d.ts +13 -0
- package/dist/conformance/reference.d.ts.map +1 -0
- package/dist/conformance/relations.d.ts +67 -0
- package/dist/conformance/relations.d.ts.map +1 -0
- package/dist/conformance/types.d.ts +71 -0
- package/dist/conformance/types.d.ts.map +1 -0
- package/dist/errors/janus-error.d.ts +259 -0
- package/dist/errors/janus-error.d.ts.map +1 -0
- package/dist/ids/id.d.ts +55 -0
- package/dist/ids/id.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +962 -0
- package/dist/index.js.map +19 -0
- package/dist/pagination/cursor-page.d.ts +53 -0
- package/dist/pagination/cursor-page.d.ts.map +1 -0
- package/dist/permissions/engine.d.ts +46 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/index.d.ts +24 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +685 -0
- package/dist/permissions/index.js.map +15 -0
- package/dist/permissions/input.d.ts +29 -0
- package/dist/permissions/input.d.ts.map +1 -0
- package/dist/permissions/model.d.ts +368 -0
- package/dist/permissions/model.d.ts.map +1 -0
- package/dist/permissions/port/memory.d.ts +17 -0
- package/dist/permissions/port/memory.d.ts.map +1 -0
- package/dist/permissions/port/types.d.ts +84 -0
- package/dist/permissions/port/types.d.ts.map +1 -0
- package/dist/permissions/resolve.d.ts +59 -0
- package/dist/permissions/resolve.d.ts.map +1 -0
- package/dist/permissions/reverse.d.ts +53 -0
- package/dist/permissions/reverse.d.ts.map +1 -0
- package/dist/permissions/walk.d.ts +29 -0
- package/dist/permissions/walk.d.ts.map +1 -0
- package/dist/subjects/notation.d.ts +35 -0
- package/dist/subjects/notation.d.ts.map +1 -0
- package/dist/subjects/subject.d.ts +75 -0
- package/dist/subjects/subject.d.ts.map +1 -0
- package/dist/time/clock.d.ts +31 -0
- package/dist/time/clock.d.ts.map +1 -0
- package/dist/time/duration.d.ts +23 -0
- package/dist/time/duration.d.ts.map +1 -0
- package/docs/README.md +17 -0
- package/docs/guide/adapters.md +296 -0
- package/docs/guide/email-flows.md +146 -0
- package/docs/guide/errors.md +146 -0
- package/docs/guide/passwords.md +136 -0
- package/docs/guide/permissions.md +347 -0
- package/docs/guide/sessions.md +211 -0
- package/docs/guide/users.md +277 -0
- package/docs/guide/vocabulary.md +163 -0
- package/docs/roadmap.md +93 -0
- package/docs/troubleshooting.md +648 -0
- package/package.json +70 -0
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mintId2,
|
|
3
|
+
createMemoryStores2
|
|
4
|
+
} from "../chunks/index-658b6mr2.js";
|
|
5
|
+
import {
|
|
6
|
+
JanusError2,
|
|
7
|
+
StoreFailure2,
|
|
8
|
+
StoreConflict2,
|
|
9
|
+
NotFoundError2,
|
|
10
|
+
formatSubject2
|
|
11
|
+
} from "../chunks/index-6p56fpbe.js";
|
|
12
|
+
import {
|
|
13
|
+
createMemoryRelations2
|
|
14
|
+
} from "../chunks/index-fgb3t64y.js";
|
|
15
|
+
|
|
16
|
+
// src/conformance/assert.ts
|
|
17
|
+
function equal(actual, expected, what) {
|
|
18
|
+
const difference = differ(actual, expected, "");
|
|
19
|
+
if (difference !== null) {
|
|
20
|
+
throw new Error(`${what}
|
|
21
|
+
at ${difference.path || "(root)"}: expected ${show(difference.expected)}, got ${show(difference.actual)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isNull(actual, what) {
|
|
25
|
+
if (actual !== null) {
|
|
26
|
+
throw new Error(`${what}
|
|
27
|
+
expected null, got ${actual === undefined ? "undefined — an absence is null; undefined is a store that forgot to answer" : show(actual)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function ok(condition, what) {
|
|
31
|
+
if (!condition)
|
|
32
|
+
throw new Error(what);
|
|
33
|
+
}
|
|
34
|
+
function rejects(promise, what) {
|
|
35
|
+
return promise.then((answer) => {
|
|
36
|
+
throw new Error(`${what}
|
|
37
|
+
expected a rejection, got ${show(answer)}`);
|
|
38
|
+
}, (error) => error);
|
|
39
|
+
}
|
|
40
|
+
function isOurs(error, cls, what) {
|
|
41
|
+
if (error instanceof cls)
|
|
42
|
+
return;
|
|
43
|
+
if (error?.name === cls.name) {
|
|
44
|
+
throw new Error(`${what}
|
|
45
|
+
the error is named ${cls.name} but is not @nxgt/janus's ${cls.name}: two copies of @nxgt/janus are installed. The adapter must list it as a peer dependency, never a dependency`);
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`${what}
|
|
48
|
+
expected ${cls.name}, got ${show(error)}`);
|
|
49
|
+
}
|
|
50
|
+
function differ(actual, expected, path) {
|
|
51
|
+
if (expected instanceof Date || actual instanceof Date) {
|
|
52
|
+
return actual instanceof Date && expected instanceof Date && actual.getTime() === expected.getTime() ? null : { path, expected, actual };
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(expected) || Array.isArray(actual)) {
|
|
55
|
+
if (!Array.isArray(expected) || !Array.isArray(actual)) {
|
|
56
|
+
return { path, expected, actual };
|
|
57
|
+
}
|
|
58
|
+
if (expected.length !== actual.length) {
|
|
59
|
+
return {
|
|
60
|
+
path: `${path}.length`,
|
|
61
|
+
expected: expected.length,
|
|
62
|
+
actual: actual.length
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0;i < expected.length; i += 1) {
|
|
66
|
+
const inner = differ(actual[i], expected[i], `${path}[${i}]`);
|
|
67
|
+
if (inner !== null)
|
|
68
|
+
return inner;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (typeof expected === "object" && expected !== null && typeof actual === "object" && actual !== null) {
|
|
73
|
+
const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]);
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const inner = differ(actual[key], expected[key], path === "" ? key : `${path}.${key}`);
|
|
76
|
+
if (inner !== null)
|
|
77
|
+
return inner;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return Object.is(actual, expected) ? null : { path, expected, actual };
|
|
82
|
+
}
|
|
83
|
+
function show(value) {
|
|
84
|
+
if (value instanceof Error)
|
|
85
|
+
return `${value.name}: ${value.message}`;
|
|
86
|
+
if (value instanceof Date)
|
|
87
|
+
return value.toISOString();
|
|
88
|
+
if (value === undefined)
|
|
89
|
+
return "undefined";
|
|
90
|
+
if (typeof value === "string")
|
|
91
|
+
return JSON.stringify(value);
|
|
92
|
+
try {
|
|
93
|
+
const json = JSON.stringify(value);
|
|
94
|
+
return json.length > 200 ? `${json.slice(0, 200)}…` : json;
|
|
95
|
+
} catch {
|
|
96
|
+
return String(value);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// src/conformance/fixtures.ts
|
|
101
|
+
var at = (iso) => new Date(iso);
|
|
102
|
+
var fresh = () => `user-${mintId2()}@example.test`;
|
|
103
|
+
function userRecord(overrides = {}) {
|
|
104
|
+
const email = fresh();
|
|
105
|
+
return {
|
|
106
|
+
id: mintId2(),
|
|
107
|
+
type: "user",
|
|
108
|
+
schemaVersion: "1",
|
|
109
|
+
active: true,
|
|
110
|
+
fields: { email, name: { first: "Ada", last: "Lovelace" } },
|
|
111
|
+
logins: [email],
|
|
112
|
+
password: {
|
|
113
|
+
hash: "$scrypt$ln=17,r=8,p=1$c2FsdA==$a2V5",
|
|
114
|
+
updatedAt: at("2026-01-01T00:00:00.000Z")
|
|
115
|
+
},
|
|
116
|
+
emailVerifiedAt: null,
|
|
117
|
+
version: 0,
|
|
118
|
+
createdAt: at("2026-01-01T00:00:00.000Z"),
|
|
119
|
+
updatedAt: at("2026-01-01T00:00:00.000Z"),
|
|
120
|
+
...overrides
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function sessionRecord(overrides = {}) {
|
|
124
|
+
return {
|
|
125
|
+
id: mintId2(),
|
|
126
|
+
tokenHash: hexOf(mintId2()),
|
|
127
|
+
userId: mintId2(),
|
|
128
|
+
authenticatedAt: at("2026-01-01T00:00:00.000Z"),
|
|
129
|
+
expiresAt: at("2099-01-01T00:00:00.000Z"),
|
|
130
|
+
revokedAt: null,
|
|
131
|
+
createdAt: at("2026-01-01T00:00:00.000Z"),
|
|
132
|
+
...overrides
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function tokenRecord(overrides = {}) {
|
|
136
|
+
return {
|
|
137
|
+
tokenHash: hexOf(mintId2()),
|
|
138
|
+
kind: "resetPassword",
|
|
139
|
+
userId: mintId2(),
|
|
140
|
+
address: fresh(),
|
|
141
|
+
expiresAt: at("2099-01-01T00:00:00.000Z"),
|
|
142
|
+
spentAt: null,
|
|
143
|
+
createdAt: at("2026-01-01T00:00:00.000Z"),
|
|
144
|
+
...overrides
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function hexOf(seed) {
|
|
148
|
+
return seed.replace(/-/g, "").padEnd(64, "0").slice(0, 64);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// src/conformance/cases/outage.ts
|
|
152
|
+
function outage(slot, method, call, seed) {
|
|
153
|
+
return {
|
|
154
|
+
id: `outage.${method}`,
|
|
155
|
+
group: "outage",
|
|
156
|
+
name: `${slot}.${method} rejects when the store cannot answer — never null, false, 0 or an empty page`,
|
|
157
|
+
needs: "faults",
|
|
158
|
+
async run(context) {
|
|
159
|
+
await seed(context);
|
|
160
|
+
await context.faults?.fail(slot, method);
|
|
161
|
+
const error = await rejects(call(context), `${slot}.${method} under an outage should reject: answering an absence turns an outage into a silent lockout`);
|
|
162
|
+
if (error instanceof JanusError2 || error?.name === "StoreFailure") {
|
|
163
|
+
isOurs(error, StoreFailure2, `${slot}.${method} under an outage`);
|
|
164
|
+
}
|
|
165
|
+
ok(error?.code !== "NOT_FOUND", `${slot}.${method} under an outage rejected with NOT_FOUND: an outage is never an absence`);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
var user = userRecord();
|
|
170
|
+
var session = sessionRecord({ userId: user.id });
|
|
171
|
+
var token = tokenRecord({ userId: user.id });
|
|
172
|
+
var now = at("2026-02-01T00:00:00.000Z");
|
|
173
|
+
var seed = async ({ stores }) => {
|
|
174
|
+
await stores.users.insertUser(user);
|
|
175
|
+
await stores.sessions.insertSession(session);
|
|
176
|
+
await stores.tokens.insertToken(token);
|
|
177
|
+
};
|
|
178
|
+
var outageCases = [
|
|
179
|
+
outage("users", "findUser", ({ stores }) => stores.users.findUser(user.id), seed),
|
|
180
|
+
outage("users", "findUserByLogin", ({ stores }) => stores.users.findUserByLogin(user.type, user.logins[0] ?? ""), seed),
|
|
181
|
+
outage("users", "listUsers", ({ stores }) => stores.users.listUsers({ type: user.type, after: null, limit: 10 }), seed),
|
|
182
|
+
outage("sessions", "findSessionByTokenHash", ({ stores }) => stores.sessions.findSessionByTokenHash(session.tokenHash), seed),
|
|
183
|
+
outage("sessions", "extendSession", ({ stores }) => stores.sessions.extendSession(session.id, at("2099-06-01T00:00:00.000Z")), seed),
|
|
184
|
+
outage("sessions", "revokeSession", ({ stores }) => stores.sessions.revokeSession(session.id, now), seed),
|
|
185
|
+
outage("sessions", "revokeUserSessions", ({ stores }) => stores.sessions.revokeUserSessions(user.id, now), seed),
|
|
186
|
+
outage("tokens", "consumeToken", ({ stores }) => stores.tokens.consumeToken(token.tokenHash, "resetPassword", now), seed),
|
|
187
|
+
outage("users", "deleteUser", ({ stores }) => stores.users.deleteUser(user.id), seed),
|
|
188
|
+
outage("sessions", "deleteUserSessions", ({ stores }) => stores.sessions.deleteUserSessions(user.id), seed),
|
|
189
|
+
outage("tokens", "deleteUserTokens", ({ stores }) => stores.tokens.deleteUserTokens(user.id), seed)
|
|
190
|
+
];
|
|
191
|
+
// src/conformance/cases/sessions.ts
|
|
192
|
+
var group = "sessions";
|
|
193
|
+
var sessionStoreCases = [
|
|
194
|
+
{
|
|
195
|
+
id: "sessions.verbatim",
|
|
196
|
+
group,
|
|
197
|
+
name: "answers a stored session verbatim — revoked or lapsed — or null once lapsed, and never a changed record",
|
|
198
|
+
async run({ stores }) {
|
|
199
|
+
const standing = sessionRecord();
|
|
200
|
+
const revoked = sessionRecord({
|
|
201
|
+
revokedAt: at("2026-01-02T00:00:00.000Z")
|
|
202
|
+
});
|
|
203
|
+
const lapsed = sessionRecord({
|
|
204
|
+
expiresAt: at("2020-01-01T00:00:00.000Z")
|
|
205
|
+
});
|
|
206
|
+
for (const record of [standing, revoked, lapsed]) {
|
|
207
|
+
await stores.sessions.insertSession(record);
|
|
208
|
+
}
|
|
209
|
+
equal(await stores.sessions.findSessionByTokenHash(standing.tokenHash), standing, "findSessionByTokenHash for a standing session");
|
|
210
|
+
equal(await stores.sessions.findSessionByTokenHash(revoked.tokenHash), revoked, "findSessionByTokenHash for a revoked session should answer it verbatim — revocation is the core’s to read");
|
|
211
|
+
const found = await stores.sessions.findSessionByTokenHash(lapsed.tokenHash);
|
|
212
|
+
if (found !== null) {
|
|
213
|
+
equal(found, lapsed, "findSessionByTokenHash for a lapsed session should answer it verbatim, or null");
|
|
214
|
+
}
|
|
215
|
+
isNull(await stores.sessions.findSessionByTokenHash("f".repeat(64)), "findSessionByTokenHash for an unknown hash");
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "sessions.idempotentInsert",
|
|
220
|
+
group,
|
|
221
|
+
name: "is idempotent under retry: inserting an existing session id writes nothing",
|
|
222
|
+
async run({ stores }) {
|
|
223
|
+
const record = sessionRecord();
|
|
224
|
+
await stores.sessions.insertSession(record);
|
|
225
|
+
await stores.sessions.insertSession({
|
|
226
|
+
...record,
|
|
227
|
+
expiresAt: new Date("2099-06-01T00:00:00.000Z")
|
|
228
|
+
});
|
|
229
|
+
equal(await stores.sessions.findSessionByTokenHash(record.tokenHash), record, "insertSession retried with the same id should write nothing");
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "sessions.extend",
|
|
234
|
+
group,
|
|
235
|
+
name: "extends a standing session, and never brings back a revoked one",
|
|
236
|
+
async run({ stores }) {
|
|
237
|
+
const standing = sessionRecord();
|
|
238
|
+
const revoked = sessionRecord({
|
|
239
|
+
revokedAt: at("2026-01-02T00:00:00.000Z")
|
|
240
|
+
});
|
|
241
|
+
await stores.sessions.insertSession(standing);
|
|
242
|
+
await stores.sessions.insertSession(revoked);
|
|
243
|
+
const later = at("2099-06-01T00:00:00.000Z");
|
|
244
|
+
equal(await stores.sessions.extendSession(standing.id, later), { ...standing, expiresAt: later }, "extendSession should answer the session with its new expiresAt");
|
|
245
|
+
isNull(await stores.sessions.extendSession(revoked.id, later), "extendSession on a revoked session — an extension racing a revocation must never bring it back");
|
|
246
|
+
equal(await stores.sessions.findSessionByTokenHash(revoked.tokenHash), revoked, "extendSession refused on a revoked session should have written nothing");
|
|
247
|
+
isNull(await stores.sessions.extendSession(mintId2(), later), "extendSession on an unknown session");
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "sessions.revoke",
|
|
252
|
+
group,
|
|
253
|
+
name: "revokes once and keeps the first revokedAt; answers false only when there is no session",
|
|
254
|
+
async run({ stores }) {
|
|
255
|
+
const record = sessionRecord();
|
|
256
|
+
await stores.sessions.insertSession(record);
|
|
257
|
+
const first = at("2026-02-01T00:00:00.000Z");
|
|
258
|
+
equal(await stores.sessions.revokeSession(record.id, first), true, "revokeSession on a standing session");
|
|
259
|
+
equal(await stores.sessions.revokeSession(record.id, at("2026-03-01T00:00:00.000Z")), true, "revokeSession on a revoked session still answers true: the session exists");
|
|
260
|
+
equal((await stores.sessions.findSessionByTokenHash(record.tokenHash))?.revokedAt, first, "revokeSession twice should keep the first revokedAt");
|
|
261
|
+
equal(await stores.sessions.revokeSession(mintId2(), first), false, "revokeSession on an unknown session");
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: "sessions.revokeUser",
|
|
266
|
+
group,
|
|
267
|
+
name: "revokes every standing session of one user but the one excepted, and counts only what it revoked",
|
|
268
|
+
async run({ stores }) {
|
|
269
|
+
const userId = mintId2();
|
|
270
|
+
const current = sessionRecord({ userId });
|
|
271
|
+
const other = sessionRecord({ userId });
|
|
272
|
+
const already = sessionRecord({
|
|
273
|
+
userId,
|
|
274
|
+
revokedAt: at("2026-01-02T00:00:00.000Z")
|
|
275
|
+
});
|
|
276
|
+
const stranger = sessionRecord();
|
|
277
|
+
for (const record of [current, other, already, stranger]) {
|
|
278
|
+
await stores.sessions.insertSession(record);
|
|
279
|
+
}
|
|
280
|
+
const now = at("2026-02-01T00:00:00.000Z");
|
|
281
|
+
equal(await stores.sessions.revokeUserSessions(userId, now, current.id), 1, "revokeUserSessions with except: how many this call revoked");
|
|
282
|
+
isNull((await stores.sessions.findSessionByTokenHash(current.tokenHash))?.revokedAt ?? null, "revokeUserSessions should spare the excepted session");
|
|
283
|
+
equal((await stores.sessions.findSessionByTokenHash(already.tokenHash))?.revokedAt, already.revokedAt, "revokeUserSessions should keep an earlier revokedAt");
|
|
284
|
+
equal(await stores.sessions.findSessionByTokenHash(stranger.tokenHash), stranger, "revokeUserSessions should not touch another user");
|
|
285
|
+
equal(await stores.sessions.revokeUserSessions(userId, now), 1, "revokeUserSessions without except");
|
|
286
|
+
equal(await stores.sessions.revokeUserSessions(mintId2(), now), 0, "revokeUserSessions for a user with none answers 0, not a failure");
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
id: "sessions.deleteUser",
|
|
291
|
+
group,
|
|
292
|
+
name: "deletes every session of one user — standing, revoked or lapsed — and counts them",
|
|
293
|
+
async run({ stores }) {
|
|
294
|
+
const userId = mintId2();
|
|
295
|
+
const records = [
|
|
296
|
+
sessionRecord({ userId }),
|
|
297
|
+
sessionRecord({ userId, revokedAt: at("2026-01-02T00:00:00.000Z") }),
|
|
298
|
+
sessionRecord({ userId, expiresAt: at("2026-01-02T00:00:00.000Z") })
|
|
299
|
+
];
|
|
300
|
+
const stranger = sessionRecord();
|
|
301
|
+
for (const record of [...records, stranger]) {
|
|
302
|
+
await stores.sessions.insertSession(record);
|
|
303
|
+
}
|
|
304
|
+
equal(await stores.sessions.deleteUserSessions(userId), 3, "deleteUserSessions: how many it deleted, revoked and lapsed ones included");
|
|
305
|
+
for (const record of records) {
|
|
306
|
+
isNull(await stores.sessions.findSessionByTokenHash(record.tokenHash), "findSessionByTokenHash after deleteUserSessions");
|
|
307
|
+
}
|
|
308
|
+
equal(await stores.sessions.findSessionByTokenHash(stranger.tokenHash), stranger, "deleteUserSessions should not touch another user");
|
|
309
|
+
equal(await stores.sessions.deleteUserSessions(userId), 0, "deleteUserSessions for a user with none answers 0, not a failure");
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: "sessions.deleteExpired",
|
|
314
|
+
group,
|
|
315
|
+
name: "deletes sessions whose expiry is at or before the instant, and only those",
|
|
316
|
+
needs: "deleteExpiredSessions",
|
|
317
|
+
async run({ stores }) {
|
|
318
|
+
const collect = stores.sessions.deleteExpiredSessions;
|
|
319
|
+
ok(collect !== undefined, "deleteExpiredSessions should be present");
|
|
320
|
+
const lapsed = sessionRecord({
|
|
321
|
+
expiresAt: at("2026-01-10T00:00:00.000Z")
|
|
322
|
+
});
|
|
323
|
+
const living = sessionRecord({
|
|
324
|
+
expiresAt: at("2026-01-10T00:00:00.001Z")
|
|
325
|
+
});
|
|
326
|
+
await stores.sessions.insertSession(lapsed);
|
|
327
|
+
await stores.sessions.insertSession(living);
|
|
328
|
+
equal(await collect?.call(stores.sessions, at("2026-01-10T00:00:00.000Z")), 1, "deleteExpiredSessions: how many it deleted");
|
|
329
|
+
isNull(await stores.sessions.findSessionByTokenHash(lapsed.tokenHash), "findSessionByTokenHash after deleteExpiredSessions");
|
|
330
|
+
equal(await stores.sessions.findSessionByTokenHash(living.tokenHash), living, "deleteExpiredSessions should keep a session one millisecond short of lapsing");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
];
|
|
334
|
+
// src/conformance/cases/tokens.ts
|
|
335
|
+
var group2 = "tokens";
|
|
336
|
+
var tokenStoreCases = [
|
|
337
|
+
{
|
|
338
|
+
id: "tokens.consume",
|
|
339
|
+
group: group2,
|
|
340
|
+
name: "answers the token as it was before the call: spentAt null means this call spent it",
|
|
341
|
+
async run({ stores }) {
|
|
342
|
+
const record = tokenRecord();
|
|
343
|
+
await stores.tokens.insertToken(record);
|
|
344
|
+
const first = at("2026-02-01T00:00:00.000Z");
|
|
345
|
+
equal(await stores.tokens.consumeToken(record.tokenHash, "resetPassword", first), record, "consumeToken the first time should answer the token as it was — spentAt null");
|
|
346
|
+
equal(await stores.tokens.consumeToken(record.tokenHash, "resetPassword", at("2026-02-02T00:00:00.000Z")), { ...record, spentAt: first }, "consumeToken the second time should answer it spent at the first call’s instant, and change nothing");
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
id: "tokens.concurrency",
|
|
351
|
+
group: group2,
|
|
352
|
+
name: "lets exactly one of twenty concurrent redemptions spend a token — one conditional write, never a read then a write",
|
|
353
|
+
async run({ stores }) {
|
|
354
|
+
const record = tokenRecord();
|
|
355
|
+
await stores.tokens.insertToken(record);
|
|
356
|
+
const now = at("2026-02-01T00:00:00.000Z");
|
|
357
|
+
const answers = await Promise.all(Array.from({ length: 20 }, () => stores.tokens.consumeToken(record.tokenHash, "resetPassword", now)));
|
|
358
|
+
equal(answers.filter((answer) => answer !== null && answer.spentAt === null).length, 1, "consumeToken: of twenty concurrent calls, exactly one should see spentAt null — a reset code redeemed twice is an account takeover");
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
id: "tokens.kind",
|
|
363
|
+
group: group2,
|
|
364
|
+
name: "does not know, and does not spend, a token of the other kind",
|
|
365
|
+
async run({ stores }) {
|
|
366
|
+
const record = tokenRecord({ kind: "verifyEmail" });
|
|
367
|
+
await stores.tokens.insertToken(record);
|
|
368
|
+
isNull(await stores.tokens.consumeToken(record.tokenHash, "resetPassword", at("2026-02-01T00:00:00.000Z")), "consumeToken for a verifyEmail token redeemed as resetPassword");
|
|
369
|
+
const own = await stores.tokens.consumeToken(record.tokenHash, "verifyEmail", at("2026-02-02T00:00:00.000Z"));
|
|
370
|
+
equal(own, record, "consumeToken of the other kind should not have spent the token: redeemed as its own kind, it is still unspent");
|
|
371
|
+
isNull(await stores.tokens.consumeToken("0".repeat(64), "resetPassword", at("2026-02-01T00:00:00.000Z")), "consumeToken for an unknown hash");
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
id: "tokens.lapsed",
|
|
376
|
+
group: group2,
|
|
377
|
+
name: "spends a lapsed token all the same: expiry is compared by the core, after the call",
|
|
378
|
+
async run({ stores }) {
|
|
379
|
+
const record = tokenRecord({ expiresAt: at("2020-01-01T00:00:00.000Z") });
|
|
380
|
+
await stores.tokens.insertToken(record);
|
|
381
|
+
const now = at("2026-02-01T00:00:00.000Z");
|
|
382
|
+
const first = await stores.tokens.consumeToken(record.tokenHash, "resetPassword", now);
|
|
383
|
+
if (first !== null) {
|
|
384
|
+
equal(first, record, "consumeToken on a lapsed token should answer it as it was");
|
|
385
|
+
equal((await stores.tokens.consumeToken(record.tokenHash, "resetPassword", now))?.spentAt, now, "consumeToken should have spent the lapsed token, so it can never be retried");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: "tokens.idempotentInsert",
|
|
391
|
+
group: group2,
|
|
392
|
+
name: "is idempotent under retry: inserting an existing token hash writes nothing",
|
|
393
|
+
async run({ stores }) {
|
|
394
|
+
const record = tokenRecord();
|
|
395
|
+
await stores.tokens.insertToken(record);
|
|
396
|
+
await stores.tokens.insertToken({
|
|
397
|
+
...record,
|
|
398
|
+
address: "other@example.test"
|
|
399
|
+
});
|
|
400
|
+
equal(await stores.tokens.consumeToken(record.tokenHash, "resetPassword", at("2026-02-01T00:00:00.000Z")), record, "insertToken retried with the same hash should write nothing");
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
id: "tokens.deleteUser",
|
|
405
|
+
group: group2,
|
|
406
|
+
name: "deletes every token of one user, spent or not, of either kind, and counts them",
|
|
407
|
+
async run({ stores }) {
|
|
408
|
+
const userId = mintId2();
|
|
409
|
+
const records = [
|
|
410
|
+
tokenRecord({ userId }),
|
|
411
|
+
tokenRecord({ userId, kind: "verifyEmail" }),
|
|
412
|
+
tokenRecord({ userId, spentAt: at("2026-01-02T00:00:00.000Z") })
|
|
413
|
+
];
|
|
414
|
+
const stranger = tokenRecord();
|
|
415
|
+
for (const record of [...records, stranger]) {
|
|
416
|
+
await stores.tokens.insertToken(record);
|
|
417
|
+
}
|
|
418
|
+
const now = at("2026-02-01T00:00:00.000Z");
|
|
419
|
+
equal(await stores.tokens.deleteUserTokens(userId), 3, "deleteUserTokens: how many it deleted");
|
|
420
|
+
for (const record of records) {
|
|
421
|
+
isNull(await stores.tokens.consumeToken(record.tokenHash, record.kind, now), "consumeToken after deleteUserTokens");
|
|
422
|
+
}
|
|
423
|
+
equal(await stores.tokens.consumeToken(stranger.tokenHash, stranger.kind, now), stranger, "deleteUserTokens should not touch another user");
|
|
424
|
+
equal(await stores.tokens.deleteUserTokens(userId), 0, "deleteUserTokens for a user with none answers 0, not a failure");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
];
|
|
428
|
+
// src/conformance/cases/users.ts
|
|
429
|
+
var group3 = "users";
|
|
430
|
+
var userStoreCases = [
|
|
431
|
+
{
|
|
432
|
+
id: "users.roundTrip",
|
|
433
|
+
group: group3,
|
|
434
|
+
name: 'round-trips a record byte for byte: nested fields, a 1 that stays a number, a "1" that stays a string',
|
|
435
|
+
async run({ stores }) {
|
|
436
|
+
const record = userRecord({
|
|
437
|
+
fields: {
|
|
438
|
+
email: "Ada@Example.test",
|
|
439
|
+
name: { first: "Ada", last: "Lovelace" },
|
|
440
|
+
count: 1,
|
|
441
|
+
code: "1",
|
|
442
|
+
zero: 0,
|
|
443
|
+
flags: [true, false, null],
|
|
444
|
+
note: " spaced ",
|
|
445
|
+
unicode: "Bob — ÿ",
|
|
446
|
+
nested: { deep: { deeper: [1, { two: 2 }] } },
|
|
447
|
+
tags: ["a", "b"]
|
|
448
|
+
},
|
|
449
|
+
emailVerifiedAt: at("2026-01-02T00:00:00.000Z")
|
|
450
|
+
});
|
|
451
|
+
equal(await stores.users.insertUser(record), record, "insertUser should answer the record as stored");
|
|
452
|
+
equal(await stores.users.findUser(record.id), record, "findUser should answer the record exactly as it was written");
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
id: "users.absence",
|
|
457
|
+
group: group3,
|
|
458
|
+
name: "answers null for an absence, never undefined, and an empty page for an empty store",
|
|
459
|
+
async run({ stores }) {
|
|
460
|
+
isNull(await stores.users.findUser(mintId2()), "findUser for an unknown id");
|
|
461
|
+
isNull(await stores.users.findUserByLogin("user", "nobody@example.test"), "findUserByLogin for an unknown login");
|
|
462
|
+
equal(await stores.users.listUsers({ type: "user", after: null, limit: 10 }), { items: [], nextCursor: null }, "listUsers on an empty store should answer an empty page");
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
id: "users.loginBytes",
|
|
467
|
+
group: group3,
|
|
468
|
+
name: "compares logins as bytes, within one type: no case folding, no trimming, no collation",
|
|
469
|
+
async run({ stores }) {
|
|
470
|
+
const upper = userRecord({ logins: ["Ada@Example.test"] });
|
|
471
|
+
const lower = userRecord({ logins: ["ada@example.test"] });
|
|
472
|
+
const staff = userRecord({ type: "staff", logins: ["ada@example.test"] });
|
|
473
|
+
for (const record of [upper, lower, staff]) {
|
|
474
|
+
await stores.users.insertUser(record);
|
|
475
|
+
}
|
|
476
|
+
equal((await stores.users.findUserByLogin("user", "Ada@Example.test"))?.id, upper.id, "findUserByLogin should find the login with its exact bytes");
|
|
477
|
+
equal((await stores.users.findUserByLogin("user", "ada@example.test"))?.id, lower.id, 'findUserByLogin should tell "Ada@…" from "ada@…": normalisation is the core’s');
|
|
478
|
+
equal((await stores.users.findUserByLogin("staff", "ada@example.test"))?.id, staff.id, "uniqueness is of (type, login): the same login under another type is another account");
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
id: "users.idempotentInsert",
|
|
483
|
+
group: group3,
|
|
484
|
+
name: "is idempotent under retry: inserting an existing id answers the stored record and writes nothing",
|
|
485
|
+
async run({ stores }) {
|
|
486
|
+
const record = userRecord();
|
|
487
|
+
await stores.users.insertUser(record);
|
|
488
|
+
const retried = await stores.users.insertUser({
|
|
489
|
+
...record,
|
|
490
|
+
fields: { email: "changed@example.test" }
|
|
491
|
+
});
|
|
492
|
+
equal(retried, record, "insertUser retried with the same id should answer the stored record");
|
|
493
|
+
equal(await stores.users.findUser(record.id), record, "insertUser retried should write nothing");
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
id: "users.uniqueness",
|
|
498
|
+
group: group3,
|
|
499
|
+
name: "lets exactly one of twenty concurrent inserts hold a login: uniqueness is a constraint, not a read",
|
|
500
|
+
async run({ stores }) {
|
|
501
|
+
const records = Array.from({ length: 20 }, () => userRecord({ logins: ["contested@example.test"] }));
|
|
502
|
+
const outcomes = await Promise.allSettled(records.map((record) => stores.users.insertUser(record)));
|
|
503
|
+
const accepted = outcomes.filter((o) => o.status === "fulfilled");
|
|
504
|
+
const refused = outcomes.flatMap((o) => o.status === "rejected" ? [o.reason] : []);
|
|
505
|
+
equal(accepted.length, 1, "insertUser: of twenty concurrent inserts of one login, exactly one should be accepted — a read followed by a write lets several through");
|
|
506
|
+
for (const error of refused) {
|
|
507
|
+
isOurs(error, StoreConflict2, "insertUser should refuse a taken login with StoreConflict");
|
|
508
|
+
equal(error.code, "LOGIN_TAKEN", "insertUser: the conflict code");
|
|
509
|
+
equal(error.login, "contested@example.test", "insertUser: the conflict should name the login");
|
|
510
|
+
}
|
|
511
|
+
let stored = 0;
|
|
512
|
+
for (const record of records) {
|
|
513
|
+
if (await stores.users.findUser(record.id) !== null)
|
|
514
|
+
stored += 1;
|
|
515
|
+
}
|
|
516
|
+
equal(stored, 1, "insertUser: a refused insert should write nothing");
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
id: "users.versionIncrements",
|
|
521
|
+
group: group3,
|
|
522
|
+
name: "applies an update at the expected version, and answers the record written with its version one higher",
|
|
523
|
+
async run({ stores }) {
|
|
524
|
+
const record = userRecord();
|
|
525
|
+
await stores.users.insertUser(record);
|
|
526
|
+
const written = await stores.users.updateUser(record.id, { updatedAt: at("2026-02-01T00:00:00.000Z"), active: false }, 0);
|
|
527
|
+
const expected = {
|
|
528
|
+
...record,
|
|
529
|
+
active: false,
|
|
530
|
+
version: 1,
|
|
531
|
+
updatedAt: at("2026-02-01T00:00:00.000Z")
|
|
532
|
+
};
|
|
533
|
+
equal(written, expected, "updateUser should answer the record as written");
|
|
534
|
+
equal(await stores.users.findUser(record.id), expected, "findUser after updateUser");
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: "users.versionConflict",
|
|
539
|
+
group: group3,
|
|
540
|
+
name: "refuses a stale version with both versions, and leaves the record identical byte for byte",
|
|
541
|
+
async run({ stores }) {
|
|
542
|
+
const record = userRecord();
|
|
543
|
+
await stores.users.insertUser(record);
|
|
544
|
+
await stores.users.updateUser(record.id, { updatedAt: at("2026-02-01T00:00:00.000Z"), active: false }, 0);
|
|
545
|
+
const before = await stores.users.findUser(record.id);
|
|
546
|
+
const error = await rejects(stores.users.updateUser(record.id, {
|
|
547
|
+
updatedAt: at("2026-03-01T00:00:00.000Z"),
|
|
548
|
+
active: true,
|
|
549
|
+
fields: { email: "overwritten@example.test" }
|
|
550
|
+
}, 0), "updateUser at a stale version should reject");
|
|
551
|
+
isOurs(error, StoreConflict2, "updateUser at a stale version should reject with StoreConflict");
|
|
552
|
+
equal(error.code, "VERSION_CONFLICT", "updateUser: the conflict code");
|
|
553
|
+
equal(error.expectedVersion, 0, "updateUser: expectedVersion");
|
|
554
|
+
equal(error.actualVersion, 1, "updateUser: actualVersion");
|
|
555
|
+
equal(await stores.users.findUser(record.id), before, "updateUser refused for its version should have written nothing");
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
id: "users.unknownUpdate",
|
|
560
|
+
group: group3,
|
|
561
|
+
name: "refuses an update to an unknown id with NotFoundError, told apart from a version conflict",
|
|
562
|
+
async run({ stores }) {
|
|
563
|
+
const error = await rejects(stores.users.updateUser(mintId2(), { updatedAt: at("2026-02-01T00:00:00.000Z") }, 0), "updateUser on an unknown id should reject");
|
|
564
|
+
isOurs(error, NotFoundError2, "updateUser on an unknown id should reject with NotFoundError, not a version conflict");
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
id: "users.omission",
|
|
569
|
+
group: group3,
|
|
570
|
+
name: "omission — the Kratos PUT trap: an update leaves every field it does not name exactly as it was",
|
|
571
|
+
async run({ stores }) {
|
|
572
|
+
const record = userRecord({
|
|
573
|
+
fields: { email: "kept@example.test", plan: "pro" },
|
|
574
|
+
emailVerifiedAt: at("2026-01-02T00:00:00.000Z")
|
|
575
|
+
});
|
|
576
|
+
await stores.users.insertUser(record);
|
|
577
|
+
const written = await stores.users.updateUser(record.id, { updatedAt: at("2026-02-01T00:00:00.000Z"), schemaVersion: "2" }, 0);
|
|
578
|
+
equal(written, {
|
|
579
|
+
...record,
|
|
580
|
+
schemaVersion: "2",
|
|
581
|
+
version: 1,
|
|
582
|
+
updatedAt: at("2026-02-01T00:00:00.000Z")
|
|
583
|
+
}, "updateUser naming only schemaVersion should leave active, fields, logins, password and emailVerifiedAt untouched");
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
id: "users.passwordSlot",
|
|
588
|
+
group: group3,
|
|
589
|
+
name: "keeps a password the patch does not name, and removes one the patch names null",
|
|
590
|
+
async run({ stores }) {
|
|
591
|
+
const record = userRecord();
|
|
592
|
+
await stores.users.insertUser(record);
|
|
593
|
+
const kept = await stores.users.updateUser(record.id, { updatedAt: at("2026-02-01T00:00:00.000Z") }, 0);
|
|
594
|
+
equal(kept.password, record.password, "updateUser not naming password should keep it");
|
|
595
|
+
const removed = await stores.users.updateUser(record.id, { updatedAt: at("2026-02-02T00:00:00.000Z"), password: null }, 1);
|
|
596
|
+
isNull(removed.password, "updateUser with password: null should remove the password");
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
id: "users.loginsMove",
|
|
601
|
+
group: group3,
|
|
602
|
+
name: "moves logins on update: the old one is free, the new one found, one held elsewhere refused",
|
|
603
|
+
async run({ stores }) {
|
|
604
|
+
const record = userRecord();
|
|
605
|
+
const other = userRecord();
|
|
606
|
+
await stores.users.insertUser(record);
|
|
607
|
+
await stores.users.insertUser(other);
|
|
608
|
+
const [old] = record.logins;
|
|
609
|
+
const moved = "moved@example.test";
|
|
610
|
+
await stores.users.updateUser(record.id, { updatedAt: at("2026-02-01T00:00:00.000Z"), logins: [moved] }, 0);
|
|
611
|
+
isNull(await stores.users.findUserByLogin("user", old ?? ""), "findUserByLogin for a login the update replaced");
|
|
612
|
+
equal((await stores.users.findUserByLogin("user", moved))?.id, record.id, "findUserByLogin for the login the update wrote");
|
|
613
|
+
const error = await rejects(stores.users.updateUser(other.id, { updatedAt: at("2026-02-02T00:00:00.000Z"), logins: [moved] }, 0), "updateUser onto a login another user holds should reject");
|
|
614
|
+
isOurs(error, StoreConflict2, "updateUser onto a held login should reject with StoreConflict");
|
|
615
|
+
equal(error.code, "LOGIN_TAKEN", "updateUser: the conflict code");
|
|
616
|
+
equal(await stores.users.findUser(other.id), other, "updateUser refused for a login should have written nothing");
|
|
617
|
+
}
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: "users.pagination",
|
|
621
|
+
group: group3,
|
|
622
|
+
name: "pages 25 users of one type by 10 in ascending id order, with no gap, no repeat and no other type, and ends on a null cursor",
|
|
623
|
+
async run({ stores }) {
|
|
624
|
+
const records = Array.from({ length: 25 }, () => userRecord());
|
|
625
|
+
const others = Array.from({ length: 5 }, () => userRecord({ type: "staff" }));
|
|
626
|
+
for (const [index, record] of [...records].reverse().entries()) {
|
|
627
|
+
await stores.users.insertUser(record);
|
|
628
|
+
const other = others[index];
|
|
629
|
+
if (other !== undefined)
|
|
630
|
+
await stores.users.insertUser(other);
|
|
631
|
+
}
|
|
632
|
+
const expected = records.map((record) => record.id).sort();
|
|
633
|
+
const seen = [];
|
|
634
|
+
const sizes = [];
|
|
635
|
+
let after = null;
|
|
636
|
+
for (let page = 0;page < 10; page += 1) {
|
|
637
|
+
const answer = await stores.users.listUsers({
|
|
638
|
+
type: "user",
|
|
639
|
+
after,
|
|
640
|
+
limit: 10
|
|
641
|
+
});
|
|
642
|
+
seen.push(...answer.items.map((item) => item.id));
|
|
643
|
+
sizes.push(answer.items.length);
|
|
644
|
+
after = answer.nextCursor;
|
|
645
|
+
if (after === null)
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
equal(sizes, [10, 10, 5], "listUsers: page sizes for 25 users by 10");
|
|
649
|
+
equal(seen, expected, "listUsers: every id of the type once, in ascending order");
|
|
650
|
+
const between = await stores.users.listUsers({
|
|
651
|
+
type: "user",
|
|
652
|
+
after: "00000000-0000-7000-8000-000000000000",
|
|
653
|
+
limit: 3
|
|
654
|
+
});
|
|
655
|
+
ok(between.items[0]?.id === expected[0], "listUsers: a cursor naming no stored user should start after it, not fail");
|
|
656
|
+
}
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
id: "users.delete",
|
|
660
|
+
group: group3,
|
|
661
|
+
name: "deletes a user and frees their logins, answers false for nobody, and touches no other user",
|
|
662
|
+
async run({ stores }) {
|
|
663
|
+
const record = userRecord();
|
|
664
|
+
const other = userRecord();
|
|
665
|
+
await stores.users.insertUser(record);
|
|
666
|
+
await stores.users.insertUser(other);
|
|
667
|
+
const [login] = record.logins;
|
|
668
|
+
equal(await stores.users.deleteUser(record.id), true, "deleteUser for a stored user should answer true");
|
|
669
|
+
isNull(await stores.users.findUser(record.id), "findUser after deleteUser");
|
|
670
|
+
isNull(await stores.users.findUserByLogin(record.type, login ?? ""), "findUserByLogin for a deleted user’s login");
|
|
671
|
+
equal(await stores.users.deleteUser(record.id), false, "deleteUser replayed should answer false, not fail: a deletion interrupted half-way is replayed");
|
|
672
|
+
equal(await stores.users.findUser(other.id), other, "deleteUser should not touch another user");
|
|
673
|
+
const successor = userRecord({ logins: [login ?? ""] });
|
|
674
|
+
equal(await stores.users.insertUser(successor), successor, "insertUser onto a deleted user’s login should succeed");
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
];
|
|
678
|
+
// src/conformance/describe.ts
|
|
679
|
+
var allCases = [
|
|
680
|
+
...userStoreCases,
|
|
681
|
+
...sessionStoreCases,
|
|
682
|
+
...tokenStoreCases,
|
|
683
|
+
...outageCases
|
|
684
|
+
];
|
|
685
|
+
var SKIP_REASONS = {
|
|
686
|
+
faults: "faults not provided: the outage invariant is not proven for this adapter",
|
|
687
|
+
deleteExpiredSessions: "stores.sessions does not implement the optional deleteExpiredSessions"
|
|
688
|
+
};
|
|
689
|
+
async function runCase(conformanceCase, harness) {
|
|
690
|
+
const opened = await harness.open();
|
|
691
|
+
try {
|
|
692
|
+
if (conformanceCase.needs === "faults" && opened.faults === undefined) {
|
|
693
|
+
return { skipped: SKIP_REASONS.faults };
|
|
694
|
+
}
|
|
695
|
+
if (conformanceCase.needs === "deleteExpiredSessions" && opened.stores.sessions.deleteExpiredSessions === undefined) {
|
|
696
|
+
return { skipped: SKIP_REASONS.deleteExpiredSessions };
|
|
697
|
+
}
|
|
698
|
+
await conformanceCase.run({
|
|
699
|
+
stores: opened.stores,
|
|
700
|
+
faults: opened.faults ?? null
|
|
701
|
+
});
|
|
702
|
+
return { passed: true };
|
|
703
|
+
} finally {
|
|
704
|
+
await opened.close?.();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function describeJanusStores(options) {
|
|
708
|
+
describeSuite({
|
|
709
|
+
title: `${options.name} — @nxgt/janus conformance`,
|
|
710
|
+
cases: allCases,
|
|
711
|
+
run: (conformanceCase) => runCase(conformanceCase, options.harness),
|
|
712
|
+
runner: options.runner ?? fromGlobals("describeJanusStores"),
|
|
713
|
+
skip: options.skip ?? {},
|
|
714
|
+
faults: options.faults
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
function describeSuite(options) {
|
|
718
|
+
const { runner, skip, cases } = options;
|
|
719
|
+
const groups = [...new Set(cases.map((c) => c.group))];
|
|
720
|
+
const title = options.faults === false ? `${options.title} (WITHOUT faults: ${SKIP_REASONS.faults})` : options.title;
|
|
721
|
+
runner.describe(title, () => {
|
|
722
|
+
for (const group of groups) {
|
|
723
|
+
runner.describe(group, () => {
|
|
724
|
+
for (const conformanceCase of cases.filter((c) => c.group === group)) {
|
|
725
|
+
const reason = skip[conformanceCase.id];
|
|
726
|
+
if (reason !== undefined) {
|
|
727
|
+
runner.it.skip(`${conformanceCase.name} — skipped: ${reason}`, async () => {});
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
if (conformanceCase.needs === "faults" && options.faults === false) {
|
|
731
|
+
runner.it.skip(`${conformanceCase.name} — skipped: ${SKIP_REASONS.faults}`, async () => {});
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
runner.it(conformanceCase.name, async () => {
|
|
735
|
+
const outcome = await options.run(conformanceCase);
|
|
736
|
+
if ("skipped" in outcome) {
|
|
737
|
+
process.emitWarning(`${conformanceCase.id} skipped: ${outcome.skipped}`, { code: "JANUS_CONFORMANCE_SKIPPED" });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
function fromGlobals(where) {
|
|
746
|
+
const globals = globalThis;
|
|
747
|
+
if (typeof globals.describe !== "function" || typeof globals.it !== "function") {
|
|
748
|
+
throw new TypeError(`${where}: no test runner on globalThis — pass runner: { describe, it } (under bun test: import them from 'bun:test')`);
|
|
749
|
+
}
|
|
750
|
+
return { describe: globals.describe, it: globals.it };
|
|
751
|
+
}
|
|
752
|
+
// src/conformance/reference.ts
|
|
753
|
+
function referenceHarness() {
|
|
754
|
+
return {
|
|
755
|
+
async open() {
|
|
756
|
+
const failing = new Set;
|
|
757
|
+
const stores = withFaults(createMemoryStores2(), failing);
|
|
758
|
+
const faults = {
|
|
759
|
+
async fail(slot, method) {
|
|
760
|
+
failing.add(`${slot}.${method}`);
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
return { stores, faults };
|
|
764
|
+
}
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function withFaults(stores, failing) {
|
|
768
|
+
const wrap = (slot, store) => {
|
|
769
|
+
const wrapped = {};
|
|
770
|
+
for (const [method, fn] of Object.entries(store)) {
|
|
771
|
+
wrapped[method] = (...args) => {
|
|
772
|
+
if (failing.has(`${slot}.${method}`)) {
|
|
773
|
+
return Promise.reject(new StoreFailure2(`${slot}.${method}: the store could not answer`, {
|
|
774
|
+
slot,
|
|
775
|
+
operation: method
|
|
776
|
+
}));
|
|
777
|
+
}
|
|
778
|
+
return fn.apply(store, args);
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
return wrapped;
|
|
782
|
+
};
|
|
783
|
+
return {
|
|
784
|
+
users: wrap("users", stores.users),
|
|
785
|
+
sessions: wrap("sessions", stores.sessions),
|
|
786
|
+
tokens: wrap("tokens", stores.tokens)
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
// src/conformance/relations.ts
|
|
790
|
+
var entity = (type, id = mintId2()) => ({ type, id });
|
|
791
|
+
var set = (type, id, relation) => ({
|
|
792
|
+
type,
|
|
793
|
+
id,
|
|
794
|
+
relation
|
|
795
|
+
});
|
|
796
|
+
var tuple = (object, relation, subject) => ({ object, relation, subject });
|
|
797
|
+
var sorted = (subjects) => subjects.map(formatSubject2).sort();
|
|
798
|
+
var group4 = "relations";
|
|
799
|
+
var relationStoreCases = [
|
|
800
|
+
{
|
|
801
|
+
id: "relations.roundTrip",
|
|
802
|
+
group: group4,
|
|
803
|
+
name: "holds exactly the tuples written, byte for byte: staff:u1 is not patient:u1, and a set is not its entity",
|
|
804
|
+
async run({ store }) {
|
|
805
|
+
const record = entity("record", "R:1 — ÿ");
|
|
806
|
+
const direct = tuple(record, "viewer", entity("staff", "u1"));
|
|
807
|
+
const inherited = tuple(record, "viewer", set("team", "t1", "member"));
|
|
808
|
+
await store.write({ add: [direct, inherited] });
|
|
809
|
+
equal(await store.has(direct), true, "has for a written tuple");
|
|
810
|
+
equal(await store.has(inherited), true, "has for a written subject set");
|
|
811
|
+
equal(await store.has(tuple(record, "viewer", entity("patient", "u1"))), false, "has for the same id under another subject type: types are part of the subject");
|
|
812
|
+
equal(await store.has(tuple(record, "viewer", entity("team", "t1"))), false, "has for the entity of a stored subject set: a set is not its entity");
|
|
813
|
+
equal(await store.has(tuple(entity("record", "r:1 — ÿ"), "viewer", direct.subject)), false, "has for an id differing in one character: ids are compared as written");
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
id: "relations.undefinedRelation",
|
|
818
|
+
group: group4,
|
|
819
|
+
name: "reads a subject whose relation is undefined as its entity, not as a set",
|
|
820
|
+
async run({ store }) {
|
|
821
|
+
const record = entity("record");
|
|
822
|
+
const staff = entity("staff");
|
|
823
|
+
const spread = { ...staff, relation: undefined };
|
|
824
|
+
await store.write({ add: [tuple(record, "viewer", spread)] });
|
|
825
|
+
equal(await store.has(tuple(record, "viewer", staff)), true, "has for the entity, after writing it with relation: undefined");
|
|
826
|
+
equal(await store.findEntities(record, "viewer"), [staff], "findEntities: the tuple names an entity");
|
|
827
|
+
equal(await store.findSubjectSets(record, "viewer"), [], "findSubjectSets: relation: undefined is no set");
|
|
828
|
+
equal(await store.findObjects({
|
|
829
|
+
type: "record",
|
|
830
|
+
relation: "viewer",
|
|
831
|
+
subject: spread,
|
|
832
|
+
after: null,
|
|
833
|
+
limit: 10
|
|
834
|
+
}), { items: [record.id], nextCursor: null }, "findObjects for the subject written with relation: undefined");
|
|
835
|
+
equal(await store.findObjects({
|
|
836
|
+
type: "record",
|
|
837
|
+
relation: "viewer",
|
|
838
|
+
subject: set("staff", staff.id, "x"),
|
|
839
|
+
after: null,
|
|
840
|
+
limit: 10
|
|
841
|
+
}), { items: [], nextCursor: null }, "findObjects for a set of that entity: the entity is not a set");
|
|
842
|
+
await store.write({ remove: [tuple(record, "viewer", spread)] });
|
|
843
|
+
equal(await store.has(tuple(record, "viewer", staff)), false, "has after removing it with relation: undefined");
|
|
844
|
+
}
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
id: "relations.absence",
|
|
848
|
+
group: group4,
|
|
849
|
+
name: "answers false, [], an empty page and 0 for an empty store — never null or undefined",
|
|
850
|
+
async run({ store }) {
|
|
851
|
+
const record = entity("record");
|
|
852
|
+
const staff = entity("staff");
|
|
853
|
+
equal(await store.has(tuple(record, "viewer", staff)), false, "has");
|
|
854
|
+
equal(await store.findSubjectSets(record, "viewer"), [], "findSubjectSets");
|
|
855
|
+
equal(await store.findEntities(record, "viewer"), [], "findEntities");
|
|
856
|
+
equal(await store.findObjects({
|
|
857
|
+
type: "record",
|
|
858
|
+
relation: "viewer",
|
|
859
|
+
subject: staff,
|
|
860
|
+
after: null,
|
|
861
|
+
limit: 10
|
|
862
|
+
}), { items: [], nextCursor: null }, "findObjects on an empty store");
|
|
863
|
+
equal(await store.deleteEntity(staff), 0, "deleteEntity");
|
|
864
|
+
}
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
id: "relations.idempotentWrite",
|
|
868
|
+
group: group4,
|
|
869
|
+
name: "is idempotent under retry: adding a stored tuple and removing an absent one write nothing",
|
|
870
|
+
async run({ store }) {
|
|
871
|
+
const record = entity("record");
|
|
872
|
+
const viewer = tuple(record, "viewer", entity("team"));
|
|
873
|
+
await store.write({ add: [viewer] });
|
|
874
|
+
await store.write({ add: [viewer] });
|
|
875
|
+
await store.write({ remove: [tuple(record, "viewer", entity("team"))] });
|
|
876
|
+
equal((await store.findEntities(record, "viewer")).length, 1, "findEntities after the same tuple was added twice");
|
|
877
|
+
}
|
|
878
|
+
},
|
|
879
|
+
{
|
|
880
|
+
id: "relations.uniqueness",
|
|
881
|
+
group: group4,
|
|
882
|
+
name: "keeps one tuple when twenty concurrent writes add it: uniqueness is a constraint, not a read",
|
|
883
|
+
async run({ store }) {
|
|
884
|
+
const record = entity("record");
|
|
885
|
+
const parent = entity("folder");
|
|
886
|
+
await Promise.all(Array.from({ length: 20 }, () => store.write({ add: [tuple(record, "parent", parent)] })));
|
|
887
|
+
equal(await store.findEntities(record, "parent"), [parent], "findEntities after twenty concurrent adds of one tuple");
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
{
|
|
891
|
+
id: "relations.removeThenAdd",
|
|
892
|
+
group: group4,
|
|
893
|
+
name: "applies one write’s removals and additions together",
|
|
894
|
+
async run({ store }) {
|
|
895
|
+
const record = entity("record");
|
|
896
|
+
const before = tuple(record, "owner", entity("staff", "a"));
|
|
897
|
+
const after = tuple(record, "owner", entity("staff", "b"));
|
|
898
|
+
await store.write({ add: [before] });
|
|
899
|
+
await store.write({ remove: [before], add: [after] });
|
|
900
|
+
equal(await store.has(before), false, "the removed tuple");
|
|
901
|
+
equal(await store.has(after), true, "the added tuple");
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
id: "relations.oneHop",
|
|
906
|
+
group: group4,
|
|
907
|
+
name: "answers one hop: the subject sets and the entities holding one relation on one object, and nothing else",
|
|
908
|
+
async run({ store }) {
|
|
909
|
+
const record = entity("record");
|
|
910
|
+
const staff = entity("staff");
|
|
911
|
+
const team = entity("team");
|
|
912
|
+
const members = set("team", team.id, "member");
|
|
913
|
+
const leads = set("team", team.id, "lead");
|
|
914
|
+
await store.write({
|
|
915
|
+
add: [
|
|
916
|
+
tuple(record, "viewer", staff),
|
|
917
|
+
tuple(record, "viewer", team),
|
|
918
|
+
tuple(record, "viewer", members),
|
|
919
|
+
tuple(record, "viewer", leads),
|
|
920
|
+
tuple(record, "editor", set("team", team.id, "admin")),
|
|
921
|
+
tuple(entity("record"), "viewer", set("team", "other", "member"))
|
|
922
|
+
]
|
|
923
|
+
});
|
|
924
|
+
equal(sorted(await store.findSubjectSets(record, "viewer")), sorted([members, leads]), "findSubjectSets: the sets only, of this object and this relation");
|
|
925
|
+
equal(sorted(await store.findEntities(record, "viewer")), sorted([staff, team]), "findEntities: the single entities only, of this object and this relation");
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
id: "relations.objects",
|
|
930
|
+
group: group4,
|
|
931
|
+
name: "pages 25 objects by 10 in ascending id order, for one type, relation and exact subject, and ends on a null cursor",
|
|
932
|
+
async run({ store }) {
|
|
933
|
+
const staff = entity("staff");
|
|
934
|
+
const ids = Array.from({ length: 25 }, (_, n) => `r${String(n).padStart(2, "0")}`);
|
|
935
|
+
for (const id of [...ids].reverse()) {
|
|
936
|
+
await store.write({
|
|
937
|
+
add: [
|
|
938
|
+
tuple(entity("record", id), "viewer", staff),
|
|
939
|
+
tuple(entity("record", `${id}x`), "editor", staff),
|
|
940
|
+
tuple(entity("folder", id), "viewer", staff),
|
|
941
|
+
tuple(entity("record", `${id}y`), "viewer", set("staff", staff.id, "x"))
|
|
942
|
+
]
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
const seen = [];
|
|
946
|
+
const sizes = [];
|
|
947
|
+
let after = null;
|
|
948
|
+
for (let page = 0;page < 10; page += 1) {
|
|
949
|
+
const answer = await store.findObjects({
|
|
950
|
+
type: "record",
|
|
951
|
+
relation: "viewer",
|
|
952
|
+
subject: staff,
|
|
953
|
+
after,
|
|
954
|
+
limit: 10
|
|
955
|
+
});
|
|
956
|
+
seen.push(...answer.items);
|
|
957
|
+
sizes.push(answer.items.length);
|
|
958
|
+
after = answer.nextCursor;
|
|
959
|
+
if (after === null)
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
equal(sizes, [10, 10, 5], "findObjects: page sizes for 25 objects by 10");
|
|
963
|
+
equal(seen, ids, "findObjects: every id once, in ascending order");
|
|
964
|
+
const between = await store.findObjects({
|
|
965
|
+
type: "record",
|
|
966
|
+
relation: "viewer",
|
|
967
|
+
subject: staff,
|
|
968
|
+
after: "r04a",
|
|
969
|
+
limit: 2
|
|
970
|
+
});
|
|
971
|
+
equal(between.items, ["r05", "r06"], "findObjects: a cursor naming no stored object should start after it");
|
|
972
|
+
}
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
id: "relations.deleteEntity",
|
|
976
|
+
group: group4,
|
|
977
|
+
name: "deletes every tuple naming an entity — as object, as subject, as a set’s entity — and counts them",
|
|
978
|
+
async run({ store }) {
|
|
979
|
+
const team = entity("team");
|
|
980
|
+
const other = entity("team");
|
|
981
|
+
const kept = [
|
|
982
|
+
tuple(other, "member", entity("staff")),
|
|
983
|
+
tuple(entity("record"), "viewer", set("team", other.id, "member"))
|
|
984
|
+
];
|
|
985
|
+
await store.write({
|
|
986
|
+
add: [
|
|
987
|
+
tuple(team, "member", entity("staff")),
|
|
988
|
+
tuple(entity("record"), "team", team),
|
|
989
|
+
tuple(entity("record"), "viewer", set("team", team.id, "member")),
|
|
990
|
+
...kept
|
|
991
|
+
]
|
|
992
|
+
});
|
|
993
|
+
equal(await store.deleteEntity(team), 3, "deleteEntity: how many it deleted");
|
|
994
|
+
for (const survivor of kept) {
|
|
995
|
+
equal(await store.has(survivor), true, "deleteEntity should not touch another entity");
|
|
996
|
+
}
|
|
997
|
+
equal(await store.deleteEntity(team), 0, "deleteEntity replayed answers 0, not a failure");
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
];
|
|
1001
|
+
function outage2(method, call, after) {
|
|
1002
|
+
return {
|
|
1003
|
+
id: `outage.${method}`,
|
|
1004
|
+
group: "outage",
|
|
1005
|
+
name: `relations.${method} rejects when the store cannot answer — never false, [], an empty page or 0`,
|
|
1006
|
+
needs: "faults",
|
|
1007
|
+
async run({ store, faults }) {
|
|
1008
|
+
await store.write({ add: [seeded] });
|
|
1009
|
+
await faults?.fail(method);
|
|
1010
|
+
const error = await rejects(call(store), `relations.${method} under an outage should reject`);
|
|
1011
|
+
if (error instanceof JanusError2 || error?.name === "StoreFailure") {
|
|
1012
|
+
isOurs(error, StoreFailure2, `relations.${method} under an outage`);
|
|
1013
|
+
}
|
|
1014
|
+
ok(error?.code !== "NOT_FOUND", `relations.${method} under an outage rejected with NOT_FOUND: an outage is never an absence`);
|
|
1015
|
+
await after?.(store);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
var seededObject = { type: "record", id: "seeded" };
|
|
1020
|
+
var seededSubject = { type: "staff", id: "seeded" };
|
|
1021
|
+
var seeded = tuple(seededObject, "viewer", seededSubject);
|
|
1022
|
+
var relationOutageCases = [
|
|
1023
|
+
outage2("write", (store) => store.write({
|
|
1024
|
+
remove: [seeded],
|
|
1025
|
+
add: [tuple(seededObject, "owner", seededSubject)]
|
|
1026
|
+
}), async (store) => {
|
|
1027
|
+
equal(await store.has(seeded), true, "relations.write rejected, and still removed a tuple: a write is all or nothing");
|
|
1028
|
+
equal(await store.has(tuple(seededObject, "owner", seededSubject)), false, "relations.write rejected, and still added a tuple: a write is all or nothing");
|
|
1029
|
+
}),
|
|
1030
|
+
outage2("has", (store) => store.has(seeded)),
|
|
1031
|
+
outage2("findSubjectSets", (store) => store.findSubjectSets(seededObject, "viewer")),
|
|
1032
|
+
outage2("findEntities", (store) => store.findEntities(seededObject, "viewer")),
|
|
1033
|
+
outage2("findObjects", (store) => store.findObjects({
|
|
1034
|
+
type: "record",
|
|
1035
|
+
relation: "viewer",
|
|
1036
|
+
subject: seededSubject,
|
|
1037
|
+
after: null,
|
|
1038
|
+
limit: 10
|
|
1039
|
+
})),
|
|
1040
|
+
outage2("deleteEntity", (store) => store.deleteEntity(seededSubject))
|
|
1041
|
+
];
|
|
1042
|
+
var allRelationCases = [
|
|
1043
|
+
...relationStoreCases,
|
|
1044
|
+
...relationOutageCases
|
|
1045
|
+
];
|
|
1046
|
+
async function runRelationCase(relationCase, harness) {
|
|
1047
|
+
const opened = await harness.open();
|
|
1048
|
+
try {
|
|
1049
|
+
if (relationCase.needs === "faults" && opened.faults === undefined) {
|
|
1050
|
+
return { skipped: SKIP_REASONS.faults };
|
|
1051
|
+
}
|
|
1052
|
+
await relationCase.run({
|
|
1053
|
+
store: opened.store,
|
|
1054
|
+
faults: opened.faults ?? null
|
|
1055
|
+
});
|
|
1056
|
+
return { passed: true };
|
|
1057
|
+
} finally {
|
|
1058
|
+
await opened.close?.();
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function describeRelationStores(options) {
|
|
1062
|
+
describeSuite({
|
|
1063
|
+
title: `${options.name} — @nxgt/janus relation conformance`,
|
|
1064
|
+
cases: allRelationCases,
|
|
1065
|
+
run: (relationCase) => runRelationCase(relationCase, options.harness),
|
|
1066
|
+
runner: options.runner ?? fromGlobals("describeRelationStores"),
|
|
1067
|
+
skip: options.skip ?? {},
|
|
1068
|
+
faults: options.faults
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
function referenceRelationHarness() {
|
|
1072
|
+
return {
|
|
1073
|
+
async open() {
|
|
1074
|
+
const failing = new Set;
|
|
1075
|
+
const inner = createMemoryRelations2();
|
|
1076
|
+
const store = Object.fromEntries(Object.entries(inner).map(([method, fn]) => [
|
|
1077
|
+
method,
|
|
1078
|
+
(...args) => failing.has(method) ? Promise.reject(new StoreFailure2(`relations.${method}: the store could not answer`, {
|
|
1079
|
+
slot: "relations",
|
|
1080
|
+
operation: method
|
|
1081
|
+
})) : fn.apply(inner, args)
|
|
1082
|
+
]));
|
|
1083
|
+
return {
|
|
1084
|
+
store,
|
|
1085
|
+
faults: {
|
|
1086
|
+
async fail(method) {
|
|
1087
|
+
failing.add(method);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
export {
|
|
1095
|
+
SKIP_REASONS,
|
|
1096
|
+
allCases,
|
|
1097
|
+
allRelationCases,
|
|
1098
|
+
describeJanusStores,
|
|
1099
|
+
describeRelationStores,
|
|
1100
|
+
outageCases,
|
|
1101
|
+
referenceHarness,
|
|
1102
|
+
referenceRelationHarness,
|
|
1103
|
+
relationOutageCases,
|
|
1104
|
+
relationStoreCases,
|
|
1105
|
+
runCase,
|
|
1106
|
+
runRelationCase,
|
|
1107
|
+
sessionStoreCases,
|
|
1108
|
+
tokenStoreCases,
|
|
1109
|
+
userStoreCases
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
//# debugId=26762626E8104F4164756E2164756E21
|
|
1113
|
+
//# sourceMappingURL=index.js.map
|