Skip to content
Merged
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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@
(BR-21) are deferred and untested.
- **Audit timing:** the upload audit row is written after the load commits.
A crash in that window leaves a loaded table with no audit row.
- **Authentication:** per-user sign-in exists in the API: local accounts with
lockout, OIDC, four roles (`docs/ROLES.md`) and a schema per tenant. The
browser still uses the shared API key until frontend sign-in (#96) lands.
The API refuses to start outside `dev` without `API_KEY`.
- **Authentication:** people sign in per user: local accounts with lockout,
OIDC, four roles (`docs/ROLES.md`) and a schema per tenant. The shared
`API_KEY` is for scripts and services only; the browser never holds it. The
API refuses to start outside `dev` without `API_KEY`. Requests that use the
key reach the default schemas, not any tenant's.
- **Rate limiting:** in-process only, so it suits a single API instance.
- **Frontend audit page:** shows this browser's local import history, not the
server audit log (see the naming note under Frontend Features).
Expand Down Expand Up @@ -103,7 +104,7 @@ PostgreDataMigrationAppWithCSVLoader/
│ │ ├── mapping-templates.ts ← saved column mapping templates
│ │ └── csv-preview.ts ← CSV parsing utilities
│ ├── src/__tests__/ ← 102 vitest tests
│ ├── .env.local ← VITE_API_BASE, VITE_API_KEY (not committed)
│ ├── .env.local ← VITE_API_BASE (not committed)
│ └── package.json
│
├── build/ ← SQL schema, loaders, deployment scripts
Expand Down
8 changes: 4 additions & 4 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ async def lifespan(app: FastAPI):
"variable before deploying anywhere reachable beyond localhost."
)
else:
# Fingerprint (first 4 chars + length) so mismatches with the frontend's
# VITE_API_KEY are diagnosable without leaking the secret to the log.
# Fixes BUG-006 in docs/BUG_REPORT.md.
# Fingerprint (first 4 chars + length) so a script's key mismatch is
# diagnosable without leaking the secret to the log (BUG-006). Browsers
# no longer hold this key; they sign in per user (#96).
fp = settings.API_KEY[:4] + "..." if len(settings.API_KEY) >= 4 else "***"
logger.info(
"API_KEY is set (fingerprint: %s, length: %d). "
"Frontend must send matching VITE_API_KEY via the X-API-Key header.",
"Scripts and services send it in the X-API-Key header; browsers sign in.",
fp, len(settings.API_KEY),
)
if not settings.PG_PASSWORD:
Expand Down
140 changes: 140 additions & 0 deletions csv-table-hub-main/src/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// src/__tests__/session.test.ts
// Frontend sign-in (#96): the browser holds a per-user session token, never
// the shared API key; an ended session sends the user back to sign-in.

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { isRedirect } from "@tanstack/react-router";
import {
authHeaders,
clearSession,
getSession,
requireSession,
signIn,
signOut,
type Session,
} from "@/lib/session";
import { listCsvFiles } from "@/lib/csv.functions";

const future = () => new Date(Date.now() + 3_600_000).toISOString();
const session = (over: Partial<Session> = {}): Session => ({
token: "tok-123",
expiresAt: future(),
username: "alice",
tenant: "acme",
role: "uploader",
...over,
});
const store = (s: Session) => sessionStorage.setItem("te.session", JSON.stringify(s));
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });

beforeEach(() => clearSession());
afterEach(() => vi.unstubAllGlobals());

describe("getSession", () => {
it("is null when nobody has signed in", () => {
expect(getSession()).toBeNull();
});

it("returns a live session", () => {
store(session());
expect(getSession()?.username).toBe("alice");
});

it("drops an expired session", () => {
store(session({ expiresAt: new Date(Date.now() - 1000).toISOString() }));
expect(getSession()).toBeNull();
expect(sessionStorage.getItem("te.session")).toBeNull();
});

it("treats unreadable storage as signed out", () => {
sessionStorage.setItem("te.session", "{not json");
expect(getSession()).toBeNull();
});
});

describe("signIn / signOut", () => {
it("stores the session the API returns", async () => {
const fetchMock = vi.fn(async () => json(session()));
vi.stubGlobal("fetch", fetchMock);
await signIn("acme", "alice", "pw");
expect(getSession()?.tenant).toBe("acme");
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toMatch(/\/api\/auth\/login$/);
expect(JSON.parse(init.body as string)).toEqual({
tenant: "acme",
username: "alice",
password: "pw",
});
});

it("rejects with one generic message on 401 and stores nothing", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => json({ detail: "Invalid tenant, username or password" }, 401)),
);
await expect(signIn("acme", "alice", "wrong")).rejects.toThrow(
"Invalid tenant, username or password",
);
expect(getSession()).toBeNull();
});

it("sign-out revokes the token on the server and forgets it here", async () => {
store(session());
const fetchMock = vi.fn(async () => json({ status: "ok" }));
vi.stubGlobal("fetch", fetchMock);
await signOut();
expect(getSession()).toBeNull();
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toMatch(/\/api\/auth\/logout$/);
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer tok-123");
});
});

describe("API calls", () => {
it("send the bearer token and never an X-API-Key", async () => {
store(session());
const fetchMock = vi.fn(async () => json([]));
vi.stubGlobal("fetch", fetchMock);
await listCsvFiles();
const headers = (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1]
.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer tok-123");
expect(Object.keys(headers).map((h) => h.toLowerCase())).not.toContain("x-api-key");
});

it("send no Authorization header when signed out", () => {
expect(authHeaders()).toEqual({});
});

it("a 401 ends the session and sends the user to sign-in", async () => {
store(session());
vi.stubGlobal(
"fetch",
vi.fn(async () => json({ detail: "Session expired or invalid; sign in again" }, 401)),
);
const assign = vi.fn();
vi.stubGlobal("location", { ...window.location, assign });
await expect(listCsvFiles()).rejects.toThrow("Your session has ended");
expect(getSession()).toBeNull();
expect(assign).toHaveBeenCalledWith("/auth");
});
});

describe("requireSession (route guard)", () => {
it("redirects to /auth when signed out", () => {
let thrown: unknown;
try {
requireSession();
} catch (e) {
thrown = e;
}
expect(isRedirect(thrown)).toBe(true);
expect((thrown as { options: { to: string } }).options.to).toBe("/auth");
});

it("lets a signed-in user through", () => {
store(session());
expect(() => requireSession()).not.toThrow();
});
});
71 changes: 71 additions & 0 deletions csv-table-hub-main/src/__tests__/sign-in-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// src/__tests__/sign-in-page.test.tsx — the sign-in form (#96).

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { clearSession, getSession } from "@/lib/session";

const navigate = vi.fn();
vi.mock("@tanstack/react-router", async (orig) => ({
...(await orig<typeof import("@tanstack/react-router")>()),
useNavigate: () => navigate,
}));

const { SignInPage } = await import("@/routes/auth");

const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });

async function fill(password = "pw") {
const user = userEvent.setup();
await user.type(screen.getByLabelText("Tenant"), "acme");
await user.type(screen.getByLabelText("Username"), "alice");
await user.type(screen.getByLabelText("Password"), password);
await user.click(screen.getByRole("button", { name: /sign in/i }));
}

beforeEach(() => {
clearSession();
navigate.mockReset();
});
afterEach(() => vi.unstubAllGlobals());

describe("SignInPage", () => {
it("keeps the button disabled until every field is filled", () => {
render(<SignInPage />);
expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled();
});

it("signs in and goes to the upload page", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
json({
token: "t",
expiresAt: new Date(Date.now() + 60_000).toISOString(),
username: "alice",
tenant: "acme",
role: "uploader",
}),
),
);
render(<SignInPage />);
await fill();
await vi.waitFor(() => expect(navigate).toHaveBeenCalledWith({ to: "/" }));
expect(getSession()?.username).toBe("alice");
});

it("shows the error, clears the password and stays put on a failed sign-in", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => json({ detail: "x" }, 401)),
);
render(<SignInPage />);
await fill("wrong");
expect(await screen.findByRole("alert")).toHaveTextContent(
"Invalid tenant, username or password",
);
expect(screen.getByLabelText("Password")).toHaveValue("");
expect(navigate).not.toHaveBeenCalled();
});
});
29 changes: 29 additions & 0 deletions csv-table-hub-main/src/components/session-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Signed-in user, tenant and role, with sign-out (#96).
import { useNavigate } from "@tanstack/react-router";
import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getSession, signOut } from "@/lib/session";

export function SessionMenu() {
const navigate = useNavigate();
const s = getSession();
if (!s) return null;
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span title={`Role: ${s.role}`}>
{s.username} · {s.tenant}
</span>
<Button
variant="ghost"
size="sm"
aria-label="Sign out"
onClick={async () => {
await signOut();
navigate({ to: "/auth" });
}}
>
<LogOut className="h-4 w-4" />
</Button>
</div>
);
}
17 changes: 12 additions & 5 deletions csv-table-hub-main/src/lib/csv.functions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod";
import type { QueryClient } from "@tanstack/react-query";
import { apiBase, authHeaders, clearSession } from "@/lib/session";

const columnTypeSchema = z.enum(["int8", "numeric", "date", "timestamptz", "boolean", "text"]);
export type ColumnType = z.infer<typeof columnTypeSchema>;
Expand Down Expand Up @@ -98,19 +99,24 @@ const previewRowsSchema = z.object({
rows: z.array(z.record(previewRowValueSchema)),
});

const apiBase = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
const apiKey = import.meta.env.VITE_API_KEY ?? "";

async function apiFetch<T>(path: string, schema: z.ZodType<T>, options?: RequestInit): Promise<T> {
const response = await fetch(`${apiBase}${path}`, {
...options,
headers: {
"content-type": "application/json",
...(apiKey ? { "X-API-Key": apiKey } : {}),
...authHeaders(),
...options?.headers,
},
});

// #96: an expired or revoked session ends here; the route guard then sends
// the user to the sign-in page.
if (response.status === 401) {
clearSession();
if (typeof window !== "undefined") window.location.assign("/auth");
throw new Error("Your session has ended. Please sign in again.");
}

const text = await response.text();
let payload: unknown = null;
if (text) {
Expand All @@ -122,7 +128,8 @@ async function apiFetch<T>(path: string, schema: z.ZodType<T>, options?: Request
}

if (!response.ok) {
const message = (payload as any)?.message || (payload as any)?.error || response.statusText;
const body = payload as { message?: unknown; error?: unknown; detail?: unknown } | null;
const message = body?.message || body?.error || body?.detail || response.statusText;
throw new Error(typeof message === "string" ? message : String(payload));
}

Expand Down
Loading
Loading