mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
287 lines
8.8 KiB
TypeScript
287 lines
8.8 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import { AUTH_FILE_VERSION, type AuthData } from "../src/lib/config";
|
|
import {
|
|
DEFAULT_REFRESH_WINDOW_MS,
|
|
isExpired,
|
|
isNearExpiry,
|
|
requireAuth,
|
|
} from "../src/lib/auth-guard";
|
|
import type { ApiResult } from "../src/lib/api-client";
|
|
import type { TokenBundle } from "../src/lib/auth-flow";
|
|
import type { OutputContext } from "../src/lib/output";
|
|
|
|
const FIXED_NOW = new Date("2026-04-11T12:00:00.000Z");
|
|
|
|
function makeAuth(overrides: Partial<AuthData> = {}): AuthData {
|
|
return {
|
|
version: AUTH_FILE_VERSION,
|
|
email: "student@example.com",
|
|
access_token: "jwt-current",
|
|
refresh_token: "rt-current",
|
|
expires_at: new Date(FIXED_NOW.getTime() + 60 * 60 * 1_000).toISOString(),
|
|
created_at: FIXED_NOW.toISOString(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const ctx: OutputContext = { json: true, verbose: false };
|
|
|
|
function captureExit<T>(fn: () => Promise<T>): Promise<{ value?: T; exitCode?: number }> {
|
|
return new Promise((resolve) => {
|
|
const realExit = process.exit;
|
|
const realStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
const realStderrWrite = process.stderr.write.bind(process.stderr);
|
|
let captured = "";
|
|
// Silence the test output and capture exit attempts.
|
|
process.stdout.write = ((chunk: string) => {
|
|
captured += chunk;
|
|
return true;
|
|
}) as typeof process.stdout.write;
|
|
process.stderr.write = ((chunk: string) => {
|
|
captured += chunk;
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
process.exit = ((code?: number) => {
|
|
throw Object.assign(new Error("__exit__"), { __exitCode: code });
|
|
}) as typeof process.exit;
|
|
fn()
|
|
.then((value) => resolve({ value }))
|
|
.catch((err: unknown) => {
|
|
if (err && typeof err === "object" && "__exitCode" in err) {
|
|
resolve({ exitCode: (err as { __exitCode: number }).__exitCode });
|
|
} else {
|
|
resolve({ exitCode: -1 });
|
|
}
|
|
})
|
|
.finally(() => {
|
|
process.stdout.write = realStdoutWrite;
|
|
process.stderr.write = realStderrWrite;
|
|
process.exit = realExit;
|
|
// ensure captured is referenced so the closure isn't dead-code-eliminated
|
|
void captured;
|
|
});
|
|
});
|
|
}
|
|
|
|
describe("isExpired / isNearExpiry", () => {
|
|
it("isExpired returns false for a future token", () => {
|
|
const auth = makeAuth();
|
|
expect(isExpired(auth, FIXED_NOW)).toBe(false);
|
|
});
|
|
|
|
it("isExpired returns true for a past token", () => {
|
|
const auth = makeAuth({
|
|
expires_at: new Date(FIXED_NOW.getTime() - 1000).toISOString(),
|
|
});
|
|
expect(isExpired(auth, FIXED_NOW)).toBe(true);
|
|
});
|
|
|
|
it("isNearExpiry uses the default 5-minute window", () => {
|
|
const inFourMin = makeAuth({
|
|
expires_at: new Date(FIXED_NOW.getTime() + 4 * 60 * 1_000).toISOString(),
|
|
});
|
|
const inTenMin = makeAuth({
|
|
expires_at: new Date(FIXED_NOW.getTime() + 10 * 60 * 1_000).toISOString(),
|
|
});
|
|
expect(isNearExpiry(inFourMin, DEFAULT_REFRESH_WINDOW_MS, FIXED_NOW)).toBe(true);
|
|
expect(isNearExpiry(inTenMin, DEFAULT_REFRESH_WINDOW_MS, FIXED_NOW)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("requireAuth", () => {
|
|
it("returns the stored auth when token is healthy", async () => {
|
|
const auth = makeAuth();
|
|
const result = await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => auth,
|
|
persist: () => {
|
|
throw new Error("should not persist");
|
|
},
|
|
refresh: async () => {
|
|
throw new Error("should not refresh");
|
|
},
|
|
});
|
|
expect(result.access_token).toBe("jwt-current");
|
|
});
|
|
|
|
it("transparently refreshes when within the window", async () => {
|
|
const auth = makeAuth({
|
|
expires_at: new Date(FIXED_NOW.getTime() + 60 * 1_000).toISOString(),
|
|
});
|
|
let persisted: AuthData | null = null;
|
|
const result = await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => auth,
|
|
persist: (next) => {
|
|
persisted = next;
|
|
},
|
|
refresh: async (rt): Promise<ApiResult<TokenBundle>> => {
|
|
expect(rt).toBe("rt-current");
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
data: {
|
|
token: "jwt-new",
|
|
refresh_token: "rt-new",
|
|
expires_at: new Date(FIXED_NOW.getTime() + 30 * 60 * 1_000).toISOString(),
|
|
},
|
|
responseHeaders: new Headers(),
|
|
rawBody: "",
|
|
};
|
|
},
|
|
});
|
|
expect(result.access_token).toBe("jwt-new");
|
|
expect(result.refresh_token).toBe("rt-new");
|
|
expect(persisted).not.toBeNull();
|
|
expect(persisted!.access_token).toBe("jwt-new");
|
|
// created_at must be preserved across refresh
|
|
expect(persisted!.created_at).toBe(auth.created_at);
|
|
});
|
|
|
|
it("falls back to existing token when refresh fails but token still valid", async () => {
|
|
const auth = makeAuth({
|
|
expires_at: new Date(FIXED_NOW.getTime() + 60 * 1_000).toISOString(),
|
|
});
|
|
const result = await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => auth,
|
|
persist: () => {
|
|
throw new Error("should not persist");
|
|
},
|
|
refresh: async (): Promise<ApiResult<TokenBundle>> => ({
|
|
ok: false,
|
|
status: 500,
|
|
code: "server_error",
|
|
error: "boom",
|
|
}),
|
|
});
|
|
expect(result.access_token).toBe("jwt-current");
|
|
});
|
|
|
|
it("exits AUTH_REQUIRED when no auth file exists", async () => {
|
|
const { exitCode } = await captureExit(async () => {
|
|
await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => null,
|
|
persist: () => undefined,
|
|
refresh: async () => ({
|
|
ok: false,
|
|
status: 0,
|
|
code: "x",
|
|
error: "x",
|
|
}),
|
|
});
|
|
});
|
|
expect(exitCode).toBe(3);
|
|
});
|
|
|
|
it("exits AUTH_REQUIRED when token is expired and refresh also fails", async () => {
|
|
const auth = makeAuth({
|
|
expires_at: new Date(FIXED_NOW.getTime() - 1_000).toISOString(),
|
|
});
|
|
const { exitCode } = await captureExit(async () => {
|
|
await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => auth,
|
|
persist: () => undefined,
|
|
refresh: async (): Promise<ApiResult<TokenBundle>> => ({
|
|
ok: false,
|
|
status: 401,
|
|
code: "invalid_refresh",
|
|
error: "expired",
|
|
}),
|
|
});
|
|
});
|
|
expect(exitCode).toBe(3);
|
|
});
|
|
|
|
it("exits FORBIDDEN when refresh returns 403 (membership revoked)", async () => {
|
|
const auth = makeAuth({
|
|
expires_at: new Date(FIXED_NOW.getTime() - 1_000).toISOString(),
|
|
});
|
|
const { exitCode } = await captureExit(async () => {
|
|
await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => auth,
|
|
persist: () => undefined,
|
|
refresh: async (): Promise<ApiResult<TokenBundle>> => ({
|
|
ok: false,
|
|
status: 403,
|
|
code: "membership_revoked",
|
|
error: "no membership",
|
|
}),
|
|
});
|
|
});
|
|
expect(exitCode).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("requireAuth — AuthData.method round-trip (W07)", () => {
|
|
function refreshOk(): ApiResult<TokenBundle> {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
data: {
|
|
token: "jwt-new",
|
|
refresh_token: "rt-new",
|
|
expires_at: new Date(FIXED_NOW.getTime() + 30 * 60 * 1_000).toISOString(),
|
|
},
|
|
responseHeaders: new Headers(),
|
|
rawBody: "",
|
|
};
|
|
}
|
|
|
|
it("preserves method: 'circle' through the refresh constructor", async () => {
|
|
const auth = makeAuth({
|
|
method: "circle",
|
|
expires_at: new Date(FIXED_NOW.getTime() + 60 * 1_000).toISOString(),
|
|
});
|
|
let persisted: AuthData | null = null;
|
|
const result = await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => auth,
|
|
persist: (next) => {
|
|
persisted = next;
|
|
},
|
|
refresh: async () => refreshOk(),
|
|
});
|
|
expect(result.method).toBe("circle");
|
|
expect(persisted!.method).toBe("circle");
|
|
expect(persisted!.access_token).toBe("jwt-new");
|
|
});
|
|
|
|
it("preserves method: 'email' through the refresh constructor", async () => {
|
|
const auth = makeAuth({
|
|
method: "email",
|
|
expires_at: new Date(FIXED_NOW.getTime() + 60 * 1_000).toISOString(),
|
|
});
|
|
let persisted: AuthData | null = null;
|
|
await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => auth,
|
|
persist: (next) => {
|
|
persisted = next;
|
|
},
|
|
refresh: async () => refreshOk(),
|
|
});
|
|
expect(persisted!.method).toBe("email");
|
|
});
|
|
|
|
it("does not invent a method for a pre-existing record without one", async () => {
|
|
const auth = makeAuth({
|
|
expires_at: new Date(FIXED_NOW.getTime() + 60 * 1_000).toISOString(),
|
|
});
|
|
let persisted: AuthData | null = null;
|
|
await requireAuth(ctx, {
|
|
now: () => FIXED_NOW,
|
|
read: () => auth,
|
|
persist: (next) => {
|
|
persisted = next;
|
|
},
|
|
refresh: async () => refreshOk(),
|
|
});
|
|
expect(persisted).not.toBeNull();
|
|
expect("method" in persisted!).toBe(false);
|
|
});
|
|
});
|