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
42 changes: 42 additions & 0 deletions src/frontend/src/api/files.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* REST client for the document-file surface. `/api/files/<name>` sits
* behind the backend Easy Auth gate, so a plain top-level navigation
* (an `<a href>` straight to the backend origin) is rejected with 401 --
* the browser never attaches the MSAL bearer to a navigation. This
* client fetches the blob with the forwarded bearer + principal id like
* every other backend call, then hands back an object URL the UI can
* open in a new tab for inline viewing.
*/
import { authHeaders, userIdHeaders } from "@/api/auth";
import { getBackendUrl, loadRuntimeConfig } from "@/api/runtimeConfig";

const FILES_URL = "/api/files";

/** Join the backend base (trailing slash trimmed) with an API path.
* Awaits `loadRuntimeConfig()` so the URL resolves against the real
* backend origin, never the SPA catch-all. */
async function apiUrl(path: string): Promise<string> {
await loadRuntimeConfig();
return `${getBackendUrl().replace(/\/$/, "")}${path}`;
}

/**
* Fetch a stored document blob by filename through the authenticated
* backend route and return an object URL for it. Throws on a non-2xx
* response. The caller owns the returned URL and should revoke it with
* `URL.revokeObjectURL` once the consumer no longer needs it.
*/
export async function fetchDocumentObjectUrl(filename: string): Promise<string> {
const url = await apiUrl(`${FILES_URL}/${encodeURIComponent(filename)}`);
const response = await fetch(url, {
method: "GET",
headers: { Accept: "*/*", ...userIdHeaders(), ...authHeaders() },
});
if (!response.ok) {
throw new Error(
`fetchDocumentObjectUrl: request failed with status ${response.status}`,
);
}
const blob = await response.blob();
return URL.createObjectURL(blob);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,38 @@
import { Dismiss20Regular } from "@fluentui/react-icons";
import { ChatActionType, useChat } from "@/pages/chat/ChatContext";
import { MarkdownContent } from "@/pages/chat/components/MarkdownContent";
import { deriveDocumentHref } from "./documentHref";
import { fetchDocumentObjectUrl } from "@/api/files";
import { deriveDocumentHref, resolveCitationDocument } from "./documentHref";
import styles from "./CitationDetailPanel.module.css";

/**
* Open a backend-gated document file. `/api/files/<name>` sits behind
* Easy Auth, so a bearer-less navigation 401s; instead a blank tab is
* opened synchronously (within the click gesture, so it is not blocked),
* then redirected to the authenticated blob object URL once the fetch
* resolves. On failure the placeholder tab is closed.
*/
async function openFileDocument(filename: string): Promise<void> {
const tab = window.open("", "_blank");
try {
const objectUrl = await fetchDocumentObjectUrl(filename);
if (tab) {
tab.location.href = objectUrl;
} else {
window.open(objectUrl, "_blank", "noopener,noreferrer");
}
} catch {
tab?.close();
}
}

export function CitationDetailPanel() {
const { state, dispatch } = useChat();
const citation = state.activeCitation;
if (citation === null) return null;

const title = citation.title.length > 0 ? citation.title : "Source";
const documentHref = deriveDocumentHref(citation);
const document = resolveCitationDocument(citation);

return (
<aside
Expand All @@ -56,13 +78,30 @@ export function CitationDetailPanel() {
<h3 className={styles.title} data-testid="citation-detail-title">
{title}
</h3>
{documentHref !== null && (
{document !== null && document.kind === "external" && (
<a
href={document.url}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
data-testid="citation-detail-link"
>
Open document
</a>
)}
{document !== null && document.kind === "file" && (
<a
href={documentHref}
href={deriveDocumentHref(citation) ?? "#"}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
data-testid="citation-detail-link"
onClick={(event) => {
// The blob lives behind Easy Auth; a plain navigation 401s,
// so intercept and fetch it with the forwarded bearer.
event.preventDefault();
void openFileDocument(document.filename);
}}
>
Open document
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ import { getBackendUrl } from "@/api/runtimeConfig";

const BLOB_HOST_FRAGMENT = ".blob.core.windows.net";

/**
* Classified "Open document" target for a citation. An `external`
* document is a plain http(s) URL the browser opens directly; a `file`
* document is a blob stored behind the backend Easy Auth gate, reachable
* only by the authenticated `/api/files/<name>` fetch (a bearer-less
* top-level navigation is rejected with 401).
*/
export type CitationDocument =
| { kind: "external"; url: string }
| { kind: "file"; filename: string };

function isHttpUrl(value: string): boolean {
return value.startsWith("http://") || value.startsWith("https://");
}
Expand Down Expand Up @@ -56,21 +67,34 @@ function lastPathSegment(rawUrl: string): string {
}
}

export function deriveDocumentHref(citation: Citation): string | null {
/**
* Classify a citation's document target as an external URL (open
* directly) or a backend-gated file (fetch with the bearer). Returns
* `null` when neither `url` nor `title` yields a usable target.
*/
export function resolveCitationDocument(citation: Citation): CitationDocument | null {
const url = citation.url;
if (isHttpUrl(url)) {
if (isAzureBlobHost(url)) {
const filename = lastPathSegment(url);
return filename.length > 0 ? filesHref(filename) : null;
return filename.length > 0 ? { kind: "file", filename } : null;
}
return url;
return { kind: "external", url };
}
const title = citation.title;
if (isHttpUrl(title)) {
return title;
return { kind: "external", url: title };
}
if (title.length > 0) {
return filesHref(title);
return { kind: "file", filename: title };
}
return null;
}

export function deriveDocumentHref(citation: Citation): string | null {
const doc = resolveCitationDocument(citation);
if (doc === null) {
return null;
}
return doc.kind === "external" ? doc.url : filesHref(doc.filename);
}
2 changes: 1 addition & 1 deletion src/frontend/tsconfig.tsbuildinfo
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/admin.tsx","./src/api/auth.tsx","./src/api/conversationhistory.tsx","./src/api/msal.tsx","./src/api/runtimeconfig.tsx","./src/api/speech.tsx","./src/api/streamchat.tsx","./src/components/coralshell/coralshellcolumn.tsx","./src/components/coralshell/coralshellrow.tsx","./src/components/coralshell/panelleft.tsx","./src/components/errorboundary/errorboundary.tsx","./src/components/header/header.tsx","./src/components/header/headertools.tsx","./src/components/header/msftcolorlogo.tsx","./src/components/header/multiagentlogo.tsx","./src/components/header/useridentity.tsx","./src/hooks/useauth.tsx","./src/hooks/usespeechrecognition.tsx","./src/models/admin.tsx","./src/models/auth.tsx","./src/models/chat.tsx","./src/models/sections.tsx","./src/models/speech.tsx","./src/models/status.tsx","./src/pages/admin/adminlayout.tsx","./src/pages/admin/configuration/configuration.tsx","./src/pages/admin/deletedata/deletedata.tsx","./src/pages/admin/ingestdata/ingestdata.tsx","./src/pages/chat/chatcontext.tsx","./src/pages/chat/chatpage.tsx","./src/pages/chat/components/historypanel.tsx","./src/pages/chat/components/markdowncontent.tsx","./src/pages/chat/components/messageinput.tsx","./src/pages/chat/components/messagelist.tsx","./src/pages/chat/components/answertokens.tsx","./src/pages/chat/components/citationtokens.tsx","./src/pages/chat/components/parseanswer.tsx","./src/pages/chat/components/reasoningtext.tsx","./src/pages/chat/components/citationdetailpanel/citationdetailpanel.tsx","./src/pages/chat/components/citationdetailpanel/documenthref.tsx","./src/pages/chat/components/citationpanel/citationpanel.tsx","./src/theme/fluentthemebridge.tsx","./src/theme/themecontext.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/api/admin.tsx","./src/api/auth.tsx","./src/api/conversationhistory.tsx","./src/api/files.tsx","./src/api/msal.tsx","./src/api/runtimeconfig.tsx","./src/api/speech.tsx","./src/api/streamchat.tsx","./src/components/coralshell/coralshellcolumn.tsx","./src/components/coralshell/coralshellrow.tsx","./src/components/coralshell/panelleft.tsx","./src/components/errorboundary/errorboundary.tsx","./src/components/header/header.tsx","./src/components/header/headertools.tsx","./src/components/header/msftcolorlogo.tsx","./src/components/header/multiagentlogo.tsx","./src/components/header/useridentity.tsx","./src/hooks/useauth.tsx","./src/hooks/usespeechrecognition.tsx","./src/models/admin.tsx","./src/models/auth.tsx","./src/models/chat.tsx","./src/models/sections.tsx","./src/models/speech.tsx","./src/models/status.tsx","./src/pages/admin/adminlayout.tsx","./src/pages/admin/configuration/configuration.tsx","./src/pages/admin/deletedata/deletedata.tsx","./src/pages/admin/ingestdata/ingestdata.tsx","./src/pages/chat/chatcontext.tsx","./src/pages/chat/chatpage.tsx","./src/pages/chat/components/historypanel.tsx","./src/pages/chat/components/markdowncontent.tsx","./src/pages/chat/components/messageinput.tsx","./src/pages/chat/components/messagelist.tsx","./src/pages/chat/components/answertokens.tsx","./src/pages/chat/components/citationtokens.tsx","./src/pages/chat/components/parseanswer.tsx","./src/pages/chat/components/reasoningtext.tsx","./src/pages/chat/components/citationdetailpanel/citationdetailpanel.tsx","./src/pages/chat/components/citationdetailpanel/documenthref.tsx","./src/pages/chat/components/citationpanel/citationpanel.tsx","./src/theme/fluentthemebridge.tsx","./src/theme/themecontext.tsx"],"version":"5.9.3"}
4 changes: 1 addition & 3 deletions tests/backend/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,7 @@ def _make(
# Pin the router-level auth gate to a fixed authenticated caller
# so functional route tests don't need to drive EasyAuth headers;
# the fail-closed 401 behavior is covered in test_admin_auth.py.
app.dependency_overrides[require_authenticated_user] = (
lambda: _FIXED_USER_ID
)
app.dependency_overrides[require_authenticated_user] = lambda: _FIXED_USER_ID
# Pin a sentinel credential so routes consuming ``CredentialDep``
# don't trip on the lifespan-less ASGI test transport.
cred = credential if credential is not None else AsyncMock()
Expand Down
76 changes: 76 additions & 0 deletions tests/frontend/api/files.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Vitest suite for `fetchDocumentObjectUrl` -- the authenticated bridge
* that pulls a backend-gated `/api/files/<name>` blob with the forwarded
* bearer + principal id and hands back an object URL. Global `fetch` and
* `URL.createObjectURL` are stubbed so the test runs offline.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchDocumentObjectUrl } from "@/api/files";
import { setAccessToken, setUserId } from "@/api/auth";
import { resetRuntimeConfig } from "@/api/runtimeConfig";

const RESOLVED_OID = "6b2e1f54-1c2d-4a8b-9f0e-1234567890ab";

let originalCreateObjectURL: typeof URL.createObjectURL | undefined;

beforeEach(() => {
resetRuntimeConfig();
originalCreateObjectURL = URL.createObjectURL;
URL.createObjectURL = vi.fn(() => "blob:mock-url");
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
setAccessToken(null);
setUserId(null);
resetRuntimeConfig();
if (originalCreateObjectURL) {
URL.createObjectURL = originalCreateObjectURL;
}
});

/** Find the fetch call that targeted the `/api/files/` route. */
function fileCall(fetchMock: ReturnType<typeof vi.fn>) {
return fetchMock.mock.calls.find((args) =>
String(args[0]).includes("/api/files/"),
);
}

describe("fetchDocumentObjectUrl", () => {
it("fetches with the bearer + principal id and returns an object URL", async () => {
setAccessToken("backend-token");
setUserId(RESOLVED_OID);
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("/config")) {
return new Response("{}", { status: 200 });
}
return new Response(new Blob(["%PDF"], { type: "application/pdf" }), {
status: 200,
});
});
vi.stubGlobal("fetch", fetchMock);

const objectUrl = await fetchDocumentObjectUrl("Benefit Options.pdf");

expect(objectUrl).toBe("blob:mock-url");
const call = fileCall(fetchMock);
expect(call).toBeDefined();
expect(String(call?.[0])).toContain("/api/files/Benefit%20Options.pdf");
const headers = new Headers((call?.[1] as RequestInit | undefined)?.headers);
expect(headers.get("authorization")).toBe("Bearer backend-token");
expect(headers.get("x-ms-client-principal-id")).toBe(RESOLVED_OID);
});

it("throws on a non-2xx response", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
if (String(input).includes("/config")) {
return new Response("{}", { status: 200 });
}
return new Response("", { status: 404 });
});
vi.stubGlobal("fetch", fetchMock);

await expect(fetchDocumentObjectUrl("missing.pdf")).rejects.toThrow(/404/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,34 @@
* citation is selected, escapes raw HTML in the snippet, and clears
* itself when dismissed.
*/
import { describe, expect, it } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { CitationDetailPanel } from "@/pages/chat/components/CitationDetailPanel/CitationDetailPanel";
import {
ChatActionType,
ChatProvider,
useChat,
} from "@/pages/chat/ChatContext";
import { fetchDocumentObjectUrl } from "@/api/files";
import type { Citation } from "@/models/chat";

vi.mock("@/api/files", () => ({
fetchDocumentObjectUrl: vi.fn(),
}));

const fetchDocumentObjectUrlMock = vi.mocked(fetchDocumentObjectUrl);

afterEach(() => {
vi.clearAllMocks();
});

const docFull: Citation = {
id: "doc-1",
title: "Benefit_Options.pdf - Part 1",
Expand Down Expand Up @@ -183,6 +200,46 @@ describe("CitationDetailPanel", () => {
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});

it("fetches the blob with the bearer and opens it when a file link is clicked", async () => {
fetchDocumentObjectUrlMock.mockResolvedValue("blob:doc-url");
const tab = { location: { href: "" }, close: vi.fn() } as unknown as Window;
const openSpy = vi.spyOn(window, "open").mockReturnValue(tab);

renderHarness(docNoUrl);
act(() => {
fireEvent.click(screen.getByTestId("harness-show"));
});
await act(async () => {
fireEvent.click(screen.getByTestId("citation-detail-link"));
});

// The gated `/api/files/<name>` blob is fetched with the forwarded
// bearer (not navigated to), then the placeholder tab is redirected
// to the resulting object URL.
expect(fetchDocumentObjectUrlMock).toHaveBeenCalledWith("No Link Source.pdf");
await waitFor(() => {
expect(tab.location.href).toBe("blob:doc-url");
});
openSpy.mockRestore();
});

it("does not fetch through the files API for an external citation link", () => {
renderHarness(docFull);
act(() => {
fireEvent.click(screen.getByTestId("harness-show"));
});

const link = screen.getByTestId(
"citation-detail-link",
) as HTMLAnchorElement;
// External citations keep a plain anchor to the source URL -- no
// authenticated blob fetch is involved.
expect(link.getAttribute("href")).toBe(
"https://example.com/benefit-options.pdf",
);
expect(fetchDocumentObjectUrlMock).not.toHaveBeenCalled();
});

it("omits the open-document link when the citation has no url and no title", () => {
renderHarness(docEmpty);
act(() => {
Expand Down
Loading
Loading