@lizard-build/cli 4.0.33 → 4.0.35
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 +5 -0
- package/dist/commands/billing.d.ts +1 -0
- package/dist/commands/billing.js +1 -1
- package/dist/commands/billing.js.map +1 -1
- package/dist/commands/login.d.ts +17 -26
- package/dist/commands/login.js +207 -69
- package/dist/commands/login.js.map +1 -1
- package/dist/lib/auth.d.ts +19 -8
- package/dist/lib/auth.js +38 -67
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/config.d.ts +2 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/signin.d.ts +91 -0
- package/dist/lib/signin.js +249 -0
- package/dist/lib/signin.js.map +1 -0
- package/dist/lib/skills-data.generated.js +1 -1
- package/dist/lib/skills-data.generated.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/install.cmd +15 -2
- package/install.ps1 +14 -2
- package/install.sh +22 -2
- package/package.json +1 -1
- package/skill-data/core/SKILL.md +14 -5
- package/src/commands/billing.ts +1 -1
- package/src/commands/login.ts +247 -92
- package/src/lib/auth.ts +41 -71
- package/src/lib/config.ts +2 -0
- package/src/lib/signin.ts +320 -0
- package/src/lib/updater.ts +1 -1
- package/test/unit/json.test.ts +3 -0
- package/test/unit/login.test.ts +398 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { registerLogin, accountState, accountInstruction, nextSteps, readTokenFromStdin } from "../../src/commands/login.js";
|
|
8
|
+
import { authUrlFor, providerFlag, pendingPayload } from "../../src/lib/signin.js";
|
|
9
|
+
import type { Subscription } from "../../src/commands/billing.js";
|
|
10
|
+
import { requireAuth } from "../../src/lib/auth.js";
|
|
11
|
+
import { APIError, setBaseURL } from "../../src/lib/api.js";
|
|
12
|
+
import { setJSONMode } from "../../src/lib/format.js";
|
|
13
|
+
import { loadConfig, saveConfig } from "../../src/lib/config.js";
|
|
14
|
+
|
|
15
|
+
const fetchMock = vi.fn();
|
|
16
|
+
let stdout: string[];
|
|
17
|
+
let tmpDir: string;
|
|
18
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
19
|
+
|
|
20
|
+
function reply(status: number, body: unknown) {
|
|
21
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Answers each request by "METHOD path". */
|
|
25
|
+
function routes(table: Record<string, () => Response>) {
|
|
26
|
+
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
|
27
|
+
const key = `${init?.method ?? "GET"} ${new URL(url).pathname}`;
|
|
28
|
+
const handler = table[key];
|
|
29
|
+
if (!handler) return reply(404, { error: `no route ${key}` });
|
|
30
|
+
return handler();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requested(): string[] {
|
|
35
|
+
return fetchMock.mock.calls.map(([url, init]) => `${init?.method ?? "GET"} ${new URL(url).pathname}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The one JSON object `lizard login --json` printed. */
|
|
39
|
+
function output(): Record<string, any> {
|
|
40
|
+
return JSON.parse(stdout.join("\n"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function run(args: string[]) {
|
|
44
|
+
const program = new Command().exitOverride().configureOutput({ writeErr: () => {}, writeOut: () => {} });
|
|
45
|
+
registerLogin(program);
|
|
46
|
+
return program.parseAsync(args, { from: "user" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An unsigned JWT that expires `inMs` from now: enough for the CLI's expiry check. */
|
|
50
|
+
function jwt(inMs: number) {
|
|
51
|
+
const part = (o: object) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
|
52
|
+
return `${part({ alg: "none" })}.${part({ exp: Math.floor((Date.now() + inMs) / 1000) })}.sig`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function authError(): Promise<APIError> {
|
|
56
|
+
try {
|
|
57
|
+
await requireAuth();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return err as APIError;
|
|
60
|
+
}
|
|
61
|
+
throw new Error("requireAuth did not throw");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DAY = 86_400_000;
|
|
65
|
+
|
|
66
|
+
const none: Subscription = {
|
|
67
|
+
plan: "none",
|
|
68
|
+
status: "none",
|
|
69
|
+
isOwner: true,
|
|
70
|
+
priceCents: 1900,
|
|
71
|
+
taxIncluded: true,
|
|
72
|
+
includedCents: 1900,
|
|
73
|
+
trial: { eligible: true, days: 7, creditCents: 500, promoCode: null, endsAt: null, usedCents: null, remainingCents: null },
|
|
74
|
+
period: null,
|
|
75
|
+
nextCharge: null,
|
|
76
|
+
cancelAt: null,
|
|
77
|
+
pastDue: false,
|
|
78
|
+
openInvoiceUrl: null,
|
|
79
|
+
paymentMethod: null,
|
|
80
|
+
limits: null,
|
|
81
|
+
checkoutAvailable: true,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const session = { sessionId: "sess123", sessionSecret: "secret456", expiresIn: 300 };
|
|
85
|
+
const me = { id: "u1", username: "ada", email: "ada@example.com" };
|
|
86
|
+
const both = () => reply(200, { github: true, google: true });
|
|
87
|
+
|
|
88
|
+
function savePending(createdAt = Date.now()) {
|
|
89
|
+
saveConfig({ pendingAuth: { sessionId: "sess123", sessionSecret: "secret456", authUrl: "x", createdAt } });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
for (const k of ["LIZARD_HOME", "LIZARD_TOKEN", "LIZARD_API_KEY", "CI"]) savedEnv[k] = process.env[k];
|
|
94
|
+
delete process.env.LIZARD_TOKEN;
|
|
95
|
+
delete process.env.LIZARD_API_KEY;
|
|
96
|
+
delete process.env.CI;
|
|
97
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "lizard-login-test-"));
|
|
98
|
+
process.env.LIZARD_HOME = tmpDir;
|
|
99
|
+
|
|
100
|
+
fetchMock.mockReset();
|
|
101
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
102
|
+
setBaseURL("https://lizard.build");
|
|
103
|
+
setJSONMode(true);
|
|
104
|
+
stdout = [];
|
|
105
|
+
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => { stdout.push(a.join(" ")); });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
afterEach(() => {
|
|
109
|
+
for (const [k, v] of Object.entries(savedEnv)) {
|
|
110
|
+
if (v === undefined) delete process.env[k];
|
|
111
|
+
else process.env[k] = v;
|
|
112
|
+
}
|
|
113
|
+
vi.unstubAllGlobals();
|
|
114
|
+
vi.restoreAllMocks();
|
|
115
|
+
setJSONMode(false);
|
|
116
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("sign-in links", () => {
|
|
120
|
+
test("without a provider the link is the one older CLIs open", () => {
|
|
121
|
+
expect(authUrlFor("abc")).toBe("https://lizard.build/auth/cli?session=abc");
|
|
122
|
+
expect(authUrlFor("abc", "google")).toBe("https://lizard.build/auth/cli?session=abc&provider=google");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("--github and --google together are refused", () => {
|
|
126
|
+
expect(providerFlag({})).toBeUndefined();
|
|
127
|
+
expect(providerFlag({ google: true })).toBe("google");
|
|
128
|
+
expect(() => providerFlag({ github: true, google: true })).toThrow(/not both/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("two methods: both links, the old authUrl, and the same-method rule", () => {
|
|
132
|
+
const ev = pendingPayload({ sessionId: "s", sessionSecret: "x", expiresAt: Date.now() + 300_000 }, ["github", "google"], "new");
|
|
133
|
+
expect(ev.status).toBe("pending");
|
|
134
|
+
expect(ev.authUrl).toBe("https://lizard.build/auth/cli?session=s");
|
|
135
|
+
expect(ev.authUrls).toEqual({
|
|
136
|
+
github: "https://lizard.build/auth/cli?session=s&provider=github",
|
|
137
|
+
google: "https://lizard.build/auth/cli?session=s&provider=google",
|
|
138
|
+
});
|
|
139
|
+
expect(ev).not.toHaveProperty("nextCommand");
|
|
140
|
+
expect(ev.instruction).toContain("same method as before");
|
|
141
|
+
expect(ev.instruction).toContain("run the same command again");
|
|
142
|
+
expect(ev.instruction).toContain("5 more minutes");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a chosen method: one link, and nextCommand keeps the flag", () => {
|
|
146
|
+
const ev = pendingPayload(
|
|
147
|
+
{ sessionId: "s", sessionSecret: "x", expiresAt: Date.now() + 60_000 },
|
|
148
|
+
["google"],
|
|
149
|
+
"expired",
|
|
150
|
+
{ flag: "google", nextCommand: "lizard login --google" },
|
|
151
|
+
);
|
|
152
|
+
expect(ev.authUrl).toBe("https://lizard.build/auth/cli?session=s&provider=google");
|
|
153
|
+
expect(ev.nextCommand).toBe("lizard login --google");
|
|
154
|
+
expect(ev.instruction).toMatch(/^The last sign-in link expired/);
|
|
155
|
+
expect(ev.instruction).toContain("run nextCommand");
|
|
156
|
+
expect(ev.instruction).toContain("1 more minute;");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("account state", () => {
|
|
161
|
+
test("maps the subscription to what the account still needs", () => {
|
|
162
|
+
expect(accountState(null)).toBe("unknown");
|
|
163
|
+
expect(accountState(none)).toBe("trial_available");
|
|
164
|
+
expect(accountState({ ...none, trial: { ...none.trial, eligible: false } })).toBe("subscription_required");
|
|
165
|
+
expect(accountState({ ...none, checkoutAvailable: false })).toBe("checkout_unavailable");
|
|
166
|
+
expect(accountState({ ...none, isOwner: false })).toBe("not_owner");
|
|
167
|
+
expect(accountState({ ...none, plan: "payg" })).toBe("credits");
|
|
168
|
+
expect(accountState({ ...none, plan: "enterprise" })).toBe("enterprise");
|
|
169
|
+
expect(accountState({ ...none, plan: "pro", status: "trialing" })).toBe("trialing");
|
|
170
|
+
expect(accountState({ ...none, plan: "pro", status: "active" })).toBe("active");
|
|
171
|
+
expect(accountState({ ...none, plan: "pro", status: "active", pastDue: true })).toBe("past_due");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("instructions say when not to touch billing", () => {
|
|
175
|
+
expect(accountInstruction("trial_available", none)).toContain("7-day Pro trial with $5 in credits");
|
|
176
|
+
expect(accountInstruction("trial_available", none)).toContain("lizard billing start --json");
|
|
177
|
+
expect(accountInstruction("subscription_required", none)).toContain("do NOT promise one");
|
|
178
|
+
expect(accountInstruction("active", null)).toContain("Do NOT open billing");
|
|
179
|
+
expect(accountInstruction("unknown", null)).toContain("Do NOT start billing");
|
|
180
|
+
const now = Date.now();
|
|
181
|
+
const trialing = { ...none, plan: "pro", status: "trialing", trial: { ...none.trial, endsAt: now + 3 * DAY, remainingCents: 380 } };
|
|
182
|
+
expect(accountInstruction("trialing", trialing, now)).toBe(
|
|
183
|
+
"The Pro trial is on (3 days left, $3.80 of trial credits left). Do NOT open billing or start a subscription.",
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("an unlinked folder gets lizard init before lizard up", () => {
|
|
188
|
+
expect(nextSteps(false).map((s) => s.command)).toEqual(["lizard skills get core", "lizard init", "lizard up"]);
|
|
189
|
+
expect(nextSteps(true).map((s) => s.command)).toEqual(["lizard skills get core", "lizard up"]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("lizard login --json", () => {
|
|
194
|
+
test("signed out: saves a session and prints the links as one object", async () => {
|
|
195
|
+
routes({ "POST /api/auth/cli/session": () => reply(200, session), "GET /api/auth/providers": both });
|
|
196
|
+
await run(["login"]);
|
|
197
|
+
|
|
198
|
+
const out = output();
|
|
199
|
+
expect(out.status).toBe("pending");
|
|
200
|
+
expect(out.authUrl).toBe("https://lizard.build/auth/cli?session=sess123");
|
|
201
|
+
expect(Object.keys(out.authUrls)).toEqual(["github", "google"]);
|
|
202
|
+
expect(out.nextCommand).toBe("lizard login");
|
|
203
|
+
const saved = loadConfig().pendingAuth!;
|
|
204
|
+
expect(saved.sessionId).toBe("sess123");
|
|
205
|
+
expect(saved.expiresAt).toBeGreaterThan(Date.now());
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("--google prints one Google link without asking which methods exist", async () => {
|
|
209
|
+
routes({ "POST /api/auth/cli/session": () => reply(200, session) });
|
|
210
|
+
await run(["login", "--google"]);
|
|
211
|
+
expect(output().authUrl).toBe("https://lizard.build/auth/cli?session=sess123&provider=google");
|
|
212
|
+
expect(output().nextCommand).toBe("lizard login --google");
|
|
213
|
+
expect(requested()).toEqual(["POST /api/auth/cli/session"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("the next run finishes an approved session and reports the plan", async () => {
|
|
217
|
+
savePending();
|
|
218
|
+
routes({
|
|
219
|
+
"POST /api/auth/cli/poll": () => reply(200, { status: "complete", accessToken: jwt(DAY), user: { id: "u1", username: "ada" } }),
|
|
220
|
+
"GET /api/auth/me": () => reply(200, me),
|
|
221
|
+
"GET /api/billing/subscription": () => reply(200, none),
|
|
222
|
+
});
|
|
223
|
+
await run(["login"]);
|
|
224
|
+
|
|
225
|
+
const out = output();
|
|
226
|
+
expect(out).toMatchObject({ status: "complete", username: "ada", email: "ada@example.com", source: "browser" });
|
|
227
|
+
expect(out.account.state).toBe("trial_available");
|
|
228
|
+
expect(out.account.billingUrl).toBe("https://lizard.build/profile/account-billing");
|
|
229
|
+
expect(out.next.map((s: { command: string }) => s.command)).toContain("lizard init");
|
|
230
|
+
expect(loadConfig().pendingAuth).toBeUndefined();
|
|
231
|
+
expect(loadConfig().credentials?.username).toBe("ada");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("a session the browser has not approved yet keeps its links", async () => {
|
|
235
|
+
savePending();
|
|
236
|
+
routes({
|
|
237
|
+
"POST /api/auth/cli/poll": () => reply(200, { status: "pending" }),
|
|
238
|
+
"GET /api/auth/providers": () => reply(200, { github: true, google: false }),
|
|
239
|
+
});
|
|
240
|
+
await run(["login"]);
|
|
241
|
+
|
|
242
|
+
expect(output().authUrl).toBe("https://lizard.build/auth/cli?session=sess123&provider=github");
|
|
243
|
+
expect(output().instruction).toMatch(/^The user has not finished signing in yet/);
|
|
244
|
+
expect(requested()).not.toContain("POST /api/auth/cli/session");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("an expired session is replaced by a new one", async () => {
|
|
248
|
+
saveConfig({ pendingAuth: { sessionId: "old", sessionSecret: "s", authUrl: "x", createdAt: Date.now() - 10 * 60_000 } });
|
|
249
|
+
routes({
|
|
250
|
+
"POST /api/auth/cli/poll": () => reply(200, { status: "expired" }),
|
|
251
|
+
"POST /api/auth/cli/session": () => reply(200, session),
|
|
252
|
+
"GET /api/auth/providers": both,
|
|
253
|
+
});
|
|
254
|
+
await run(["login"]);
|
|
255
|
+
|
|
256
|
+
expect(output().authUrls.github).toContain("session=sess123");
|
|
257
|
+
expect(output().instruction).toMatch(/^The last sign-in link expired/);
|
|
258
|
+
expect(loadConfig().pendingAuth?.sessionId).toBe("sess123");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("a saved login is reported instead of starting a new one", async () => {
|
|
262
|
+
saveConfig({ credentials: { accessToken: jwt(DAY), userId: "u1", username: "ada" } });
|
|
263
|
+
routes({
|
|
264
|
+
"GET /api/auth/me": () => reply(200, me),
|
|
265
|
+
"GET /api/billing/subscription": () => reply(200, { ...none, plan: "pro", status: "active" }),
|
|
266
|
+
});
|
|
267
|
+
await run(["login"]);
|
|
268
|
+
|
|
269
|
+
expect(output()).toMatchObject({ status: "complete", source: "saved", account: { state: "active" } });
|
|
270
|
+
expect(requested()).toEqual(["GET /api/auth/me", "GET /api/billing/subscription"]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("a saved token the platform rejects is dropped and sign-in starts", async () => {
|
|
274
|
+
saveConfig({ credentials: { accessToken: jwt(DAY), userId: "u1", username: "ada" } });
|
|
275
|
+
routes({
|
|
276
|
+
"GET /api/auth/me": () => reply(401, { error: "Unauthorized" }),
|
|
277
|
+
"POST /api/auth/cli/session": () => reply(200, session),
|
|
278
|
+
"GET /api/auth/providers": both,
|
|
279
|
+
});
|
|
280
|
+
await run(["login"]);
|
|
281
|
+
|
|
282
|
+
expect(output().status).toBe("pending");
|
|
283
|
+
expect(loadConfig().credentials).toBeUndefined();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a token from the environment counts, and a plan it cannot read is unknown", async () => {
|
|
287
|
+
process.env.LIZARD_API_KEY = "liz_scoped";
|
|
288
|
+
routes({
|
|
289
|
+
"GET /api/auth/me": () => reply(200, { id: "u1", username: "ada", scoped: true }),
|
|
290
|
+
"GET /api/billing/subscription": () => reply(403, { error: "ACCOUNT_SCOPE_REQUIRED", message: "no" }),
|
|
291
|
+
});
|
|
292
|
+
await run(["login"]);
|
|
293
|
+
|
|
294
|
+
expect(output()).toMatchObject({ status: "complete", scoped: true, source: "env", account: { state: "unknown" } });
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("requireAuth without a terminal", () => {
|
|
299
|
+
test("no login: fails with NOT_AUTHENTICATED and the sign-in links in the body", async () => {
|
|
300
|
+
routes({ "POST /api/auth/cli/session": () => reply(200, session), "GET /api/auth/providers": both });
|
|
301
|
+
const err = await authError();
|
|
302
|
+
|
|
303
|
+
expect(err).toBeInstanceOf(APIError);
|
|
304
|
+
expect(err.status).toBe(401);
|
|
305
|
+
expect(err.code).toBe("NOT_AUTHENTICATED");
|
|
306
|
+
expect(err.message).toMatch(/^Not signed in to Lizard\. Sign-in is needed/);
|
|
307
|
+
const login = (err.body as { login: Record<string, any> }).login;
|
|
308
|
+
expect(login.authUrls.google).toBe("https://lizard.build/auth/cli?session=sess123&provider=google");
|
|
309
|
+
expect(login.instruction).toContain("run the same command again");
|
|
310
|
+
expect(loadConfig().pendingAuth?.sessionId).toBe("sess123");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("the same command run again picks up the approved session", async () => {
|
|
314
|
+
savePending();
|
|
315
|
+
routes({
|
|
316
|
+
"POST /api/auth/cli/poll": () => reply(200, { status: "complete", accessToken: jwt(DAY), user: { id: "u1", username: "ada" } }),
|
|
317
|
+
});
|
|
318
|
+
const creds = await requireAuth();
|
|
319
|
+
expect(creds.username).toBe("ada");
|
|
320
|
+
expect(loadConfig().pendingAuth).toBeUndefined();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("a session still waiting is handed out again, not replaced", async () => {
|
|
324
|
+
savePending();
|
|
325
|
+
routes({ "POST /api/auth/cli/poll": () => reply(200, { status: "pending" }), "GET /api/auth/providers": both });
|
|
326
|
+
const err = await authError();
|
|
327
|
+
|
|
328
|
+
expect(err.message).toContain("The user has not finished signing in yet");
|
|
329
|
+
expect(requested()).not.toContain("POST /api/auth/cli/session");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("an expired saved login says so", async () => {
|
|
333
|
+
saveConfig({ credentials: { accessToken: jwt(-DAY), userId: "u1", username: "ada" } });
|
|
334
|
+
routes({ "POST /api/auth/cli/session": () => reply(200, session), "GET /api/auth/providers": both });
|
|
335
|
+
expect((await authError()).message).toMatch(/^The Lizard sign-in on this machine expired\./);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("CI gets the plain error and no session", async () => {
|
|
339
|
+
process.env.CI = "true";
|
|
340
|
+
routes({});
|
|
341
|
+
const err = await authError();
|
|
342
|
+
expect(err.code).toBe("NOT_AUTHENTICATED");
|
|
343
|
+
expect(err.body).toBeNull();
|
|
344
|
+
expect(err.message).toContain("LIZARD_TOKEN");
|
|
345
|
+
expect(requested()).toEqual([]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("a platform it cannot reach gives the plain error", async () => {
|
|
349
|
+
fetchMock.mockRejectedValue(new TypeError("fetch failed"));
|
|
350
|
+
const err = await authError();
|
|
351
|
+
expect(err.code).toBe("NOT_AUTHENTICATED");
|
|
352
|
+
expect(err.body).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("a token in the environment skips all of it", async () => {
|
|
356
|
+
process.env.LIZARD_TOKEN = "tok";
|
|
357
|
+
expect((await requireAuth()).accessToken).toBe("tok");
|
|
358
|
+
expect(requested()).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("lizard login with a token", () => {
|
|
363
|
+
test("--token works as before: checks it, saves it, prints the username", async () => {
|
|
364
|
+
routes({ "GET /api/auth/me": () => reply(200, me) });
|
|
365
|
+
await run(["login", "--token", "tok_abc"]);
|
|
366
|
+
|
|
367
|
+
expect(output()).toEqual({ status: "complete", username: "ada" });
|
|
368
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
369
|
+
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer tok_abc");
|
|
370
|
+
expect(loadConfig().credentials).toMatchObject({ accessToken: "tok_abc", username: "ada", email: "ada@example.com" });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("--token-stdin reads the piped token and does the same", async () => {
|
|
374
|
+
vi.spyOn(process, "stdin", "get").mockReturnValue(Readable.from([" tok_piped\n"]) as any);
|
|
375
|
+
routes({ "GET /api/auth/me": () => reply(200, me) });
|
|
376
|
+
await run(["login", "--token-stdin"]);
|
|
377
|
+
|
|
378
|
+
expect(output()).toEqual({ status: "complete", username: "ada" });
|
|
379
|
+
expect(loadConfig().credentials?.accessToken).toBe("tok_piped");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("a rejected token is not saved", async () => {
|
|
383
|
+
routes({ "GET /api/auth/me": () => reply(401, { error: "Unauthorized" }) });
|
|
384
|
+
await expect(run(["login", "--token", "bad"])).rejects.toThrow("Invalid token");
|
|
385
|
+
expect(loadConfig().credentials).toBeUndefined();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("--token and --token-stdin together are refused", async () => {
|
|
389
|
+
await expect(run(["login", "--token", "a", "--token-stdin"])).rejects.toThrow(/not both/);
|
|
390
|
+
expect(requested()).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("stdin must be a pipe with something in it", async () => {
|
|
394
|
+
const tty = Object.assign(Readable.from([]), { isTTY: true });
|
|
395
|
+
await expect(readTokenFromStdin(tty)).rejects.toThrow(/piped token/);
|
|
396
|
+
await expect(readTokenFromStdin(Readable.from(["\n "]))).rejects.toThrow(/nothing on stdin/);
|
|
397
|
+
});
|
|
398
|
+
});
|