@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.
@@ -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 { runPlaylistzTests } from "./test/playlistz.js";
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 playlistz domain tests (pure - no server needed)
26
- const playlistzResults = await runPlaylistzTests();
27
- totalPassed += playlistzResults.passed;
28
- totalFailed += playlistzResults.failed;
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
- // swallow; try again next tick. callers can fail-fast by
315
- // observing snapshot errors separately via snapshotJobEvents.
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>();
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["src/**/*.test.ts"],
7
+ },
8
+ });