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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ All notable changes to api-medic are documented here. The format follows [Keep a

## [Unreleased]

### Added

- HAR picker meta line on the hosted demo's HAR tab now surfaces uncompressed file size, capture date (from `log.pages[0].startedDateTime` or the first entry's `startedDateTime`), and unique-host count alongside the existing entry count. Fields are omitted when absent from the parsed HAR.
- Report action bar: `Re-run` re-fires whichever input produced the current Report (Run-tab composer state, HAR-tab uploaded file, Demos-tab fixture, or extension panel's captured DevTools entry). `Export markdown` copies a markdown rendering of the Report to the clipboard, mirroring the CLI's `--output markdown` format.

### Fixed

- Report action buttons (`Re-run`, `Export markdown`) are now wired up — they previously rendered but did nothing on click. `Share report` is intentionally hidden in v1: it requires persistence, which is out of scope.
- Lambda `/api/analyze` now returns 400 with a useful `detail` for malformed HAR entries (missing `request.method`, missing `request.url`, non-integer `response.status`, out-of-range status codes) instead of a silent 500. The HAR parser also validates required request fields up front rather than letting `KeyError` escape. This was hitting any browser-extension capture path that produced a partial entry — the panel previously surfaced "Analyze failed: 500 Internal Server Error" with no actionable hint.

## [1.1.0] - 2026-05-01

### Added
Expand Down
8 changes: 6 additions & 2 deletions deploy/lambda/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ def _handle_analyze(event: dict[str, Any]) -> dict[str, Any]:
return _err(400, "Body must include 'kind' (one of 'har', 'curl').")

kind = body["kind"]
# Catch ValueError (incl. Pydantic ValidationError, which subclasses it)
# and KeyError. Either can be raised deep inside parse_har / analyze when
# an input field is missing or malformed. Without this the Lambda would
# 500 on payloads it could meaningfully reject as 400.
try:
if kind == "har":
har_payload = body.get("har")
Expand All @@ -86,10 +90,10 @@ def _handle_analyze(event: dict[str, Any]) -> dict[str, Any]:
captured = parse_curl(curl)
else:
return _err(400, f"Unknown kind: {kind!r} (expected 'har' or 'curl').")
except ValueError as e:
report = analyze(captured)
except (ValueError, KeyError) as e:
return _err(400, str(e))

report = analyze(captured)
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
Expand Down
12 changes: 7 additions & 5 deletions extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,13 @@ changes.

- The panel only sees requests the browser actually completes.
Connections the browser refuses at its own layer — expired or
untrusted certs, mixed-content blocks, CSP-blocked subresources —
never fire `chrome.devtools.network.onRequestFinished`, so they
don't appear in the panel and can't be analyzed. For diagnosing
those, capture a curl reproduction or paste a HAR into the hosted
demo at `https://api-medic.markandrewmarquez.com`.
untrusted certs (e.g. `https://expired.badssl.com`), mixed-content
blocks, CSP-blocked subresources — never fire
`chrome.devtools.network.onRequestFinished`, so they don't appear in
the panel and can't be analyzed. For diagnosing those, capture a
curl reproduction (`curl -v https://expired.badssl.com`) and run
`api-medic from-curl` locally, or paste the failing request into
the hosted demo at `https://api-medic.markandrewmarquez.com`.
- Captures are scoped to the page DevTools is attached to. Background
tabs, service-worker requests, and requests issued before DevTools
was opened won't appear.
Expand Down
92 changes: 92 additions & 0 deletions extension/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,25 @@ describe("<App />", () => {
).toBeInTheDocument();
});

it("Re-run on the rendered Report re-fires analyzeHarEntry with the same captured entry", async () => {
mockAnalyze.mockResolvedValue(HEALTHY_REPORT);
render(<App />);
const entry = fireRequest();

fireEvent.click(screen.getByText(entry.request.url));
fireEvent.click(
screen.getByRole("button", { name: /Analyze with api-medic/i }),
);
await screen.findByText(/https:\/\/api\.example\.com\/v1\/health/);
expect(mockAnalyze).toHaveBeenCalledTimes(1);

fireEvent.click(screen.getByRole("button", { name: /^Re-run$/ }));
await waitFor(() => {
expect(mockAnalyze).toHaveBeenCalledTimes(2);
});
expect(mockAnalyze.mock.calls[1]?.[0]).toBe(entry);
});

it("shows an error banner and no report when analyze fails", async () => {
mockAnalyze.mockRejectedValue(new Error("Analyze failed: URL is invalid"));
render(<App />);
Expand Down Expand Up @@ -215,6 +234,79 @@ describe("<App />", () => {
expect(screen.queryByText(/^Selected:/)).not.toBeInTheDocument();
});

it("renders the auth.missing finding for a captured 401 DevTools entry", async () => {
// Regression for the reported "extension fails on httpbin/401" symptom.
// We feed the panel a DevTools-shaped 401 entry and assert that when
// the analyze call returns the contractually correct Report (the same
// shape the parser+engine produces server-side, see
// tests/unit/test_parser.py::test_chrome_devtools_401_entry_yields_auth_missing),
// the panel renders the `auth.missing` finding's title — confirming
// the render path doesn't drop the finding.
const httpbin401Report: Report = {
schema_version: "1.0",
source: "extension",
timestamp: "2026-05-01T12:00:00Z",
request: {
method: "GET",
url: "https://httpbin.org/status/401",
headers: { Accept: "*/*" },
body_size_bytes: 0,
},
response: {
status_code: 401,
status_text: "UNAUTHORIZED",
headers: { "WWW-Authenticate": 'Basic realm="Fake Realm"' },
body_size_bytes: 0,
protocol: "HTTP/1.1",
},
timing: { dns_ms: null, connect_ms: null, tls_ms: null, ttfb_ms: 100, download_ms: 50, total_ms: 150 },
findings: [
{
id: "auth.missing",
severity: "critical",
title: "No Authorization header sent",
explanation:
"The server returned 401 and the request didn't include an Authorization header.",
evidence: { status_code: 401, had_authorization_header: false },
suggested_fix: "Add an Authorization header and retry.",
},
],
};
mockAnalyze.mockResolvedValue(httpbin401Report);

render(<App />);
fireRequest({
request: {
method: "GET",
url: "https://httpbin.org/status/401",
headers: [{ name: "Accept", value: "*/*" }],
} as unknown as chrome.devtools.network.Request["request"],
response: {
status: 401,
statusText: "UNAUTHORIZED",
headers: [
{ name: "WWW-Authenticate", value: 'Basic realm="Fake Realm"' },
],
content: { size: 0, mimeType: "text/html" },
} as unknown as chrome.devtools.network.Request["response"],
});

fireEvent.click(screen.getByText("https://httpbin.org/status/401"));
fireEvent.click(
screen.getByRole("button", { name: /Analyze with api-medic/i }),
);

expect(
await screen.findByText(/No Authorization header sent/),
).toBeInTheDocument();
// And the analyze call received the full DevTools entry (not stripped).
const passedEntry = mockAnalyze.mock.calls[0]?.[0] as
| chrome.devtools.network.Request
| undefined;
expect(passedEntry?.request.url).toBe("https://httpbin.org/status/401");
expect(passedEntry?.response?.status).toBe(401);
});

it("caps the captured list at 100 entries", () => {
const { container } = render(<App />);
for (let i = 0; i < 105; i++) {
Expand Down
4 changes: 3 additions & 1 deletion extension/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,9 @@ export function App() {
</div>
)}

{report && <ReportView report={report} />}
{report && (
<ReportView report={report} onRerun={onAnalyze} rerunBusy={loading} />
)}
</div>
);
}
9 changes: 7 additions & 2 deletions frontend/src/components/FixtureBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export function FixtureBrowser({ fixtures }: FixtureBrowserProps) {
: (fixtures[0]?.id ?? DEFAULT_FIXTURE);
const [selected, setSelected] = useState<string>(initial);
const [state, setState] = useState<LoadState>({ kind: "idle" });
const [reloadTick, setReloadTick] = useState(0);

useEffect(() => {
let cancelled = false;
Expand All @@ -40,7 +41,7 @@ export function FixtureBrowser({ fixtures }: FixtureBrowserProps) {
return () => {
cancelled = true;
};
}, [selected]);
}, [selected, reloadTick]);

return (
<div>
Expand Down Expand Up @@ -73,7 +74,11 @@ export function FixtureBrowser({ fixtures }: FixtureBrowserProps) {
{state.message}
</div>
) : (
<ReportView report={state.report} />
<ReportView
report={state.report}
onRerun={() => setReloadTick((n) => n + 1)}
rerunBusy={false}
/>
)}
</div>
);
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/components/HarUpload.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,111 @@ describe("HarUpload", () => {
).toBeInTheDocument();
});

it("Re-run on the rendered Report re-fires /api/analyze with the same file", async () => {
let calls = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = typeof input === "string" ? input : (input as Request).url;
expect(url.endsWith("/api/analyze")).toBe(true);
calls++;
return new Response(JSON.stringify(corsReport), { status: 200 });
});

render(<HarUpload />);
uploadFile(
new File([JSON.stringify(VALID_HAR)], "session.har", {
type: "application/json",
}),
);
await screen.findByText(/1 entry/);
fireEvent.click(screen.getByRole("button", { name: /^Analyze$/ }));
await screen.findByText(/CORS preflight/);
expect(calls).toBe(1);

fireEvent.click(screen.getByRole("button", { name: /^Re-run$/ }));
await screen.findByText(/CORS preflight/);
expect(calls).toBe(2);
});

it("shows size, capture date, and unique host count when present in the HAR", async () => {
const har = {
log: {
version: "1.2",
creator: { name: "test", version: "0" },
pages: [
{ id: "p1", startedDateTime: "2026-04-29T15:00:00Z", title: "x" },
],
entries: [
{
request: { method: "GET", url: "https://api.example.com/a" },
response: { status: 200 },
},
{
request: { method: "GET", url: "https://cdn.example.com/b" },
response: { status: 200 },
},
{
request: { method: "GET", url: "https://api.example.com/c" },
response: { status: 200 },
},
],
},
};
render(<HarUpload />);
uploadFile(
new File([JSON.stringify(har)], "session.har", {
type: "application/json",
}),
);
await screen.findByText(/3 entries/);
// Size, capture date, and unique host count appear on the meta line.
expect(
screen.getByText(/captured 2026-04-29T15:00:00Z/),
).toBeInTheDocument();
// Two unique hosts (api.example.com, cdn.example.com).
expect(screen.getByText(/2 hosts/)).toBeInTheDocument();
});

it("falls back to entries[0].startedDateTime when log.pages is absent", async () => {
const har = {
log: {
version: "1.2",
creator: { name: "test", version: "0" },
entries: [
{
request: { method: "GET", url: "https://api.example.com/a" },
response: { status: 200 },
startedDateTime: "2026-04-29T16:30:00Z",
},
],
},
};
render(<HarUpload />);
uploadFile(
new File([JSON.stringify(har)], "session.har", {
type: "application/json",
}),
);
await screen.findByText(/1 entry/);
expect(
screen.getByText(/captured 2026-04-29T16:30:00Z/),
).toBeInTheDocument();
expect(screen.getByText(/1 host/)).toBeInTheDocument();
});

it("omits the capture-date fragment when no startedDateTime is available anywhere", async () => {
render(<HarUpload />);
// VALID_HAR has no log.pages and no entries[0].startedDateTime.
uploadFile(
new File([JSON.stringify(VALID_HAR)], "session.har", {
type: "application/json",
}),
);
await screen.findByText(/1 entry/);
expect(screen.queryByText(/captured /)).toBeNull();
// Size and host count still render.
expect(screen.getByText(/1 host/)).toBeInTheDocument();
});

it("forwards the HAR JSON in the analyze body", async () => {
let body: { kind?: string; har?: unknown } = {};
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
Expand Down
Loading
Loading