@freqhole/api-client 0.1.31 → 0.2.1
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/package.json +7 -17
- package/src/CharnelTransport.ts +7 -7
- package/src/WasmTransport.ts +23 -22
- package/src/domains/admin.ts +23 -0
- package/src/permissions.ts +16 -29
- package/src/pollingJobEvents.test.ts +192 -0
- package/src/test/permissions.ts +185 -0
- package/src/test.ts +5 -23
- package/src/transport.ts +10 -3
- package/vitest.config.ts +8 -0
- package/src/automerge/IrohNetworkAdapter.ts +0 -727
- package/src/automerge/index.ts +0 -14
- package/src/storage/blobs.ts +0 -272
- package/src/storage/identity.ts +0 -190
- package/src/storage/index.ts +0 -5
- package/src/storage/webLocks.ts +0 -92
- package/src/test/automerge.ts +0 -796
- package/src/test/playlistz.ts +0 -631
- package/src/test/storage.ts +0 -443
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// characterization tests for permission outcomes - written against the
|
|
2
|
+
// current implementation before it's rebound onto
|
|
3
|
+
// @freqhole/haruspex/permissions, so the rebind can be verified as a
|
|
4
|
+
// pure internal refactor (same public behavior, different plumbing).
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import {
|
|
7
|
+
canAccessOwner,
|
|
8
|
+
canAccessOwnerOr,
|
|
9
|
+
canAccessRole,
|
|
10
|
+
canAccessRoute,
|
|
11
|
+
canCreatePlaylist,
|
|
12
|
+
canDeleteAlbum,
|
|
13
|
+
canDeletePlaylist,
|
|
14
|
+
canDeleteSong,
|
|
15
|
+
canReorderPlaylistSongs,
|
|
16
|
+
canUpdatePlaylist,
|
|
17
|
+
isAdmin,
|
|
18
|
+
isMemberOrHigher,
|
|
19
|
+
} from "../permissions.js";
|
|
20
|
+
import type { RouteAuth } from "../codegen/routes.js";
|
|
21
|
+
|
|
22
|
+
interface Case {
|
|
23
|
+
name: string;
|
|
24
|
+
run: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cases: Case[] = [
|
|
28
|
+
{
|
|
29
|
+
name: "canAccessRole: exact role match allowed",
|
|
30
|
+
run: () => assert.equal(canAccessRole("member", "member"), true),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "canAccessRole: higher-privilege role satisfies a lower requirement",
|
|
34
|
+
run: () => {
|
|
35
|
+
assert.equal(canAccessRole("admin", "member"), true);
|
|
36
|
+
assert.equal(canAccessRole("root", "admin"), true);
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "canAccessRole: lower-privilege role rejected",
|
|
41
|
+
run: () => {
|
|
42
|
+
assert.equal(canAccessRole("viewer", "member"), false);
|
|
43
|
+
assert.equal(canAccessRole("member", "admin"), false);
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "canAccessOwner: matching id allowed, mismatched/null rejected",
|
|
48
|
+
run: () => {
|
|
49
|
+
assert.equal(canAccessOwner("user-1", "user-1"), true);
|
|
50
|
+
assert.equal(canAccessOwner("user-1", "user-2"), false);
|
|
51
|
+
assert.equal(canAccessOwner("user-1", null), false);
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "canAccessOwnerOr: owner passes regardless of role",
|
|
56
|
+
run: () => assert.equal(canAccessOwnerOr("user-1", "user-1", "viewer", "admin"), true),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "canAccessOwnerOr: non-owner needs a sufficient role",
|
|
60
|
+
run: () => {
|
|
61
|
+
assert.equal(canAccessOwnerOr("user-1", "user-2", "admin", "admin"), true);
|
|
62
|
+
assert.equal(canAccessOwnerOr("user-1", "user-2", "member", "admin"), false);
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "canAccessRoute: public always allowed",
|
|
67
|
+
run: () => assert.equal(canAccessRoute({ type: "public" }, null, null, null), true),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "canAccessRoute: authenticated requires any signed-in role",
|
|
71
|
+
run: () => {
|
|
72
|
+
const auth: RouteAuth = { type: "authenticated" };
|
|
73
|
+
assert.equal(canAccessRoute(auth, "viewer", "user-1", null), true);
|
|
74
|
+
assert.equal(canAccessRoute(auth, null, null, null), false);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "canAccessRoute: role requires the minimum role",
|
|
79
|
+
run: () => {
|
|
80
|
+
const auth: RouteAuth = { type: "role", role: "admin" };
|
|
81
|
+
assert.equal(canAccessRoute(auth, "admin", "user-1", null), true);
|
|
82
|
+
assert.equal(canAccessRoute(auth, "member", "user-1", null), false);
|
|
83
|
+
assert.equal(canAccessRoute(auth, null, null, null), false);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "canAccessRoute: owner requires the exact owner id",
|
|
88
|
+
run: () => {
|
|
89
|
+
const auth: RouteAuth = { type: "owner" };
|
|
90
|
+
assert.equal(canAccessRoute(auth, "viewer", "user-1", "user-1"), true);
|
|
91
|
+
assert.equal(canAccessRoute(auth, "viewer", "user-1", "user-2"), false);
|
|
92
|
+
assert.equal(canAccessRoute(auth, null, null, "user-2"), false);
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "canAccessRoute: owner_or allows the owner or a sufficient role",
|
|
97
|
+
run: () => {
|
|
98
|
+
const auth: RouteAuth = { type: "owner_or", role: "admin" };
|
|
99
|
+
assert.equal(canAccessRoute(auth, "viewer", "user-1", "user-1"), true);
|
|
100
|
+
assert.equal(canAccessRoute(auth, "admin", "user-2", "user-1"), true);
|
|
101
|
+
assert.equal(canAccessRoute(auth, "member", "user-2", "user-1"), false);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "canDeletePlaylist: owner_or(admin) - owner or admin allowed, others rejected",
|
|
106
|
+
run: () => {
|
|
107
|
+
assert.equal(canDeletePlaylist("user-1", "user-1", "viewer"), true);
|
|
108
|
+
assert.equal(canDeletePlaylist("user-2", "user-1", "admin"), true);
|
|
109
|
+
assert.equal(canDeletePlaylist("user-2", "user-1", "member"), false);
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "canUpdatePlaylist: same owner_or(admin) shape as delete",
|
|
114
|
+
run: () => {
|
|
115
|
+
assert.equal(canUpdatePlaylist("user-1", "user-1", "viewer"), true);
|
|
116
|
+
assert.equal(canUpdatePlaylist("user-2", "user-1", "member"), false);
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "canReorderPlaylistSongs: same owner_or(admin) shape",
|
|
121
|
+
run: () => {
|
|
122
|
+
assert.equal(canReorderPlaylistSongs("user-1", "user-1", "viewer"), true);
|
|
123
|
+
assert.equal(canReorderPlaylistSongs("user-2", "user-1", "member"), false);
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: "canDeleteSong: role(admin) - only admin+ allowed",
|
|
128
|
+
run: () => {
|
|
129
|
+
assert.equal(canDeleteSong("admin"), true);
|
|
130
|
+
assert.equal(canDeleteSong("member"), false);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "canDeleteAlbum: role(admin)",
|
|
135
|
+
run: () => {
|
|
136
|
+
assert.equal(canDeleteAlbum("root"), true);
|
|
137
|
+
assert.equal(canDeleteAlbum("viewer"), false);
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "canCreatePlaylist: role(member) - member+ allowed, viewer rejected",
|
|
142
|
+
run: () => {
|
|
143
|
+
assert.equal(canCreatePlaylist("member"), true);
|
|
144
|
+
assert.equal(canCreatePlaylist("admin"), true);
|
|
145
|
+
assert.equal(canCreatePlaylist("viewer"), false);
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "isMemberOrHigher / isAdmin convenience checks",
|
|
150
|
+
run: () => {
|
|
151
|
+
assert.equal(isMemberOrHigher("member"), true);
|
|
152
|
+
assert.equal(isMemberOrHigher("viewer"), false);
|
|
153
|
+
assert.equal(isAdmin("admin"), true);
|
|
154
|
+
assert.equal(isAdmin("member"), false);
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
export async function runPermissionsTests(): Promise<{ passed: number; failed: number }> {
|
|
160
|
+
let passed = 0;
|
|
161
|
+
let failed = 0;
|
|
162
|
+
const errors: string[] = [];
|
|
163
|
+
|
|
164
|
+
console.log("running permissions characterization tests...\n");
|
|
165
|
+
|
|
166
|
+
for (const testCase of cases) {
|
|
167
|
+
try {
|
|
168
|
+
testCase.run();
|
|
169
|
+
console.log(`PASS ${testCase.name}`);
|
|
170
|
+
passed++;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
errors.push(`FAIL ${testCase.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
173
|
+
failed++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (errors.length > 0) {
|
|
178
|
+
console.log("\nerrors found:\n");
|
|
179
|
+
errors.forEach((err) => console.log(err));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(`\n${passed} passed, ${failed} failed\n`);
|
|
183
|
+
|
|
184
|
+
return { passed, failed };
|
|
185
|
+
}
|
package/src/test.ts
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
import { validateWrapperCoverage } from "./test/coverage.js";
|
|
3
3
|
import { runIntegrationTests } from "./test/integration.js";
|
|
4
4
|
import { runStatefulTests } from "./test/stateful.js";
|
|
5
|
-
import {
|
|
6
|
-
import { runAutomergeTests } from "./test/automerge.js";
|
|
7
|
-
import { runStorageTests } from "./test/storage.js";
|
|
5
|
+
import { runPermissionsTests } from "./test/permissions.js";
|
|
8
6
|
|
|
9
7
|
async function main() {
|
|
10
8
|
console.log("freqhole api client test suite\n");
|
|
@@ -22,26 +20,10 @@ async function main() {
|
|
|
22
20
|
console.log("=".repeat(50));
|
|
23
21
|
console.log("");
|
|
24
22
|
|
|
25
|
-
// always run
|
|
26
|
-
const
|
|
27
|
-
totalPassed +=
|
|
28
|
-
totalFailed +=
|
|
29
|
-
|
|
30
|
-
console.log("=".repeat(50));
|
|
31
|
-
console.log("");
|
|
32
|
-
|
|
33
|
-
// automerge adapter unit tests (no server required)
|
|
34
|
-
const automergeResults = await runAutomergeTests();
|
|
35
|
-
totalPassed += automergeResults.passed;
|
|
36
|
-
totalFailed += automergeResults.failed;
|
|
37
|
-
|
|
38
|
-
console.log("=".repeat(50));
|
|
39
|
-
console.log("");
|
|
40
|
-
|
|
41
|
-
// run storage unit tests (no server required)
|
|
42
|
-
const storageResults = await runStorageTests();
|
|
43
|
-
totalPassed += storageResults.passed;
|
|
44
|
-
totalFailed += storageResults.failed;
|
|
23
|
+
// always run permissions characterization tests (no server needed)
|
|
24
|
+
const permissionsResults = await runPermissionsTests();
|
|
25
|
+
totalPassed += permissionsResults.passed;
|
|
26
|
+
totalFailed += permissionsResults.failed;
|
|
45
27
|
|
|
46
28
|
console.log("=".repeat(50));
|
|
47
29
|
console.log("");
|
package/src/transport.ts
CHANGED
|
@@ -307,12 +307,19 @@ export async function* pollingJobEvents(
|
|
|
307
307
|
let first = true;
|
|
308
308
|
while (true) {
|
|
309
309
|
if (signal?.aborted) return;
|
|
310
|
-
let snaps: JobStateSnapshot[]
|
|
310
|
+
let snaps: JobStateSnapshot[];
|
|
311
311
|
try {
|
|
312
312
|
snaps = await snapshotJobEventsViaRequest(transport, filter);
|
|
313
313
|
} catch {
|
|
314
|
-
//
|
|
315
|
-
//
|
|
314
|
+
// a transient fetch failure must not wipe `prev` - doing so would
|
|
315
|
+
// make every still-active job look "new" again on the next
|
|
316
|
+
// successful tick (a spurious `from: null` status_changed for
|
|
317
|
+
// jobs that never actually changed). skip this tick's diff
|
|
318
|
+
// entirely and retry with the same baseline next time. callers
|
|
319
|
+
// can fail-fast by observing snapshot errors separately via
|
|
320
|
+
// snapshotJobEvents.
|
|
321
|
+
await sleepOrAbort(POLL_INTERVAL_FALLBACK_MS, signal);
|
|
322
|
+
continue;
|
|
316
323
|
}
|
|
317
324
|
if (!first) {
|
|
318
325
|
const next = new Map<string, JobStateSnapshot>();
|