@truenas/api-client 6.0.1 → 6.0.2
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/README.md +144 -217
- package/dist/chunk-7QVRLQIZ.js +4211 -0
- package/dist/chunk-7QVRLQIZ.js.map +1 -0
- package/dist/chunk-PQIVBXN3.cjs +4251 -0
- package/dist/chunk-PQIVBXN3.cjs.map +1 -0
- package/dist/index.cjs +130 -4343
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -31436
- package/dist/index.d.ts +29 -31436
- package/dist/index.js +1 -4324
- package/dist/index.js.map +1 -1
- package/dist/testing/index.cjs +729 -0
- package/dist/testing/index.cjs.map +1 -0
- package/dist/testing/index.d.cts +483 -0
- package/dist/testing/index.d.ts +483 -0
- package/dist/testing/index.js +716 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/truenas-auth-mechanism.enum-DikfjVxa.d.cts +30936 -0
- package/dist/truenas-auth-mechanism.enum-DikfjVxa.d.ts +30936 -0
- package/package.json +23 -2
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import { SUPPORTED_API_VERSIONS, TrueNasConnection, noopLogger, TrueNasAuthenticator, parseApiVersion, clientClassFor } from '../chunk-7QVRLQIZ.js';
|
|
2
|
+
export { AuthResponseType } from '../chunk-7QVRLQIZ.js';
|
|
3
|
+
import { Subject, distinctUntilChanged, takeUntil, filter, map, Subscription } from 'rxjs';
|
|
4
|
+
|
|
5
|
+
// src/testing/unmocked-call-error.ts
|
|
6
|
+
var UnmockedCallError = class extends Error {
|
|
7
|
+
constructor(method, params) {
|
|
8
|
+
super(
|
|
9
|
+
`Unmocked call: nothing is scripted to answer '${method}'${params === void 0 ? "" : ` with ${JSON.stringify(params)}`}. Script it with mock.call / mock.query / mock.job, answer the frame with connection.reply, or build the client without { strict: true }.`
|
|
10
|
+
);
|
|
11
|
+
this.method = method;
|
|
12
|
+
this.params = params;
|
|
13
|
+
this.name = "UnmockedCallError";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/testing/fake-connection.ts
|
|
18
|
+
var CLIENT_HOUSEKEEPING = /* @__PURE__ */ new Set(["core.subscribe"]);
|
|
19
|
+
var FakeConnection = class extends TrueNasConnection {
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
super(
|
|
22
|
+
// Disabled: `connection$` derives from this, so no socket is ever built
|
|
23
|
+
// and none of the retry machinery runs.
|
|
24
|
+
false,
|
|
25
|
+
[],
|
|
26
|
+
options.uuid ?? "fake-uuid",
|
|
27
|
+
options.websocketPath ?? "/api/current",
|
|
28
|
+
void 0,
|
|
29
|
+
0,
|
|
30
|
+
0,
|
|
31
|
+
noopLogger
|
|
32
|
+
);
|
|
33
|
+
this.incoming = new Subject();
|
|
34
|
+
this.frames = [];
|
|
35
|
+
/** Frames handed to `send` while closed, waiting for the socket. */
|
|
36
|
+
this.queued = [];
|
|
37
|
+
this.autoReplies = /* @__PURE__ */ new Map();
|
|
38
|
+
/** Set by `close()`. Latched, because the real connection cannot come back. */
|
|
39
|
+
this.terminated = false;
|
|
40
|
+
/**
|
|
41
|
+
* The canonical streams, re-pointed at what this class actually drives.
|
|
42
|
+
*
|
|
43
|
+
* The base derives all three from `connection$`, which on a disabled
|
|
44
|
+
* connection reports one state and then nothing: `{ state: 'closed' }`. So
|
|
45
|
+
* they describe the connection that never connects, not the one the test is
|
|
46
|
+
* driving — `messages$` stayed silent while `receive` delivered, `opened$`
|
|
47
|
+
* stayed `[false]` through every simulated open, and `closed$` reported a
|
|
48
|
+
* connection that had never been up. `messages()` is a shim over `messages$`;
|
|
49
|
+
* leaving them disagreeing puts the divergence in the members a consumer is
|
|
50
|
+
* most likely to reach for.
|
|
51
|
+
*/
|
|
52
|
+
this.messages$ = this.incoming.asObservable();
|
|
53
|
+
this.opened$ = this.opened.pipe(
|
|
54
|
+
distinctUntilChanged(),
|
|
55
|
+
takeUntil(this.closeConnection)
|
|
56
|
+
);
|
|
57
|
+
this.closed$ = this.opened.pipe(
|
|
58
|
+
distinctUntilChanged(),
|
|
59
|
+
filter((isOpen) => !isOpen),
|
|
60
|
+
map(() => void 0),
|
|
61
|
+
takeUntil(this.closeConnection)
|
|
62
|
+
);
|
|
63
|
+
this.strict = options.strict ?? false;
|
|
64
|
+
if (options.opened ?? true) this.simulateOpen();
|
|
65
|
+
}
|
|
66
|
+
/** Every frame handed to `send`, in order. */
|
|
67
|
+
get sent() {
|
|
68
|
+
return this.frames;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Records the frame, or queues it until the connection opens.
|
|
72
|
+
*
|
|
73
|
+
* The real `send` queues on `ws$` until a socket exists and returns the
|
|
74
|
+
* subscription holding that queue open; a caller who gives up unsubscribes
|
|
75
|
+
* it and the frame never goes out. Both halves are reproduced, because
|
|
76
|
+
* "records everything regardless of `opened`" is precisely the divergence
|
|
77
|
+
* the hand-rolled double had — it forwarded straight through — and moving it
|
|
78
|
+
* in here would have been the same bug in a better-looking place.
|
|
79
|
+
*/
|
|
80
|
+
send(message) {
|
|
81
|
+
if (this.strict && message.method !== void 0 && !this.autoReplies.has(message.method) && !CLIENT_HOUSEKEEPING.has(message.method)) {
|
|
82
|
+
throw new UnmockedCallError(message.method, message.params);
|
|
83
|
+
}
|
|
84
|
+
if (this.terminated) {
|
|
85
|
+
const dropped = new Subscription();
|
|
86
|
+
dropped.unsubscribe();
|
|
87
|
+
return dropped;
|
|
88
|
+
}
|
|
89
|
+
if (!this.opened.getValue()) {
|
|
90
|
+
this.queued.push(message);
|
|
91
|
+
return new Subscription(() => {
|
|
92
|
+
const at = this.queued.indexOf(message);
|
|
93
|
+
if (at >= 0) this.queued.splice(at, 1);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
this.write(message);
|
|
97
|
+
const done = new Subscription();
|
|
98
|
+
done.unsubscribe();
|
|
99
|
+
return done;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Answer any frame for `method` as soon as it is written.
|
|
103
|
+
*
|
|
104
|
+
* The narrow ancestor of `mockCall`: enough to let a collaborator that talks
|
|
105
|
+
* to the appliance — the authenticator, for one — run its real code against
|
|
106
|
+
* a scripted answer rather than be replaced by a fake of itself.
|
|
107
|
+
*
|
|
108
|
+
* One answer per method, last registration wins. The one it replaced is
|
|
109
|
+
* returned so a caller that needs to share the method can chain to it rather
|
|
110
|
+
* than take it over silently.
|
|
111
|
+
*/
|
|
112
|
+
autoReply(method, answer) {
|
|
113
|
+
const replaced = this.autoReplies.get(method);
|
|
114
|
+
this.autoReplies.set(method, answer);
|
|
115
|
+
return replaced;
|
|
116
|
+
}
|
|
117
|
+
write(message) {
|
|
118
|
+
this.frames.push(message);
|
|
119
|
+
const answer = this.autoReplies.get(String(message.method));
|
|
120
|
+
if (!answer) return;
|
|
121
|
+
queueMicrotask(() => answer(message));
|
|
122
|
+
}
|
|
123
|
+
/** Deliver a message as if middleware had sent it. */
|
|
124
|
+
receive(message) {
|
|
125
|
+
this.incoming.next(message);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Answer the most recent frame carrying `method`, by its id.
|
|
129
|
+
*
|
|
130
|
+
* Ids are what correlate a reply to a request, and a test that had to read
|
|
131
|
+
* them itself would be asserting on the id generator. Most recent rather
|
|
132
|
+
* than first: a spec that calls the same method twice is asking about the
|
|
133
|
+
* second one.
|
|
134
|
+
*/
|
|
135
|
+
reply(method, result) {
|
|
136
|
+
this.receive({ jsonrpc: "2.0", id: this.idOf(method), result });
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* As {@link reply}, with a JSON-RPC error instead of a result.
|
|
140
|
+
*
|
|
141
|
+
* Typed as the frame `/api/<version>` sends and nothing else: `code` and
|
|
142
|
+
* `message` outside, the TrueNAS payload under `data`. The legacy
|
|
143
|
+
* `/websocket` shape — those payload fields at the top level — is not
|
|
144
|
+
* accepted, so a spec cannot script an error this client can never receive.
|
|
145
|
+
* It was accepted until now only because the parameter was the loose
|
|
146
|
+
* `ApiError` union, which admits both.
|
|
147
|
+
*
|
|
148
|
+
* The cast is gone with it: the frame's own type says this is what an error
|
|
149
|
+
* looks like.
|
|
150
|
+
*/
|
|
151
|
+
replyError(method, error) {
|
|
152
|
+
this.receive({ jsonrpc: "2.0", id: this.idOf(method), error });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Raise `opened` and flush whatever was queued, as the socket opening would.
|
|
156
|
+
*
|
|
157
|
+
* The queue drains after `opened` goes true, which is the order the real
|
|
158
|
+
* connection produces: `send` is waiting on `ws$`, and `ws$` emits because a
|
|
159
|
+
* socket arrived.
|
|
160
|
+
*/
|
|
161
|
+
simulateOpen() {
|
|
162
|
+
if (this.terminated || this.opened.getValue()) return;
|
|
163
|
+
this.opened.next(true);
|
|
164
|
+
const pending = this.queued.splice(0, this.queued.length);
|
|
165
|
+
for (const message of pending) this.write(message);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Lower `opened` and fire `closed`, as a socket closing would.
|
|
169
|
+
*
|
|
170
|
+
* Named for what it simulates rather than overriding `close()`, which on the
|
|
171
|
+
* real connection means "stop, and do not retry" — a different verb that
|
|
172
|
+
* callers already use for teardown.
|
|
173
|
+
*/
|
|
174
|
+
simulateClose() {
|
|
175
|
+
if (this.terminated || !this.opened.getValue()) return;
|
|
176
|
+
this.opened.next(false);
|
|
177
|
+
this.closed.next();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Terminal, as on the real connection: the streams end and nothing more is
|
|
181
|
+
* written.
|
|
182
|
+
*
|
|
183
|
+
* `simulateClose` is the socket dropping — recoverable, and the client will
|
|
184
|
+
* try again. This is the caller giving up on the connection for good, so the
|
|
185
|
+
* message stream completes and `send` has nowhere to put a frame.
|
|
186
|
+
*/
|
|
187
|
+
close() {
|
|
188
|
+
if (this.terminated) return;
|
|
189
|
+
this.terminated = true;
|
|
190
|
+
this.incoming.complete();
|
|
191
|
+
super.close();
|
|
192
|
+
}
|
|
193
|
+
idOf(method) {
|
|
194
|
+
const frame = [...this.frames].reverse().find((m) => m.method === method);
|
|
195
|
+
if (!frame) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`No frame for '${method}' has been sent; sent so far: ${this.frames.map((m) => String(m.method)).join(", ") || "(none)"}`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return frame.id ?? "";
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// src/testing/fake-authenticator.ts
|
|
205
|
+
var FakeAuthenticator = class extends TrueNasAuthenticator {
|
|
206
|
+
constructor(fakeConnection, version) {
|
|
207
|
+
super(fakeConnection, version);
|
|
208
|
+
this.fakeConnection = fakeConnection;
|
|
209
|
+
this.attempts = [];
|
|
210
|
+
/**
|
|
211
|
+
* Answers waiting for their frames, oldest first.
|
|
212
|
+
*
|
|
213
|
+
* Bound when the login is *made*, not when the reply goes out. Reading an
|
|
214
|
+
* armed answer at reply time meant a second login armed in the meantime
|
|
215
|
+
* answered the first — two logins in flight is exactly what testing
|
|
216
|
+
* supersession looks like, so scripting both is the ordinary case, and it
|
|
217
|
+
* produced the same wrong verdict this class was rewritten to stop
|
|
218
|
+
* producing.
|
|
219
|
+
*/
|
|
220
|
+
this.pending = [];
|
|
221
|
+
this.fakeConnection.autoReply("auth.login_ex", (frame) => {
|
|
222
|
+
this.fakeConnection.receive({
|
|
223
|
+
jsonrpc: "2.0",
|
|
224
|
+
id: frame.id,
|
|
225
|
+
result: this.pending.shift() ?? this.success()
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Every login attempt, in order.
|
|
231
|
+
*
|
|
232
|
+
* Including the ones the base class makes itself: the auto-relogin runs
|
|
233
|
+
* through these same methods, and nothing here distinguishes it from a
|
|
234
|
+
* caller's login. A spec counting logins across a reconnect should expect
|
|
235
|
+
* that one.
|
|
236
|
+
*/
|
|
237
|
+
get logins() {
|
|
238
|
+
return this.attempts;
|
|
239
|
+
}
|
|
240
|
+
/** The next login is answered with this response. Defaults to a success. */
|
|
241
|
+
succeedNextLogin(response = {}) {
|
|
242
|
+
this.armed = { response_type: "SUCCESS" /* Success */, ...response };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The next login is answered with a rejection.
|
|
246
|
+
*
|
|
247
|
+
* A `response_type` rather than an `Error`, so what the caller sees is
|
|
248
|
+
* whatever the real authenticator makes of it — the `AuthError` codes
|
|
249
|
+
* included, which a hand-thrown `Error` could not produce.
|
|
250
|
+
*/
|
|
251
|
+
failNextLogin(responseType = "AUTH_ERR" /* AuthErr */) {
|
|
252
|
+
this.armed = { response_type: responseType };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Records the attempt and binds its answer, in that order, before the real
|
|
256
|
+
* method puts a frame on the wire.
|
|
257
|
+
*/
|
|
258
|
+
record(mechanism, credential) {
|
|
259
|
+
this.attempts.push({ mechanism, credential });
|
|
260
|
+
this.pending.push(this.armed ?? this.success());
|
|
261
|
+
this.armed = void 0;
|
|
262
|
+
}
|
|
263
|
+
success() {
|
|
264
|
+
return { response_type: "SUCCESS" /* Success */ };
|
|
265
|
+
}
|
|
266
|
+
loginWithUserPass(username, password) {
|
|
267
|
+
this.record("PASSWORD_PLAIN" /* Password */, username);
|
|
268
|
+
return super.loginWithUserPass(username, password);
|
|
269
|
+
}
|
|
270
|
+
loginWithToken(token) {
|
|
271
|
+
this.record("TOKEN_PLAIN" /* Token */, token);
|
|
272
|
+
return super.loginWithToken(token);
|
|
273
|
+
}
|
|
274
|
+
loginWithApiKey(credentials) {
|
|
275
|
+
this.record("API_KEY_PLAIN" /* ApiKey */, credentials.username);
|
|
276
|
+
return super.loginWithApiKey(credentials);
|
|
277
|
+
}
|
|
278
|
+
loginWithOtp(code) {
|
|
279
|
+
this.record("OTP_TOKEN" /* Otp */, code);
|
|
280
|
+
return super.loginWithOtp(code);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// src/testing/present.ts
|
|
285
|
+
function present(value) {
|
|
286
|
+
return Object.fromEntries(
|
|
287
|
+
Object.entries(value).filter(([, v]) => v !== void 0)
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/testing/fake-api-error.ts
|
|
292
|
+
var BARE_REPR = /^([A-Za-z_][A-Za-z0-9_]*)\(\)$/;
|
|
293
|
+
var UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}\p{Zs}]/u;
|
|
294
|
+
function escapeCodePoint(character) {
|
|
295
|
+
if (character === "\n") return "\\n";
|
|
296
|
+
if (character === "\r") return "\\r";
|
|
297
|
+
if (character === " ") return "\\t";
|
|
298
|
+
const code = character.codePointAt(0);
|
|
299
|
+
if (code < 256) return `\\x${code.toString(16).padStart(2, "0")}`;
|
|
300
|
+
if (code < 65536) return `\\u${code.toString(16).padStart(4, "0")}`;
|
|
301
|
+
return `\\U${code.toString(16).padStart(8, "0")}`;
|
|
302
|
+
}
|
|
303
|
+
function pythonRepr(value) {
|
|
304
|
+
const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
|
|
305
|
+
let escaped = "";
|
|
306
|
+
for (const character of value) {
|
|
307
|
+
if (character === "\\") escaped += "\\\\";
|
|
308
|
+
else if (character === quote) escaped += `\\${quote}`;
|
|
309
|
+
else if (character === " " || !UNPRINTABLE.test(character)) escaped += character;
|
|
310
|
+
else escaped += escapeCodePoint(character);
|
|
311
|
+
}
|
|
312
|
+
return `${quote}${escaped}${quote}`;
|
|
313
|
+
}
|
|
314
|
+
function syntheticTrace(reason) {
|
|
315
|
+
const bare = BARE_REPR.exec(reason);
|
|
316
|
+
const cls = bare ? bare[1] : "ValueError";
|
|
317
|
+
const repr = bare ? reason : `ValueError(${pythonRepr(reason)})`;
|
|
318
|
+
return {
|
|
319
|
+
class: cls,
|
|
320
|
+
formatted: `Traceback (most recent call last):
|
|
321
|
+
<synthetic>
|
|
322
|
+
${repr}
|
|
323
|
+
`,
|
|
324
|
+
repr
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function fakeApiError(overrides = {}) {
|
|
328
|
+
const { code, message, ...data } = overrides;
|
|
329
|
+
const reason = data.reason ?? "Invalid argument";
|
|
330
|
+
return {
|
|
331
|
+
code: code ?? -32001,
|
|
332
|
+
message: message ?? "Method call error",
|
|
333
|
+
data: {
|
|
334
|
+
error: 22,
|
|
335
|
+
errname: "EINVAL",
|
|
336
|
+
reason,
|
|
337
|
+
extra: null,
|
|
338
|
+
trace: syntheticTrace(reason),
|
|
339
|
+
...present(data)
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/testing/fake-job.ts
|
|
345
|
+
function fakeJob(overrides = {}) {
|
|
346
|
+
const progress = {
|
|
347
|
+
percent: 0,
|
|
348
|
+
description: "",
|
|
349
|
+
extra: null,
|
|
350
|
+
...present(overrides.progress ?? {})
|
|
351
|
+
};
|
|
352
|
+
return {
|
|
353
|
+
id: 1,
|
|
354
|
+
method: "test.job",
|
|
355
|
+
arguments: [],
|
|
356
|
+
transient: false,
|
|
357
|
+
description: null,
|
|
358
|
+
abortable: false,
|
|
359
|
+
logs_path: null,
|
|
360
|
+
logs_excerpt: null,
|
|
361
|
+
result: null,
|
|
362
|
+
result_encoding_error: null,
|
|
363
|
+
error: null,
|
|
364
|
+
exception: null,
|
|
365
|
+
exc_info: null,
|
|
366
|
+
state: "RUNNING" /* Running */,
|
|
367
|
+
time_started: null,
|
|
368
|
+
time_finished: null,
|
|
369
|
+
credentials: null,
|
|
370
|
+
...present(overrides),
|
|
371
|
+
progress
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/testing/mock-answers.ts
|
|
376
|
+
function readJobId(read) {
|
|
377
|
+
const [filters] = read.params ?? [];
|
|
378
|
+
if (!Array.isArray(filters) || filters.length !== 1) return void 0;
|
|
379
|
+
const [clause] = filters;
|
|
380
|
+
if (!Array.isArray(clause) || clause.length !== 3) return void 0;
|
|
381
|
+
const [field, operator, value] = clause;
|
|
382
|
+
if (field !== "id" || operator !== "=" || typeof value !== "number") return void 0;
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
function createMockAnswers(connection) {
|
|
386
|
+
let nextJobId = 1;
|
|
387
|
+
const answer = (frame, result) => {
|
|
388
|
+
connection.receive({ jsonrpc: "2.0", id: frame.id, result });
|
|
389
|
+
};
|
|
390
|
+
const sequences = /* @__PURE__ */ new Map();
|
|
391
|
+
const position = /* @__PURE__ */ new Map();
|
|
392
|
+
const walking = /* @__PURE__ */ new Set();
|
|
393
|
+
const started = /* @__PURE__ */ new Set();
|
|
394
|
+
const allocateId = () => {
|
|
395
|
+
while (sequences.has(nextJobId)) nextJobId++;
|
|
396
|
+
return nextJobId++;
|
|
397
|
+
};
|
|
398
|
+
const scriptedMethods = /* @__PURE__ */ new Set();
|
|
399
|
+
const readOptions = (read) => {
|
|
400
|
+
const [, options] = read.params ?? [];
|
|
401
|
+
return options;
|
|
402
|
+
};
|
|
403
|
+
const isTracking = (read) => (read.params ?? []).length === 1;
|
|
404
|
+
const answerRead = (read, job) => {
|
|
405
|
+
const options = readOptions(read);
|
|
406
|
+
if (options?.count) return answer(read, job ? 1 : 0);
|
|
407
|
+
if (options?.get) {
|
|
408
|
+
return job ? answer(read, job) : notFound(read);
|
|
409
|
+
}
|
|
410
|
+
answer(read, job ? [job] : []);
|
|
411
|
+
};
|
|
412
|
+
let dispatcher;
|
|
413
|
+
let fallback;
|
|
414
|
+
const notFound = (frame) => {
|
|
415
|
+
errorTo(frame, fakeApiError({ reason: "MatchNotFound()" }));
|
|
416
|
+
};
|
|
417
|
+
const errorTo = (frame, error) => {
|
|
418
|
+
connection.receive({ jsonrpc: "2.0", id: frame.id, error });
|
|
419
|
+
};
|
|
420
|
+
const installSnapshotAnswer = () => {
|
|
421
|
+
if (dispatcher) return;
|
|
422
|
+
const answerSnapshot = (read) => {
|
|
423
|
+
const id = readJobId(read);
|
|
424
|
+
const sequence = id === void 0 ? void 0 : sequences.get(id);
|
|
425
|
+
if (!sequence || id === void 0) {
|
|
426
|
+
if (fallback) return fallback(read);
|
|
427
|
+
return answerRead(read, void 0);
|
|
428
|
+
}
|
|
429
|
+
const at = position.get(id) ?? 0;
|
|
430
|
+
answerRead(read, sequence[at]);
|
|
431
|
+
if (!isTracking(read) || at >= sequence.length - 1 || walking.has(id)) return;
|
|
432
|
+
walking.add(id);
|
|
433
|
+
queueMicrotask(() => {
|
|
434
|
+
for (let next = at + 1; next < sequence.length; next++) {
|
|
435
|
+
position.set(id, next);
|
|
436
|
+
connection.receive(jobEvent(sequence[next]));
|
|
437
|
+
}
|
|
438
|
+
walking.delete(id);
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
dispatcher = answerSnapshot;
|
|
442
|
+
fallback = connection.autoReply("core.get_jobs", answerSnapshot);
|
|
443
|
+
};
|
|
444
|
+
const refuseJobMethodTakeover = (method) => {
|
|
445
|
+
if (method !== "core.get_jobs" || sequences.size === 0) return;
|
|
446
|
+
throw new Error(
|
|
447
|
+
"mock.query('core.get_jobs', \u2026) cannot be registered after a scripted job: jobs answer that method to report their progress, and taking it over makes them complete with the wrong job. Script it before the job, or drive the frames with connection.reply."
|
|
448
|
+
);
|
|
449
|
+
};
|
|
450
|
+
const jobEvent = (job, messageId) => ({
|
|
451
|
+
jsonrpc: "2.0",
|
|
452
|
+
method: "collection_update",
|
|
453
|
+
params: {
|
|
454
|
+
collection: "core.get_jobs",
|
|
455
|
+
msg: "changed",
|
|
456
|
+
id: job.id,
|
|
457
|
+
fields: messageId ? { ...job, message_ids: [messageId] } : job
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
return {
|
|
461
|
+
call(method, response) {
|
|
462
|
+
connection.autoReply(String(method), (frame) => {
|
|
463
|
+
const params = frame.params ?? [];
|
|
464
|
+
answer(
|
|
465
|
+
frame,
|
|
466
|
+
typeof response === "function" ? response(params) : response
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
},
|
|
470
|
+
query(method, rows) {
|
|
471
|
+
refuseJobMethodTakeover(method);
|
|
472
|
+
connection.autoReply(method, (frame) => {
|
|
473
|
+
const [, options] = frame.params ?? [];
|
|
474
|
+
if (options?.count) return answer(frame, rows.length);
|
|
475
|
+
if (options?.get) {
|
|
476
|
+
if (rows.length === 0) {
|
|
477
|
+
return notFound(frame);
|
|
478
|
+
}
|
|
479
|
+
return answer(frame, rows[0]);
|
|
480
|
+
}
|
|
481
|
+
answer(frame, rows);
|
|
482
|
+
});
|
|
483
|
+
},
|
|
484
|
+
job(method, updates) {
|
|
485
|
+
const given = Array.isArray(updates) ? updates : [updates];
|
|
486
|
+
if (given.length === 0) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`mock.job('${String(method)}', []) has nothing to answer with. A job needs at least one update; one that never reaches a terminal state is how you script a job that hangs.`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
const chosen = given[0].id;
|
|
492
|
+
if (chosen !== void 0 && sequences.has(chosen)) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
`mock.job('${String(method)}', \u2026) was given id ${String(chosen)}, which another scripted job already holds. Ids are how a spec reaches a job through trackJob, so two jobs cannot share one.`
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
const conflicting = given.find(
|
|
498
|
+
(update) => update.id !== void 0 && update.id !== chosen
|
|
499
|
+
);
|
|
500
|
+
if (conflicting) {
|
|
501
|
+
const first = chosen === void 0 ? "none" : String(chosen);
|
|
502
|
+
throw new Error(
|
|
503
|
+
`mock.job('${String(method)}', \u2026) was given updates for different ids: ${first} and ${String(conflicting.id)}. They are updates to one job; script the second separately if it is a second job.`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (scriptedMethods.has(String(method))) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`mock.job('${String(method)}', \u2026) is already scripted. Starting the method again would correlate onto one job id, which the appliance cannot produce; script one job per method, or reach the other through trackJob with the id this returned.`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
scriptedMethods.add(String(method));
|
|
512
|
+
const id = chosen ?? allocateId();
|
|
513
|
+
const sequence = given.map(
|
|
514
|
+
(update) => fakeJob({ method: String(method), ...update, id })
|
|
515
|
+
);
|
|
516
|
+
sequences.set(id, sequence);
|
|
517
|
+
installSnapshotAnswer();
|
|
518
|
+
connection.autoReply(String(method), (frame) => {
|
|
519
|
+
const fresh = started.has(id) || position.has(id) || walking.has(id);
|
|
520
|
+
const runId = fresh ? allocateId() : id;
|
|
521
|
+
started.add(runId);
|
|
522
|
+
if (fresh) sequences.set(runId, sequence.map((job) => ({ ...job, id: runId })));
|
|
523
|
+
const run = sequences.get(runId) ?? sequence;
|
|
524
|
+
connection.receive(jobEvent(run[0], frame.id));
|
|
525
|
+
});
|
|
526
|
+
return id;
|
|
527
|
+
},
|
|
528
|
+
emit(event, change) {
|
|
529
|
+
connection.receive({
|
|
530
|
+
jsonrpc: "2.0",
|
|
531
|
+
method: "collection_update",
|
|
532
|
+
params: { collection: event, ...change }
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/testing/create-fake-client.ts
|
|
539
|
+
function withFakeCollaborators(Base, options) {
|
|
540
|
+
return class FakeClient extends Base {
|
|
541
|
+
createConnection() {
|
|
542
|
+
return new FakeConnection({
|
|
543
|
+
uuid: this.uuid,
|
|
544
|
+
websocketPath: this.version.websocketPath,
|
|
545
|
+
opened: options.opened ?? true,
|
|
546
|
+
strict: options.strict ?? false
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
createAuthenticator() {
|
|
550
|
+
return new FakeAuthenticator(this.connection, this.version);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function createFakeClient(options = {}) {
|
|
555
|
+
const versionString = options.version ?? SUPPORTED_API_VERSIONS[0];
|
|
556
|
+
const version = parseApiVersion(versionString);
|
|
557
|
+
if (!version) {
|
|
558
|
+
throw new Error(`'${versionString}' is not a version this package parses.`);
|
|
559
|
+
}
|
|
560
|
+
const Base = clientClassFor(version);
|
|
561
|
+
if (!Base) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
`No client implementation for '${versionString}'. The real factory would have refused it too.`
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
const FakeClient = withFakeCollaborators(Base, options);
|
|
567
|
+
const client = new FakeClient(
|
|
568
|
+
// Deliberately not `FakeConnection`'s own placeholder: if the two defaults
|
|
569
|
+
// matched, a test asserting the connection was told which appliance it
|
|
570
|
+
// stands for would pass whether or not it was told.
|
|
571
|
+
"fake-client-uuid",
|
|
572
|
+
["fake.local"],
|
|
573
|
+
version,
|
|
574
|
+
// Never enabled: the connection this client builds is a fake and opens
|
|
575
|
+
// nothing, and leaving the gate shut keeps that true if it ever stops
|
|
576
|
+
// being one.
|
|
577
|
+
false
|
|
578
|
+
);
|
|
579
|
+
if (options.authenticated ?? true) {
|
|
580
|
+
client.authenticator.authenticated$.next(true);
|
|
581
|
+
}
|
|
582
|
+
const fake = client;
|
|
583
|
+
fake.mock = createMockAnswers(
|
|
584
|
+
client.connection
|
|
585
|
+
);
|
|
586
|
+
return fake;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/testing/with-spies.ts
|
|
590
|
+
var API_VERBS = [
|
|
591
|
+
"call",
|
|
592
|
+
"callAndGetJobId",
|
|
593
|
+
"events",
|
|
594
|
+
"generateToken",
|
|
595
|
+
"job",
|
|
596
|
+
"query",
|
|
597
|
+
"queryCount",
|
|
598
|
+
"queryOne",
|
|
599
|
+
"trackJob"
|
|
600
|
+
];
|
|
601
|
+
var CONNECTION_METHODS = ["send"];
|
|
602
|
+
var AUTHENTICATOR_METHODS = [
|
|
603
|
+
"loginWithApiKey",
|
|
604
|
+
"loginWithOtp",
|
|
605
|
+
"loginWithToken",
|
|
606
|
+
"loginWithUserPass",
|
|
607
|
+
"logout",
|
|
608
|
+
"newApiKey"
|
|
609
|
+
];
|
|
610
|
+
function spyOnMethods(target, names, spy) {
|
|
611
|
+
for (const name of names) {
|
|
612
|
+
const original = target[name];
|
|
613
|
+
if (typeof original !== "function") {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`withSpies: '${name}' is not a method on ${target.constructor.name}.`
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
const method = original;
|
|
619
|
+
const forwarded = function(...args) {
|
|
620
|
+
if (this !== void 0) return method.apply(this, args);
|
|
621
|
+
try {
|
|
622
|
+
return method.apply(this, args);
|
|
623
|
+
} catch (cause) {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`withSpies: ${name} was called without its object. Either the verb was detached from the client, or the spy factory does not forward \`this\` to the implementation it was given. An arrow function does not; \`vi.fn\` and \`jest.fn\` do.`,
|
|
626
|
+
{ cause }
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
Object.defineProperty(forwarded, "name", { value: name, configurable: true });
|
|
631
|
+
Object.defineProperty(forwarded, "length", {
|
|
632
|
+
value: method.length,
|
|
633
|
+
configurable: true
|
|
634
|
+
});
|
|
635
|
+
target[name] = spy(forwarded);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function withSpies(client, spy) {
|
|
639
|
+
spyOnMethods(client.api, API_VERBS, spy);
|
|
640
|
+
spyOnMethods(client.connection, CONNECTION_METHODS, spy);
|
|
641
|
+
spyOnMethods(client.authenticator, AUTHENTICATOR_METHODS, spy);
|
|
642
|
+
return client;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// src/testing/fake-api-version.ts
|
|
646
|
+
function fakeApiVersion(version = SUPPORTED_API_VERSIONS[0], overrides = {}) {
|
|
647
|
+
const parsed = parseApiVersion(version);
|
|
648
|
+
if (!parsed) {
|
|
649
|
+
throw new Error(
|
|
650
|
+
`fakeApiVersion('${version}'): not a version this package parses. Expected vYY.MINOR.PATCH \u2014 v25.10.0, v27.0.0. For a version the parser rejects, pass one it accepts and override its fields.`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
return { ...parsed, ...present(overrides) };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/testing/fake-auth-response.ts
|
|
657
|
+
var ARMS = {
|
|
658
|
+
["SUCCESS" /* Success */]: () => ({
|
|
659
|
+
authenticator: "LEVEL_1",
|
|
660
|
+
reconnect_token: null
|
|
661
|
+
}),
|
|
662
|
+
["OTP_REQUIRED" /* OtpRequired */]: () => ({ username: "root" }),
|
|
663
|
+
["REDIRECT" /* Redirect */]: () => ({ urls: ["https://truenas.local/sso"] }),
|
|
664
|
+
["AUTH_ERR" /* AuthErr */]: () => ({}),
|
|
665
|
+
["EXPIRED" /* Expired */]: () => ({})
|
|
666
|
+
};
|
|
667
|
+
function fakeAuthResponse(overrides = {}) {
|
|
668
|
+
const { user_info: userInfoOverrides, ...rest } = overrides;
|
|
669
|
+
const responseType = rest.response_type ?? "SUCCESS" /* Success */;
|
|
670
|
+
const succeeded = responseType === "SUCCESS" /* Success */;
|
|
671
|
+
const userInfo = succeeded || userInfoOverrides ? {
|
|
672
|
+
username: "root",
|
|
673
|
+
fullname: "root",
|
|
674
|
+
builtin: true,
|
|
675
|
+
email: null,
|
|
676
|
+
groups: [0],
|
|
677
|
+
privilege: { roles: { $set: ["FULL_ADMIN" /* FullAdmin */] } },
|
|
678
|
+
two_factor_auth_configured: false,
|
|
679
|
+
immutable: true,
|
|
680
|
+
sid: "S-1-5-21-0-0-0-1000",
|
|
681
|
+
id: 1,
|
|
682
|
+
uid: 0,
|
|
683
|
+
gid: 0,
|
|
684
|
+
shell: "/usr/bin/bash",
|
|
685
|
+
home: "/root",
|
|
686
|
+
locked: false,
|
|
687
|
+
sudo: true,
|
|
688
|
+
sudo_nopasswd: true,
|
|
689
|
+
sudo_commands: [],
|
|
690
|
+
smb: false,
|
|
691
|
+
group: {
|
|
692
|
+
id: 1,
|
|
693
|
+
bsdgrp_builtin: true,
|
|
694
|
+
bsdgrp_gid: 0,
|
|
695
|
+
bsdgrp_group: "root",
|
|
696
|
+
bsdgrp_sudo: true,
|
|
697
|
+
bsdgrp_sudo_nopasswd: true,
|
|
698
|
+
bsdgrp_sudo_commands: [],
|
|
699
|
+
bsdgrp_smb: false,
|
|
700
|
+
bsdgrp_users: []
|
|
701
|
+
},
|
|
702
|
+
sshpubkey: null,
|
|
703
|
+
attributes: { preferences: { language: "en", lifetime: 300 } },
|
|
704
|
+
...present(userInfoOverrides ?? {})
|
|
705
|
+
} : void 0;
|
|
706
|
+
return {
|
|
707
|
+
response_type: responseType,
|
|
708
|
+
...ARMS[responseType](),
|
|
709
|
+
...present(rest),
|
|
710
|
+
...userInfo ? { user_info: userInfo } : {}
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export { FakeAuthenticator, FakeConnection, UnmockedCallError, createFakeClient, fakeApiError, fakeApiVersion, fakeAuthResponse, fakeJob, withSpies };
|
|
715
|
+
//# sourceMappingURL=index.js.map
|
|
716
|
+
//# sourceMappingURL=index.js.map
|