visionA/visionA-frontend/src/lib/api/model-download.test.ts
jim800121chen 3e45532f55 fix(model-download): 改 redirect/query-string token 下載,根除 CORS preflight 405
模型庫下載原本前端用 fetch + Authorization: Bearer 跨 origin 直連 FAA,
觸發 CORS preflight(OPTIONS);FAA 未設 CORS、OPTIONS 回 405 → 下載失敗。

FAA 設計本就支援「token 放 query string(access_token) + redirect 導航下載」,
故不需 FAA 設 CORS。改採 ADR-017 §11 v1.3 乙案:

- backend: download_url 組成含 ?access_token={url.QueryEscape(token)} 完整 FAA URL;
  ModelDownloadResponse.Token 標 deprecated 保留(向下相容);token 不進 log
- frontend: 移除 downloadModelFile/deriveDownloadFilename(fetch+blob),
  改 triggerNavDownload 用 <a download href> 導航;ModelDownloadGrant 移除 token 欄
- 整條鏈無 CORS:前端→backend 同 origin、前端→FAA 導航無 preflight

docs: ADR-017 增補 §11(v1.3) + TDD.md §9.5
tests: backend 8 PASS(含 token escape 邊界)、frontend 35 PASS;Reviewer 通過(0C/0M)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 06:18:05 +08:00

190 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Model Download API Client 單元測試Phase 0.9 / ADR-017 v1.3 — query-string token + redirect 導航)
*
* 覆蓋:
* 1. getModelDownload — 200 正規化download_url + expires_at/ 404 / 403 / 501 / 502 / parse 缺欄
* 2. triggerNavDownload — 建立 anchor、href = 傳入的 downloadUrl、有 download 屬性、click 後從 DOM 移除
* 3. ModelDownloadError 形狀
*
* v1.3 移除(對齊 ADR §11.8
* - downloadModelFilefetch + Bearer + blobCORS 405 元兇)整段拔掉 → 不再測。
* - deriveDownloadFilenameredirect 跨 origin 檔名由 FAA Content-Disposition 決定、前端命名無效)→ 不再測。
* - triggerBlobDownloadblob object URL→ 改為 triggerNavDownloadFAA 真實 URL不需 createObjectURL
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getModelDownload,
ModelDownloadError,
triggerNavDownload,
} from "./model-download";
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof fetch;
});
afterEach(() => {
vi.restoreAllMocks();
});
/* ========================================================================== */
/* 1. getModelDownload */
/* ========================================================================== */
describe("getModelDownload", () => {
it("200 → 正規化 { downloadUrl, expiresAt }snake_casedownload_url 已含 ?access_token=", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
success: true,
data: {
download_url:
"https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc123",
token: "fdt_abc123", // 後端仍回deprecated前端不使用
expires_at: "2026-06-07T12:02:00Z",
},
}),
);
const grant = await getModelDownload("m1");
expect(grant.downloadUrl).toBe(
"https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc123",
);
expect(grant.expiresAt).toBe("2026-06-07T12:02:00Z");
// 前端不再讀 token 欄位(已移出 ModelDownloadGrant 型別)
expect((grant as unknown as Record<string, unknown>).token).toBeUndefined();
});
it("camelCase 也吃downloadUrl / expiresAt", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
success: true,
data: {
downloadUrl: "https://faa/x.nef?access_token=fdt_x",
expiresAt: "2026-06-07T00:00:00Z",
},
}),
);
const grant = await getModelDownload("m1");
expect(grant.downloadUrl).toBe("https://faa/x.nef?access_token=fdt_x");
expect(grant.expiresAt).toBe("2026-06-07T00:00:00Z");
});
it("空 modelId → validation_failed不打 API", async () => {
await expect(getModelDownload("")).rejects.toMatchObject({
name: "ModelDownloadError",
code: "validation_failed",
});
expect(fetchMock).not.toHaveBeenCalled();
});
it.each([
[404, "model_not_found"],
[403, "forbidden"],
[501, "upload_not_supported"],
[502, "sign_failed"],
])("backend %i → ModelDownloadError code=%s小寫", async (status, code) => {
fetchMock.mockResolvedValue(
jsonResponse({ success: false, error: { code, message: "x" } }, status),
);
await expect(getModelDownload("m1")).rejects.toMatchObject({
name: "ModelDownloadError",
status,
code,
});
});
it("200 但缺 download_url → parse_error", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
success: true,
data: { expires_at: "2026-06-07T12:02:00Z" },
}),
);
await expect(getModelDownload("m1")).rejects.toMatchObject({
code: "parse_error",
});
});
});
/* ========================================================================== */
/* 2. triggerNavDownload */
/* ========================================================================== */
describe("triggerNavDownload", () => {
it("建立 anchor、href = 傳入的 downloadUrl、有 download 屬性、click 後從 DOM 移除", () => {
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
let capturedHref = "";
let hasDownloadAttr = false;
const appendSpy = vi
.spyOn(document.body, "appendChild")
.mockImplementation((node) => {
const a = node as HTMLAnchorElement;
capturedHref = a.href;
hasDownloadAttr = a.hasAttribute("download");
return node;
});
const removeSpy = vi
.spyOn(document.body, "removeChild")
.mockImplementation((node) => node);
const url = "https://faa.example.com:5081/files/models/u1/j1.nef?access_token=fdt_abc";
triggerNavDownload(url);
expect(capturedHref).toBe(url);
expect(hasDownloadAttr).toBe(true);
expect(clickSpy).toHaveBeenCalledOnce();
expect(appendSpy).toHaveBeenCalledOnce();
expect(removeSpy).toHaveBeenCalledOnce();
});
it("不建立 blob object URL不呼叫 createObjectURL", () => {
const createObjectURL = vi.fn();
globalThis.URL.createObjectURL =
createObjectURL as unknown as typeof URL.createObjectURL;
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
triggerNavDownload("https://faa/x.nef?access_token=fdt_x");
expect(createObjectURL).not.toHaveBeenCalled();
});
it("空 downloadUrl → validation_failed不建立 anchor", () => {
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
expect(() => triggerNavDownload("")).toThrowError(
expect.objectContaining({ code: "validation_failed" }),
);
expect(clickSpy).not.toHaveBeenCalled();
});
});
/* ========================================================================== */
/* 3. ModelDownloadError 形狀 */
/* ========================================================================== */
describe("ModelDownloadError", () => {
it("保留 status / code / message / requestId", () => {
const e = new ModelDownloadError(403, "forbidden", "no", "req-1");
expect(e.name).toBe("ModelDownloadError");
expect(e.status).toBe(403);
expect(e.code).toBe("forbidden");
expect(e.message).toBe("no");
expect(e.requestId).toBe("req-1");
expect(e).toBeInstanceOf(Error);
});
});