Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

## 0.0.11-beta.3 — 2026-05-28

### Features
- Add `failproofai auth login | logout | whoami` for email-OTP login against the failproofai api-server: new `src/auth/{session-store,api-client,prompts,manager}.ts`; session at `~/.failproofai/session.json` (mode 0600); default base URL `https://api.befailproof.ai` overridable via `FAILPROOFAI_API_BASE_URL`; login prompts interactively or takes `--email`; logout best-effort revokes server-side then always clears local state; whoami auto-refreshes an expired access token, clears the session only on 401/403 from refresh, preserves it on transient failures, and wraps fetch/timeout errors as `CliError` so they print `Error:` instead of `Unexpected error:` (the follow-up promised by #380's removal of the old device-flow auth) (#396).

### Fixes
- `block-secrets-write` no longer denies `Write` of source files containing the substring `credentials` or `id_rsa`: `SECRET_FILE_CREDENTIALS_RE` now anchors to well-known credential paths (`.aws/credentials`, `.docker/credentials.json`, `.netrc`, …), `SECRET_FILE_ID_RSA_RE` matches the SSH private-key basename only (so `<anywhere>/id_rsa` blocks but `id_rsa_backup.md` and `id_rsa.pub` don't), and both patterns accept POSIX `/` and Windows `\` separators (#396).
- `sanitize-connection-strings` no longer flags grep/perl arguments that merely contain a scheme name (e.g. `'postgres://|mysql://'`): the regex now requires a URL-safe `<user>:<pass>@<host>` shape so regex metachars in the pre-`@` segment break the match while real connection strings still get redacted (#396).
- `warn-repeated-tool-calls` canonicalizes the fingerprint (sorts input keys recursively) so the same logical call always hashes the same regardless of property order, records a `warned` set in the sidecar so the warning fires once per fingerprint per session instead of repeating every turn, and fires on the 3rd identical call (matching the `THRESHOLD = 3` constant — previously off-by-one, fired on the 4th) (back-compat with the old bare-counts sidecar format preserved) (#396).
- Fix the `bump-platform-submodule.yml` workflow's first post-merge push, which failed with `fatal: could not read Username for 'https://github.com'`. The `persist-credentials: false` hardening from #394 left the cross-repo `git push`/`fetch` unauthenticated, and the inline `Authorization: bearer …` extraheader only authenticates GitHub's REST API — git-over-HTTPS smart-protocol expects Basic auth with `x-access-token:<pat>`. Switch to a base64-encoded Basic header (matching `actions/checkout`'s own internal extraheader format) so the push and the rebase-and-retry fetch in the loop both authenticate (#395).

### Features
Expand Down
159 changes: 159 additions & 0 deletions __tests__/auth/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

interface CapturedRequest {
url: string;
init: RequestInit;
}

function mockFetch(
responder: (req: CapturedRequest) => { status?: number; body?: unknown; headers?: Record<string, string> },
) {
const captured: CapturedRequest[] = [];
const fetchSpy = vi.fn(async (url: string | URL, init: RequestInit = {}) => {
const req: CapturedRequest = { url: String(url), init };
captured.push(req);
const { status = 200, body, headers = {} } = responder(req);
return new Response(body === undefined ? null : JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...headers },
});
});
vi.stubGlobal("fetch", fetchSpy);
return { captured };
}

describe("auth/api-client", () => {
beforeEach(() => {
vi.resetModules();
delete process.env.FAILPROOFAI_API_BASE_URL;
});

afterEach(() => {
vi.unstubAllGlobals();
delete process.env.FAILPROOFAI_API_BASE_URL;
});

describe("resolveBaseUrl", () => {
it("uses the bundled default when no env override is set", async () => {
const { resolveBaseUrl } = await import("../../src/auth/api-client");
expect(resolveBaseUrl()).toBe("https://api.befailproof.ai");
});

it("respects FAILPROOFAI_API_BASE_URL and strips a trailing slash", async () => {
process.env.FAILPROOFAI_API_BASE_URL = "http://localhost:8080/";
const { resolveBaseUrl } = await import("../../src/auth/api-client");
expect(resolveBaseUrl()).toBe("http://localhost:8080");
});
});

describe("requestLoginCode", () => {
it("POSTs the email and returns the parsed body", async () => {
const { captured } = mockFetch(() => ({
body: { status: "code_sent", expires_in: 600, resend_available_in: 30 },
}));
const { requestLoginCode } = await import("../../src/auth/api-client");
const res = await requestLoginCode("http://localhost:8080", "jane@acme.com");

expect(res).toEqual({ status: "code_sent", expires_in: 600, resend_available_in: 30 });
expect(captured[0]?.url).toBe("http://localhost:8080/v0/auth/login/request");
expect(captured[0]?.init.method).toBe("POST");
expect(JSON.parse(captured[0]?.init.body as string)).toEqual({ email: "jane@acme.com" });
});
});

describe("verifyLoginCode", () => {
it("POSTs email + code and returns the token response", async () => {
const { captured } = mockFetch(() => ({
body: {
token_type: "Bearer",
access_token: "access-abc",
access_expires_in: 3600,
refresh_token: "refresh-xyz",
refresh_expires_in: 2_592_000,
user: { id: "u-1", email: "jane@acme.com" },
},
}));
const { verifyLoginCode } = await import("../../src/auth/api-client");
const res = await verifyLoginCode("http://localhost:8080", "jane@acme.com", "123456");

expect(res.user.email).toBe("jane@acme.com");
expect(res.access_token).toBe("access-abc");
expect(JSON.parse(captured[0]?.init.body as string)).toEqual({
email: "jane@acme.com",
code: "123456",
});
});

it("throws a CliError with the server detail on 401", async () => {
mockFetch(() => ({
status: 401,
body: { success: false, code: "invalid_code", detail: "The code is invalid or has expired." },
}));
const { verifyLoginCode } = await import("../../src/auth/api-client");
await expect(verifyLoginCode("http://x", "a@b.c", "999999"))
.rejects.toThrow("The code is invalid or has expired.");
});

it("wraps a network failure into CliError instead of letting it bubble as Unexpected error", async () => {
vi.stubGlobal("fetch", vi.fn(async () => {
throw new TypeError("fetch failed");
}));
const { verifyLoginCode } = await import("../../src/auth/api-client");
const err = await verifyLoginCode("http://localhost:8080", "a@b.c", "111111")
.then(() => null, (e: Error) => e);
expect(err).not.toBeNull();
expect(err?.name).toBe("CliError");
expect(err?.message).toContain("Network error");
});

it("wraps an AbortError-style timeout into CliError", async () => {
const timeoutErr = new Error("timed out");
timeoutErr.name = "TimeoutError";
vi.stubGlobal("fetch", vi.fn(async () => { throw timeoutErr; }));
const { verifyLoginCode } = await import("../../src/auth/api-client");
const err = await verifyLoginCode("http://localhost:8080", "a@b.c", "111111")
.then(() => null, (e: Error) => e);
expect(err?.name).toBe("CliError");
expect(err?.message).toContain("timed out");
});

it("surfaces the Retry-After hint on 429", async () => {
mockFetch(() => ({
status: 429,
body: { success: false, code: "rate_limited", detail: "Too many requests, please try again later." },
headers: { "retry-after": "42" },
}));
const { verifyLoginCode } = await import("../../src/auth/api-client");
await expect(verifyLoginCode("http://x", "a@b.c", "111111"))
.rejects.toThrow(/retry after 42s/);
});
});

describe("logout", () => {
it("includes the Bearer header and the refresh token body, returns void on 204", async () => {
const { captured } = mockFetch(() => ({ status: 204 }));
const { logout } = await import("../../src/auth/api-client");
await expect(logout("http://x", "access-tok", "refresh-tok")).resolves.toBeUndefined();
expect(captured[0]?.url).toBe("http://x/v0/auth/logout");
const headers = captured[0]?.init.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer access-tok");
expect(JSON.parse(captured[0]?.init.body as string)).toEqual({ refresh_token: "refresh-tok" });
});
});

describe("me", () => {
it("GETs /v0/auth/me with the bearer access token", async () => {
const { captured } = mockFetch(() => ({
body: { id: "u-1", email: "jane@acme.com", status: "active", created_at: "2026-01-01T00:00:00Z" },
}));
const { me } = await import("../../src/auth/api-client");
const res = await me("http://x", "access-tok");
expect(res.email).toBe("jane@acme.com");
expect(captured[0]?.url).toBe("http://x/v0/auth/me");
expect(captured[0]?.init.method).toBe("GET");
const headers = captured[0]?.init.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer access-tok");
});
});
});
Loading