external-api: N10 API ดึงข้อมูลให้ระบบภายนอกด้วย API Key + ถอด F16 export CSV / N7 bookmark JSON แทนด้วย N10 — (1) endpoint เดียว POST /api/external/documents (JSON body {fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, filter:{type bookmarks|annotations, userId string|array, annotationTypes ค่า enum}}) ให้ backend e-Court เรียก server-to-server ผ่าน header x-api-key คืน {documents:[…]} รูปเดียวเสมอ — bundle ยุบชั้น field เอกสาร + annotations (พร้อม url deep link + replies) + bookmarks ระดับเดียวกัน, url เป็น URL เต็ม origin จาก request ผู้เรียก (x-forwarded-proto/host fallback host ตรง) + basePath (เดิม 2 GET query-param รวมเป็นเส้นเดียว 2026-09-24 · OR→AND + url relative→เต็ม + filter.types→annotationTypes กันสับสนกับ filter.type 2026-09-25) (2) lib/external-api-auth.ts — requireExternalApiKey เทียบ SHA-256 + crypto.timingSafeEqual, env EXTERNAL_API_KEY ไม่ตั้ง/ว่าง = 401 fail closed, ข้อความ 401 เดียวกันกัน enumerate + externalApiError คืน JSON {"error": ไทย} ใช้ร่วมทุก route — module DB-free ให้ tests/lib/external-api-auth.test.ts (5 tests) รันได้ไม่ต้องมี DATABASE_URL (3) lib/external-api.ts parseQueryBody strict กัน typo เงียบ ๆ (field นอก 3 ตัว = 400, annotationTypes ค่านอก enum = 400 บอกค่าที่รับ) + loadDocumentBundles bulk inArray = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ กรอง userIds/annotationTypes ใน SQL เรียง F15/N5 (4) route: auth ก่อนแตะ DB + ครอบ try/catch ทุก DB call ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework, read-only ไม่ publish SSE ไม่ revalidatePath ไม่มี CORS, REDACTION รวมในผลลัพธ์ (5) ถอด F16/N7 ตามคำขอผู้ใช้ 2026-09-25 (N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth): ลบ app/api/annotations/export-csv + app/api/bookmarks/export + utils/csv.ts + tests/utils/csv.test.ts + ปุ่มส่งออก 2 ปุ่มบนหน้าสรุป + ปุ่ม JSON ใน sidebar + รูป requirement 1.png/2.png (บรรทัดอ้างถึงใน gap-analysis.md เปลี่ยนเป็น historical note) (6) docs sync ครบ: PLAN-API.md แผนฉบับเต็ม 9 รอบ + PLAN-FEAT.md §7 N10 + PLAN-BOOKMARK-PDF.md/gap-analysis.md 5.3.14 เปลี่ยนตอบด้วย N10 + CLAUDE.md + README (env/ตัวอย่าง curl/troubleshooting) + .env.example + .claude/rules/data-layer.md — ผ่าน code review 2 รอบ 2026-09-25: แก้ 500 JSON ตอน DB ล้ม + ถอด fan-out ต่อไฟล์เป็น bulk + ถอด as cast แทน type guard (tsc + vitest 149/149) (คำขอผู้ใช้ 2026-09-25)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
df502088b7
commit
f4d46c7720
20 changed files with 656 additions and 222 deletions
74
tests/lib/external-api-auth.test.ts
Normal file
74
tests/lib/external-api-auth.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { requireExternalApiKey } from "@/lib/external-api-auth";
|
||||
|
||||
// N10 (PLAN-API.md D1) — auth ของ /api/external/*: fail closed เมื่อ env ว่าง +
|
||||
// ข้อความ 401 อันเดียวกันทั้งไม่ใส่ key และ key ผิด (กัน enumerate) — ไฟล์นี้
|
||||
// ตั้งใจไม่แตะ module ใดที่ลาก @/db มาด้วย (db/index.ts throw ตอน import เมื่อ
|
||||
// ไม่มี DATABASE_URL — vitest ไม่โหลด .env)
|
||||
|
||||
const HAD_KEY = Object.prototype.hasOwnProperty.call(process.env, "EXTERNAL_API_KEY");
|
||||
const ORIG_KEY = process.env.EXTERNAL_API_KEY;
|
||||
|
||||
function restoreKey() {
|
||||
if (HAD_KEY) process.env.EXTERNAL_API_KEY = ORIG_KEY;
|
||||
else delete process.env.EXTERNAL_API_KEY;
|
||||
}
|
||||
|
||||
function reqWithKey(key?: string): Request {
|
||||
const headers = new Headers();
|
||||
if (key !== undefined) headers.set("x-api-key", key);
|
||||
return new Request("http://localhost/docedit/note/api/external/documents", { headers });
|
||||
}
|
||||
|
||||
async function bodyOf(res: Response): Promise<{ error: string }> {
|
||||
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
||||
return (await res.json()) as { error: string };
|
||||
}
|
||||
|
||||
describe("requireExternalApiKey", () => {
|
||||
afterEach(restoreKey);
|
||||
|
||||
it("env ไม่ตั้ง / ว่าง / whitespace ล้วน = 401 fail closed (แม้จะส่ง key มาถูก)", async () => {
|
||||
for (const empty of [undefined, "", " "]) {
|
||||
if (empty === undefined) delete process.env.EXTERNAL_API_KEY;
|
||||
else process.env.EXTERNAL_API_KEY = empty;
|
||||
const res = requireExternalApiKey(reqWithKey("correct-key"));
|
||||
expect(res).not.toBeNull();
|
||||
expect(res!.status).toBe(401);
|
||||
expect(await bodyOf(res!)).toEqual({ error: "API ยังไม่ได้ตั้งค่าคีย์" });
|
||||
}
|
||||
});
|
||||
|
||||
it("ไม่ใส่ header กับ key ผิด = 401 ข้อความ/รูปร่างเดียวกัน (ไม่บอกว่าขาดอะไร)", async () => {
|
||||
process.env.EXTERNAL_API_KEY = "correct-key";
|
||||
const missing = requireExternalApiKey(reqWithKey());
|
||||
const wrong = requireExternalApiKey(reqWithKey("wrong-key"));
|
||||
expect(missing!.status).toBe(401);
|
||||
expect(wrong!.status).toBe(401);
|
||||
const missingBody = await bodyOf(missing!);
|
||||
const wrongBody = await bodyOf(wrong!);
|
||||
expect(missingBody).toEqual(wrongBody);
|
||||
expect(Object.keys(missingBody)).toEqual(["error"]);
|
||||
});
|
||||
|
||||
it("key ถูก = ผ่าน (คืน null)", () => {
|
||||
process.env.EXTERNAL_API_KEY = "correct-key";
|
||||
expect(requireExternalApiKey(reqWithKey("correct-key"))).toBeNull();
|
||||
});
|
||||
|
||||
it("env มี whitespace ครอบ (trailing newline ของ K8s secret/env_file) = เทียบด้วยค่า trim แล้ว", () => {
|
||||
process.env.EXTERNAL_API_KEY = " correct-key\n";
|
||||
expect(requireExternalApiKey(reqWithKey("correct-key"))).toBeNull();
|
||||
expect(requireExternalApiKey(reqWithKey("wrong-key"))!.status).toBe(401);
|
||||
});
|
||||
|
||||
it("key ยาว (300+ ตัวอักษร) เทียบตรง ๆ ได้ — ต่างแม้ตัวเดียว = 401", () => {
|
||||
// header เป็น ByteString จึงทดสอบด้วย latin1 เท่านั้น (ไทย/unicode ส่งผ่าน
|
||||
// header ไม่ได้ตั้งแต่ใน Headers.set แล้ว)
|
||||
const key = "k".repeat(300) + "-ok";
|
||||
process.env.EXTERNAL_API_KEY = key;
|
||||
expect(requireExternalApiKey(reqWithKey(key))).toBeNull();
|
||||
expect(requireExternalApiKey(reqWithKey(key.slice(0, -1)))!.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { csvField } from "@/utils/csv";
|
||||
|
||||
describe("csvField", () => {
|
||||
it("ครอบ double quote ตาม RFC 4180", () => {
|
||||
expect(csvField("ทดสอบ")).toBe('"ทดสอบ"');
|
||||
});
|
||||
|
||||
it("escape double quote ในค่าเป็น \"\"", () => {
|
||||
expect(csvField('พูดว่า "สวัสดี"')).toBe('"พูดว่า ""สวัสดี"""');
|
||||
});
|
||||
|
||||
it("ตัวเลขหน้าไม่ถูกนำหน้าอะไร", () => {
|
||||
expect(csvField("1")).toBe('"1"');
|
||||
expect(csvField("12")).toBe('"12"');
|
||||
});
|
||||
|
||||
it("FIX-6: ขึ้นต้นด้วย = + - @ ต้องนำหน้าด้วย ' กันสูตร Excel", () => {
|
||||
expect(csvField("=1+1")).toBe('"\'=1+1"');
|
||||
expect(csvField("+SUM(A1:A2)")).toBe('"\'+SUM(A1:A2)"');
|
||||
expect(csvField("-2+3")).toBe('"\'-2+3"');
|
||||
expect(csvField("@SUM(A1)")).toBe('"\'@SUM(A1)"');
|
||||
});
|
||||
|
||||
it("FIX-6: ขึ้นต้นด้วย TAB/CR ต้องนำหน้าด้วย ' เช่นกัน", () => {
|
||||
expect(csvField("\t=cmd")).toBe('"\'\t=cmd"');
|
||||
expect(csvField("\r=cmd")).toBe('"\'\r=cmd"');
|
||||
});
|
||||
|
||||
it("อักขระพิเศษที่ไม่ใช่จุดเริ่มสูตรไม่ถูกนำหน้า", () => {
|
||||
expect(csvField("1+1")).toBe('"1+1"');
|
||||
expect(csvField("a=b")).toBe('"a=b"');
|
||||
expect(csvField("ราคา -50 บาท")).toBe('"ราคา -50 บาท"');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue