@lightsparkdev/core 1.3.0 → 1.4.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.
@@ -0,0 +1,347 @@
1
+ import { beforeEach, jest } from "@jest/globals";
2
+
3
+ import type { Client as WsClient } from "graphql-ws";
4
+ import type AuthProvider from "../../auth/AuthProvider.js";
5
+ import type { CryptoInterface } from "../../crypto/crypto.js";
6
+ import type NodeKeyCache from "../../crypto/NodeKeyCache.js";
7
+ import type { SigningKey } from "../../crypto/SigningKey.js";
8
+ import { SigningKeyType } from "../../crypto/types.js";
9
+ import LightsparkException from "../../LightsparkException.js";
10
+ import type Query from "../Query.js";
11
+
12
+ /* Mocking ESM modules (when running node with --experimental-vm-modules)
13
+ requires unstable_mockModule, see https://bit.ly/433nRV1 */
14
+ await jest.unstable_mockModule("graphql-ws", () => ({
15
+ __esModule: true,
16
+ createClient: jest.fn(),
17
+ }));
18
+ /* Since Requester uses graphql-ws we need a dynamic import after the above mock */
19
+ const { Requester } = await import("../index.js");
20
+
21
+ describe("Requester", () => {
22
+ const schemaEndpoint = "graphql";
23
+ const sdkUserAgent = "test-agent";
24
+ const baseUrl = "https://api.example.com";
25
+
26
+ let nodeKeyCache: NodeKeyCache;
27
+ let authProvider: AuthProvider;
28
+ let signingKey: SigningKey;
29
+ let cryptoImpl: CryptoInterface;
30
+ let fetchImpl: typeof fetch;
31
+
32
+ beforeEach(() => {
33
+ nodeKeyCache = {
34
+ getKey: jest.fn(),
35
+ hasKey: jest.fn(),
36
+ } as unknown as NodeKeyCache;
37
+
38
+ authProvider = {
39
+ addAuthHeaders: jest.fn(async (headers: Record<string, string>) => ({
40
+ ...headers,
41
+ "X-Test": "1",
42
+ })),
43
+ isAuthorized: jest.fn(async () => true),
44
+ addWsConnectionParams: jest.fn(
45
+ async (params: Record<string, unknown>) => ({
46
+ ...params,
47
+ ws: true,
48
+ }),
49
+ ),
50
+ } satisfies AuthProvider;
51
+
52
+ signingKey = {
53
+ type: SigningKeyType.RSASigningKey,
54
+ sign: jest.fn(async (data: Uint8Array) => new Uint8Array([1, 2, 3])),
55
+ } satisfies SigningKey;
56
+
57
+ cryptoImpl = {
58
+ decryptSecretWithNodePassword: jest.fn(async () => new ArrayBuffer(0)),
59
+ generateSigningKeyPair: jest.fn(async () => ({
60
+ publicKey: "",
61
+ privateKey: "",
62
+ })),
63
+ serializeSigningKey: jest.fn(async () => new ArrayBuffer(0)),
64
+ getNonce: jest.fn(async () => 123),
65
+ sign: jest.fn(async () => new ArrayBuffer(0)),
66
+ importPrivateSigningKey: jest.fn(async () => ""),
67
+ } satisfies CryptoInterface;
68
+
69
+ fetchImpl = jest.fn(
70
+ async () =>
71
+ ({
72
+ ok: true,
73
+ json: async () => ({ data: { foo: "bar" }, errors: undefined }),
74
+ statusText: "OK",
75
+ }) as Response,
76
+ );
77
+ });
78
+
79
+ it("constructs without error", () => {
80
+ expect(
81
+ () =>
82
+ new Requester(
83
+ nodeKeyCache,
84
+ schemaEndpoint,
85
+ sdkUserAgent,
86
+ authProvider,
87
+ baseUrl,
88
+ cryptoImpl,
89
+ signingKey,
90
+ fetchImpl,
91
+ ),
92
+ ).not.toThrow();
93
+ });
94
+
95
+ describe("executeQuery", () => {
96
+ it("calls makeRawRequest and returns constructed object", async () => {
97
+ const requester = new Requester(
98
+ nodeKeyCache,
99
+ schemaEndpoint,
100
+ sdkUserAgent,
101
+ authProvider,
102
+ baseUrl,
103
+ cryptoImpl,
104
+ signingKey,
105
+ fetchImpl,
106
+ );
107
+ const query: Query<{ foo: string }> = {
108
+ queryPayload: "query TestQuery { foo }",
109
+ variables: { a: 1 },
110
+ constructObject: (rawData) => ({
111
+ foo: (rawData as { foo: string }).foo,
112
+ }),
113
+ };
114
+ jest.spyOn(requester, "makeRawRequest").mockResolvedValue({ foo: "bar" });
115
+ const result = await requester.executeQuery(query);
116
+ expect(result).toEqual({ foo: "bar" });
117
+ });
118
+ });
119
+
120
+ describe("makeRawRequest", () => {
121
+ it("makes a successful request and returns data", async () => {
122
+ const requester = new Requester(
123
+ nodeKeyCache,
124
+ schemaEndpoint,
125
+ sdkUserAgent,
126
+ authProvider,
127
+ baseUrl,
128
+ cryptoImpl,
129
+ signingKey,
130
+ fetchImpl,
131
+ );
132
+ const result = await requester.makeRawRequest("query TestQuery { foo }", {
133
+ a: 1,
134
+ });
135
+ expect(result).toEqual({ foo: "bar" });
136
+ expect(fetchImpl).toHaveBeenCalled();
137
+ });
138
+
139
+ it("throws on invalid query", async () => {
140
+ const requester = new Requester(
141
+ nodeKeyCache,
142
+ schemaEndpoint,
143
+ sdkUserAgent,
144
+ authProvider,
145
+ baseUrl,
146
+ cryptoImpl,
147
+ signingKey,
148
+ fetchImpl,
149
+ );
150
+ await expect(requester.makeRawRequest("invalid", {})).rejects.toThrow(
151
+ LightsparkException,
152
+ );
153
+ });
154
+
155
+ it("throws on subscription query", async () => {
156
+ const requester = new Requester(
157
+ nodeKeyCache,
158
+ schemaEndpoint,
159
+ sdkUserAgent,
160
+ authProvider,
161
+ baseUrl,
162
+ cryptoImpl,
163
+ signingKey,
164
+ fetchImpl,
165
+ );
166
+ await expect(
167
+ requester.makeRawRequest("subscription TestSub { foo }", {}),
168
+ ).rejects.toThrow(LightsparkException);
169
+ });
170
+
171
+ it("throws on failed response", async () => {
172
+ fetchImpl = jest.fn(
173
+ async () =>
174
+ ({
175
+ ok: false,
176
+ statusText: "Bad Request",
177
+ json: async () => ({
178
+ errors: [
179
+ { message: "fail", extensions: { error_name: "TestError" } },
180
+ ],
181
+ }),
182
+ }) as Response,
183
+ );
184
+ const requester = new Requester(
185
+ nodeKeyCache,
186
+ schemaEndpoint,
187
+ sdkUserAgent,
188
+ authProvider,
189
+ baseUrl,
190
+ cryptoImpl,
191
+ signingKey,
192
+ fetchImpl,
193
+ );
194
+ await expect(
195
+ requester.makeRawRequest("query TestQuery { foo }", {}),
196
+ ).rejects.toThrow(LightsparkException);
197
+ });
198
+
199
+ it("throws if response has no data and errors", async () => {
200
+ fetchImpl = jest.fn(
201
+ async () =>
202
+ ({
203
+ ok: true,
204
+ json: async () => ({
205
+ data: undefined,
206
+ errors: [
207
+ { message: "fail", extensions: { error_name: "TestError" } },
208
+ ],
209
+ }),
210
+ statusText: "OK",
211
+ }) as Response,
212
+ );
213
+ const requester = new Requester(
214
+ nodeKeyCache,
215
+ schemaEndpoint,
216
+ sdkUserAgent,
217
+ authProvider,
218
+ baseUrl,
219
+ cryptoImpl,
220
+ signingKey,
221
+ fetchImpl,
222
+ );
223
+ await expect(
224
+ requester.makeRawRequest("query TestQuery { foo }", {}),
225
+ ).rejects.toThrow(LightsparkException);
226
+ });
227
+ });
228
+
229
+ describe("subscribe", () => {
230
+ it("throws on mutation query", () => {
231
+ const requester = new Requester(
232
+ nodeKeyCache,
233
+ schemaEndpoint,
234
+ sdkUserAgent,
235
+ authProvider,
236
+ baseUrl,
237
+ cryptoImpl,
238
+ signingKey,
239
+ fetchImpl,
240
+ );
241
+ expect(() =>
242
+ requester.subscribe("mutation TestMutation { foo }"),
243
+ ).toThrow(LightsparkException);
244
+ });
245
+
246
+ it("throws on invalid query", () => {
247
+ const requester = new Requester(
248
+ nodeKeyCache,
249
+ schemaEndpoint,
250
+ sdkUserAgent,
251
+ authProvider,
252
+ baseUrl,
253
+ cryptoImpl,
254
+ signingKey,
255
+ fetchImpl,
256
+ );
257
+ expect(() => requester.subscribe("invalid")).toThrow(LightsparkException);
258
+ });
259
+
260
+ it("returns an Observable for a valid subscription", async () => {
261
+ // Mock wsClient and its subscribe method
262
+ const wsClient = {
263
+ subscribe: jest.fn(
264
+ (
265
+ _body,
266
+ handlers: { next?: (data: unknown) => void; complete?: () => void },
267
+ ) => {
268
+ setTimeout(() => {
269
+ handlers.next?.({ data: { foo: "bar" } });
270
+ handlers.complete?.();
271
+ }, 10);
272
+ return jest.fn();
273
+ },
274
+ ),
275
+ } as unknown as WsClient;
276
+
277
+ const { createClient } = await import("graphql-ws");
278
+ (createClient as jest.Mock).mockReturnValue(wsClient);
279
+
280
+ const requester = new Requester(
281
+ nodeKeyCache,
282
+ schemaEndpoint,
283
+ sdkUserAgent,
284
+ authProvider,
285
+ baseUrl,
286
+ cryptoImpl,
287
+ signingKey,
288
+ fetchImpl,
289
+ );
290
+
291
+ const observable = requester.subscribe<{ foo: string }>(
292
+ "subscription TestSub { foo }",
293
+ );
294
+
295
+ const results: { foo: string }[] = [];
296
+ await new Promise<void>((resolve) => {
297
+ observable.subscribe({
298
+ next: (data) => {
299
+ results.push(data.data);
300
+ },
301
+ complete: () => {
302
+ expect(results).toEqual([{ foo: "bar" }]);
303
+ resolve();
304
+ },
305
+ });
306
+ });
307
+
308
+ expect(wsClient.subscribe).toHaveBeenCalled();
309
+ });
310
+ });
311
+
312
+ describe("signing logic", () => {
313
+ it("adds signing headers if signingNodeId is provided", async () => {
314
+ (nodeKeyCache.getKey as jest.Mock).mockReturnValue(signingKey);
315
+ const requester = new Requester(
316
+ nodeKeyCache,
317
+ schemaEndpoint,
318
+ sdkUserAgent,
319
+ authProvider,
320
+ baseUrl,
321
+ cryptoImpl,
322
+ undefined,
323
+ fetchImpl,
324
+ );
325
+ const spy = jest.spyOn(signingKey, "sign");
326
+ await requester.makeRawRequest("query TestQuery { foo }", {}, "node123");
327
+ expect(spy).toHaveBeenCalled();
328
+ });
329
+
330
+ it("throws if signingKey is missing", async () => {
331
+ (nodeKeyCache.getKey as jest.Mock).mockReturnValue(undefined);
332
+ const requester = new Requester(
333
+ nodeKeyCache,
334
+ schemaEndpoint,
335
+ sdkUserAgent,
336
+ authProvider,
337
+ baseUrl,
338
+ cryptoImpl,
339
+ undefined,
340
+ fetchImpl,
341
+ );
342
+ await expect(
343
+ requester.makeRawRequest("query TestQuery { foo }", {}, "node123"),
344
+ ).rejects.toThrow();
345
+ });
346
+ });
347
+ });
@@ -1,3 +1,4 @@
1
+ import { isObject } from "./typeGuards.js";
1
2
  import { type JSONType } from "./types.js";
2
3
 
3
4
  export const isError = (e: unknown): e is Error => {
@@ -35,15 +36,35 @@ export const isErrorMsg = (e: unknown, msg: string) => {
35
36
  return false;
36
37
  };
37
38
 
38
- export function errorToJSON(err: unknown) {
39
+ /* Make non-enumerable properties like message and stack enumerable so they
40
+ can be handled by JSON.stringify */
41
+ function normalizeObject(obj: unknown): Record<string, unknown> {
42
+ const normalized: Record<string, unknown> = {};
43
+ if (isObject(obj)) {
44
+ const props = Object.getOwnPropertyNames(obj);
45
+ for (const prop of props) {
46
+ const objRecord = obj as Record<string, unknown>;
47
+ normalized[prop] = objRecord[prop];
48
+ }
49
+ }
50
+ return normalized;
51
+ }
52
+
53
+ export function errorToJSON(
54
+ err: unknown,
55
+ /* Enable stringifying non-primitives globally for subpaths.
56
+ Useful for enforcing single level error objects: */
57
+ stringifyObjects = false,
58
+ ) {
39
59
  if (!err) {
40
60
  return null;
41
61
  }
42
- if (
43
- typeof err === "object" &&
44
- "toJSON" in err &&
45
- typeof err.toJSON === "function"
46
- ) {
62
+
63
+ /* Objects can add standard toJSON method to determine JSON.stringify output, https://mzl.la/3Gks9zu: */
64
+ if (isObject(err) && "toJSON" in err && typeof err.toJSON === "function") {
65
+ if (stringifyObjects === true) {
66
+ return objectToJSON(err.toJSON());
67
+ }
47
68
  return err.toJSON() as JSONType;
48
69
  }
49
70
 
@@ -57,7 +78,28 @@ export function errorToJSON(err: unknown) {
57
78
  return { message: err.message };
58
79
  }
59
80
 
81
+ return objectToJSON(err);
82
+ }
83
+
84
+ function objectToJSON(obj: unknown) {
85
+ const normalizedObj = normalizeObject(obj);
60
86
  return JSON.parse(
61
- JSON.stringify(err, Object.getOwnPropertyNames(err)),
87
+ JSON.stringify(normalizedObj, (key, value: unknown) => {
88
+ /* Initial call passes the top level object with empty key: */
89
+ if (key === "") {
90
+ return value;
91
+ }
92
+
93
+ const objProps = Object.getOwnPropertyNames(normalizedObj);
94
+ if (!objProps.includes(key)) {
95
+ return undefined;
96
+ }
97
+
98
+ if (isObject(value)) {
99
+ return JSON.stringify(value);
100
+ }
101
+
102
+ return value;
103
+ }),
62
104
  ) as JSONType;
63
105
  }
@@ -1,15 +1,31 @@
1
1
  import { type ConfigKeys } from "../constants/index.js";
2
2
 
3
3
  export function getLocalStorageConfigItem(key: ConfigKeys) {
4
- return getLocalStorageBoolean(key);
4
+ const localStorageBoolean = getLocalStorageBoolean(key);
5
+ // If config not set, just default to false
6
+ if (localStorageBoolean == null) {
7
+ return false;
8
+ }
9
+
10
+ return localStorageBoolean;
5
11
  }
6
12
 
7
13
  export function getLocalStorageBoolean(key: string) {
8
14
  /* localStorage is not available in all contexts, use try/catch: */
9
15
  try {
10
- return localStorage.getItem(key) === "1";
16
+ if (localStorage.getItem(key) === "1") {
17
+ return true;
18
+ }
19
+ // Key is not set
20
+ else if (localStorage.getItem(key) == null) {
21
+ return null;
22
+ }
23
+ // Key is set but not "1"
24
+ else {
25
+ return false;
26
+ }
11
27
  } catch (e) {
12
- return false;
28
+ return null;
13
29
  }
14
30
  }
15
31
 
@@ -2,7 +2,16 @@ export function isUint8Array(value: unknown): value is Uint8Array {
2
2
  return value instanceof Uint8Array;
3
3
  }
4
4
 
5
- export function isObject(value: unknown): value is Record<string, unknown> {
5
+ export function isObject(value: unknown): value is object {
6
6
  const type = typeof value;
7
7
  return value != null && (type == "object" || type == "function");
8
8
  }
9
+
10
+ export function isRecord(value: unknown): value is Record<string, unknown> {
11
+ return (
12
+ typeof value === "object" &&
13
+ value !== null &&
14
+ !Array.isArray(value) &&
15
+ Object.prototype.toString.call(value) === "[object Object]"
16
+ );
17
+ }
@@ -59,7 +59,3 @@ export type Complete<T> = { [P in keyof T]-?: NonNullable<T[P]> };
59
59
  export type RequiredKeys<T> = {
60
60
  [K in keyof T]-?: Record<string, never> extends Pick<T, K> ? never : K;
61
61
  }[keyof T];
62
-
63
- export function isRecord(value: unknown): value is Record<string, unknown> {
64
- return typeof value === "object" && value !== null && !Array.isArray(value);
65
- }