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
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,13 @@ Run through this mentally after any change to `src/hooks/` or `dist/` build:
| `src/hooks/custom-hooks-loader.ts` | Top-level custom hook loading orchestrator |
| `src/index.ts` | Public API → `dist/index.js` bundle entry |
| `package.json` | `files` must include `dist/`; `build` must build `dist/index.js` |

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
32 changes: 31 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

49 changes: 47 additions & 2 deletions __tests__/actions/update-scheduled-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@
* server actions the dashboard calls (not a reimplementation), so CLI/dashboard
* parity is real: both write through the same `updateConfig`.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

// `setAutoAuditAction(true)` now refuses without a session — scheduling and
// mailing are one decision, so a timer with nobody to tell is a switch that
// reads as on and produces nothing. These tests are about the CONFIG WRITE, so
// the session check is stubbed to "signed in"; the refusal itself is covered in
// the settings component tests.
const { whoAmIMock } = vi.hoisted(() => ({ whoAmIMock: vi.fn() }));
vi.mock("../../lib/auth/auth-store", () => ({ whoAmI: whoAmIMock }));
import { mkdtempSync, readFileSync, rmSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
Expand All @@ -33,6 +41,10 @@ beforeEach(() => {
home = mkdtempSync(resolve(tmpdir(), "fpai-settings-write-"));
process.env.FAILPROOFAI_HOME = home;
mkdirSync(home, { recursive: true });
whoAmIMock.mockReset().mockResolvedValue({
me: { id: "u1", email: "sidd@exosphere.host", status: "active", created_at: "" },
auth: { user: { id: "u1", email: "sidd@exosphere.host" } },
});
});

afterEach(() => {
Expand All @@ -45,7 +57,7 @@ describe("scheduled-audit write actions", () => {
it("setAutoAuditAction toggles [audit] auto and reflects what the config stored", async () => {
expect(readConfig().audit.auto).toBe(false);
const res = await setAutoAuditAction(true);
expect(res.auto).toBe(true);
expect(res).toEqual({ ok: true, auto: true });
expect(readConfig().audit.auto).toBe(true);
});

Expand Down Expand Up @@ -98,3 +110,36 @@ describe("scheduled-audit write actions", () => {
expect(after.audit.auto).toBe(true);
});
});

describe("a session the server rejects", () => {
it("is REPORTED, not thrown, so the caller can act on it", async () => {
// Next masks a thrown server-action error before the browser sees it — the
// client gets an opaque digest and never the message. A caller matching on
// the text works in development and silently degrades to a generic failure
// in production, which is what shipped: the page showed an address read
// from the local session file, the toggle took the signed-in path, and the
// click dead-ended on "could not turn that on."
whoAmIMock.mockResolvedValue(null);

const res = await setAutoAuditAction(true);

expect(res).toEqual({ ok: false, reason: "signed-out" });
// And nothing was written: a timer with nobody to tell reads as on and
// produces nothing.
expect(readConfig().audit.auto).toBe(false);
});

it("still lets somebody turn scheduling OFF", async () => {
// The refusal is one-directional on purpose. An expired session must not
// trap a person into keeping a feature they are trying to disable.
whoAmIMock.mockResolvedValue({ me: { id: "u", email: "a@b.c" } });
await setAutoAuditAction(true);
expect(readConfig().audit.auto).toBe(true);

whoAmIMock.mockResolvedValue(null);
const res = await setAutoAuditAction(false);

expect(res).toEqual({ ok: true, auto: false });
expect(readConfig().audit.auto).toBe(false);
});
});
119 changes: 119 additions & 0 deletions __tests__/audit/cli-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// @vitest-environment node
/**
* `failproofai audit --schedule`'s sign-in prompts.
*
* The whole flow is two questions and a retry loop, and the part worth pinning
* is where the loop's assumptions meet the api-server's: it re-asks for a code
* only when the server says `invalid_code`, so any other rejection ends the
* sign-in. What the prompts refuse LOCALLY therefore decides which mistakes cost
* a retry and which cost the whole login.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";

const { promptTextMock, requestMock, verifyMock, writeAuthMock } = vi.hoisted(() => ({
promptTextMock: vi.fn(),
requestMock: vi.fn(),
verifyMock: vi.fn(),
writeAuthMock: vi.fn(),
}));

vi.mock("../../src/hooks/tui", () => ({ promptText: promptTextMock }));
vi.mock("../../lib/auth/api-server-client", async (orig) => ({
...(await orig<typeof import("../../lib/auth/api-server-client")>()),
requestLoginCode: requestMock,
verifyLoginCode: verifyMock,
}));
vi.mock("../../lib/auth/auth-store", async (orig) => ({
...(await orig<typeof import("../../lib/auth/auth-store")>()),
writeAuth: writeAuthMock,
}));

import { runLogin } from "../../src/audit/cli-login";
import { AuthApiError } from "../../lib/auth/api-server-client";

/** The `validate` the code prompt was handed, so it can be exercised directly. */
function codeValidator(): (v: string) => string | null {
const call = promptTextMock.mock.calls.find(([opts]) => opts.message === "the code");
expect(call, "the code prompt was never reached").toBeDefined();
return call![0].validate;
}

const TOKENS = {
token_type: "Bearer" as const,
access_token: "at",
access_expires_in: 900,
refresh_token: "rt",
refresh_expires_in: 86_400,
user: { id: "u_1", email: "you@example.com" },
};

beforeEach(() => {
promptTextMock.mockReset();
requestMock.mockReset().mockResolvedValue({
status: "code_sent",
expires_in: 600,
resend_available_in: 60,
});
verifyMock.mockReset().mockResolvedValue(TOKENS);
writeAuthMock.mockReset();
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
});

describe("the code prompt", () => {
it("refuses a value longer than the api-server will validate", async () => {
// The server bounds `code` at 4..12 characters, and a longer one comes back
// as `validation_error` rather than `invalid_code` — which the retry loop
// below does not recognise, so the whole sign-in aborts and the next attempt
// costs a fresh email. Pasting the sentence around the code out of the
// message, rather than just the code, is the ordinary way to hit that.
promptTextMock
.mockResolvedValueOnce("you@example.com")
.mockResolvedValueOnce("123456");

await runLogin();

const validate = codeValidator();
expect(validate("Your code is 123456")).toMatch(/paste just the code/i);
expect(validate("1234567890123")).toBeTruthy();
// And the ordinary six digits still pass, plus the boundary either side.
expect(validate("123456")).toBeNull();
expect(validate("1234")).toBeNull();
expect(validate("123456789012")).toBeNull();
expect(validate("123")).toBeTruthy();
});
});

describe("the retry loop", () => {
it("re-asks on a wrong code rather than sending a second email", async () => {
promptTextMock
.mockResolvedValueOnce("you@example.com")
.mockResolvedValueOnce("000000")
.mockResolvedValueOnce("123456");
verifyMock
.mockRejectedValueOnce(new AuthApiError(401, "invalid_code", "that code is wrong"))
.mockResolvedValueOnce(TOKENS);

const user = await runLogin();

expect(user.email).toBe("you@example.com");
// One code, two attempts at it. A fresh email per typo would burn the
// server's own per-address rate limit on the user's behalf.
expect(requestMock).toHaveBeenCalledTimes(1);
expect(verifyMock).toHaveBeenCalledTimes(2);
expect(writeAuthMock).toHaveBeenCalledTimes(1);
});

it("stops on anything that is not a wrong code", async () => {
// A rate limit or a validation failure will not become a success by asking
// the same question again, and the message names the remedy instead.
promptTextMock
.mockResolvedValueOnce("you@example.com")
.mockResolvedValueOnce("123456");
verifyMock.mockRejectedValue(new AuthApiError(429, "rate_limited", "slow down", 30));

await expect(runLogin()).rejects.toThrow(/too many attempts/i);
expect(verifyMock).toHaveBeenCalledTimes(1);
expect(writeAuthMock).not.toHaveBeenCalled();
});
});
Loading