implement phase 4

This commit is contained in:
Suphonchai Phoonsawat 2026-08-19 21:35:07 +07:00
parent 25c2ce53cd
commit f4b1c3e14b
27 changed files with 6929 additions and 48 deletions

46
addin/eslint.config.mjs Normal file
View file

@ -0,0 +1,46 @@
import officeAddins from "eslint-plugin-office-addins";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import globals from "globals";
// สืบทอดจาก config เริ่มต้นของ office-addin-lint (node_modules/office-addin-lint/config/eslint.config.mjs)
// — office-addin-lint จะใช้ไฟล์นี้แทนถ้ามีอยู่ใน root ของโปรเจกต์
//
// ปิด `no-navigational-load` เพราะ rule ของ plugin เวอร์ชันนี้ (4.0.10):
// (a) crash กับ `context.load(obj)` แบบ 1 อาร์กิวเมนต์ (parsePropertiesArgument(undefined))
// (b) ไม่รู้จัก Word API properties (เช่น `tag`) จึงรายงาน false positive
export default [
...officeAddins.configs.recommended,
{
plugins: {
"office-addins": officeAddins,
},
languageOptions: {
parser: tsParser,
// task pane รันใน browser (Office) แต่ unit test/jest setup ใช้ node globals ด้วย
globals: { ...globals.browser, ...globals.node, ...globals.jest },
},
},
{
rules: {
"office-addins/no-navigational-load": "off",
},
},
{
// ไฟล์ TS: tsc ตรวจ undefined globals อยู่แล้ว (แม่นกว่า no-undef ที่ไม่เข้าใจ types)
// และ no-redeclare ชนกับ /* global */ comments ของ scaffold
files: ["**/*.ts", "**/*.tsx"],
plugins: {
"@typescript-eslint": tsPlugin,
},
rules: {
"no-undef": "off",
"no-redeclare": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
];

13
addin/jest.config.js Normal file
View file

@ -0,0 +1,13 @@
/* eslint-disable no-undef */
/**
* Unit tests ของ services layer — แปลง TS ด้วย babel-jest (ใช้ babel.config.json เดิม
* ที่มี @babel/preset-typescript อยู่แล้ว — type-check ทำที่ npm run build ผ่าน ts-loader)
* environment = jsdom เพราะ services ใช้ DOM API (DOMParser, Blob, btoa, URLSearchParams)
*/
module.exports = {
testEnvironment: "jsdom",
roots: ["<rootDir>/src"],
setupFiles: ["<rootDir>/src/test/setup.ts"],
testMatch: ["**/*.test.ts"],
};

View file

@ -15,6 +15,15 @@
<Hosts>
<Host Name="Document"/>
</Hosts>
<!-- Requirement sets ที่ services layer ใช้จริง — host ที่ไม่รองรับจะไม่โหลด add-in เลย (fail fast ดีกว่าพังกลางทาง)
WordApi 1.5: customProperties / customXmlParts / document.save()
DialogAPI 1.1: Office Dialog API สำหรับ login ผ่าน Keycloak (FR-8) -->
<Requirements>
<Sets DefaultMinVersion="1.1">
<Set Name="WordApi" MinVersion="1.5"/>
<Set Name="DialogAPI" MinVersion="1.1"/>
</Sets>
</Requirements>
<DefaultSettings>
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
</DefaultSettings>

4796
addin/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,7 @@
"signout": "office-addin-dev-settings m365-account logout",
"start": "office-addin-debugging start manifest.xml",
"stop": "office-addin-debugging stop manifest.xml",
"test": "jest",
"validate": "office-addin-manifest validate manifest.xml",
"watch": "webpack --mode development --watch"
},
@ -38,6 +39,8 @@
"@babel/core": "^8.0.1",
"@babel/preset-env": "^8.0.2",
"@babel/preset-typescript": "^8.0.1",
"@types/jest": "^29.5.14",
"@types/node": "^26.2.0",
"@types/office-js": "^1.0.377",
"@types/office-runtime": "^1.0.35",
"@types/react": "^18.2.64",
@ -51,6 +54,8 @@
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-plugin": "^5.6.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"less": "^4.2.0",
"less-loader": "^12.2.0",
"office-addin-cli": "^2.0.10",
@ -73,4 +78,4 @@
"last 2 versions",
"ie 11"
]
}
}

19
addin/src/config.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* Config ของ add-in — inject ผ่าน webpack DefinePlugin ตอน build (อ่านจาก process.env)
* ค่า default คือ local dev: Keycloak จาก docker-compose (port 8080), backend `dotnet run` (port 5075)
* ถ้าจะชี้ที่อื่น: `KEYCLOAK_AUTHORITY=... API_BASE_URL=... npm run build`
*/
export const config = {
keycloak: {
/** เช่น http://localhost:8080/realms/word-versioning */
authority: __KEYCLOAK_AUTHORITY__,
/** public client ของ add-in (PKCE S256 บังคับ) */
clientId: __KEYCLOAK_CLIENT_ID__,
},
/** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น http://localhost:5075 */
apiBaseUrl: __API_BASE_URL__,
/** redirect URI ที่ลงทะเบียนไว้กับ Keycloak — หน้าเล็กใน add-in เอง (รันใน Office Dialog) */
get redirectUri(): string {
return `${window.location.origin}/auth-callback.html`;
},
};

5
addin/src/globals.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
// Compile-time constants ที่ webpack DefinePlugin แทนที่ตอน build (ค่าจาก process.env — ดู webpack.config.js)
// ตามกติกา "config แยกเป็น env เท่านั้น ห้าม hardcode" — ค่า default สำหรับ local dev อยู่ที่ webpack.config.js
declare const __KEYCLOAK_AUTHORITY__: string;
declare const __KEYCLOAK_CLIENT_ID__: string;
declare const __API_BASE_URL__: string;

View file

@ -0,0 +1,53 @@
/**
* Type ตรงกับ response ของ backend (`Models/VersionDtos.cs` — ASP.NET Core serialize camelCase)
*/
/** response ของ POST /api/documents/{documentId}/versions และ POST /api/documents/generate */
export interface UploadVersionResult {
versionId: string;
versionNumber: number;
}
/** field เดียวใน snapshot ของ version (เอกสารที่ผูก template) */
export interface FieldSnapshotEntry {
value: string;
missing: boolean;
}
/**
* snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ:
* - เอกสารผูก template: `{ [fieldKey]: { value, missing } }`
* - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }`
* UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน)
*/
export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string, string>;
/** item ของ GET /api/documents/{documentId}/versions */
export interface VersionRecord {
versionId: string;
versionNumber: number;
/** ISO-8601 */
createdAt: string;
createdByUserId: string;
/** SHA256 hex */
contentHash: string;
fields: FieldsSnapshot | null;
}
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */
export function readSnapshotField(
snapshot: FieldsSnapshot | null,
key: string
): FieldSnapshotEntry | null {
if (!snapshot) {
return null;
}
const raw = snapshot[key];
if (raw === undefined) {
return null;
}
if (typeof raw === "string") {
return { value: raw, missing: false };
}
return { value: raw.value, missing: raw.missing };
}

View file

@ -0,0 +1,154 @@
import * as authService from "./authService";
import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient";
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
const API_URL = "http://localhost:5075";
beforeEach(() => {
authService.signOut();
});
afterEach(() => {
authService.signOut();
});
/** header ที่ request แต่ละ call ส่งจริง */
function authHeaderOf(call: [unknown, { headers?: unknown }]): string | null {
return new Headers(call[1]?.headers as HeadersInit).get("Authorization");
}
describe("apiFetch", () => {
it("แนบ Authorization: Bearer ทุก request และต่อ path กับ apiBaseUrl", async () => {
const token = makeFakeJwt(Date.now() + 600_000);
authService.setSessionForTest(token, "refresh-1", Date.now() + 600_000);
const fetchMock = installFetchMock(async () =>
mockResponse({ status: 200, body: { ok: true } })
);
const response = await apiFetch("/api/documents/doc-1/versions");
expect(response.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url] = fetchMock.mock.calls[0] as [string, unknown];
expect(url).toBe(`${API_URL}/api/documents/doc-1/versions`);
expect(authHeaderOf(fetchMock.mock.calls[0] as [string, { headers?: unknown }])).toBe(
`Bearer ${token}`
);
});
it("โดน 401 → refresh แล้ว retry ครั้งเดียวด้วย token ใหม่ จนสำเร็จ", async () => {
const oldToken = makeFakeJwt(Date.now() + 600_000);
const newToken = makeFakeJwt(Date.now() + 900_000);
authService.setSessionForTest(oldToken, "refresh-old", Date.now() + 600_000);
const fetchMock = installFetchMock();
fetchMock
.mockImplementationOnce(async () => mockResponse({ status: 401, text: "expired" }))
.mockImplementationOnce(async () =>
mockResponse({
status: 200,
body: { access_token: newToken, refresh_token: "refresh-new", expires_in: 900 },
})
)
.mockImplementationOnce(async () => mockResponse({ status: 200, body: [] }));
const response = await apiFetch("/api/documents/doc-1/versions");
expect(response.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
const headers = fetchMock.mock.calls.map((call) =>
authHeaderOf(call as [string, { headers?: unknown }])
);
expect(headers[0]).toBe(`Bearer ${oldToken}`); // request แรกโดน 401
expect(headers[1]).toBeNull(); // token endpoint ไม่ใช่ apiFetch — ไม่มี Bearer
expect(headers[2]).toBe(`Bearer ${newToken}`); // retry ด้วย token ใหม่
});
it("retry แล้วยัง 401 อีก → ยอมแพ้เป็น AuthRequiredError (ให้ UI กลับหน้า sign-in)", async () => {
authService.setSessionForTest(
makeFakeJwt(Date.now() + 600_000),
"refresh-old",
Date.now() + 600_000
);
const fetchMock = installFetchMock();
fetchMock
.mockImplementationOnce(async () => mockResponse({ status: 401, text: "expired" }))
.mockImplementationOnce(async () =>
mockResponse({
status: 200,
body: { access_token: makeFakeJwt(Date.now() + 900_000), expires_in: 900 },
})
)
.mockImplementationOnce(async () => mockResponse({ status: 401, text: "still rejected" }));
await expect(apiFetch("/api/documents/doc-1/versions")).rejects.toThrow(AuthRequiredError);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("401 แล้ว refresh ไม่ได้ (refresh token ตาย) → AuthRequiredError ทันที ไม่ retry", async () => {
authService.setSessionForTest(
makeFakeJwt(Date.now() + 600_000),
"refresh-dead",
Date.now() + 600_000
);
const fetchMock = installFetchMock();
fetchMock
.mockImplementationOnce(async () => mockResponse({ status: 401, text: "expired" }))
.mockImplementationOnce(async () => mockResponse({ status: 400, text: "invalid_grant" }));
await expect(apiFetch("/api/documents/doc-1/versions")).rejects.toThrow(AuthRequiredError);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("ไม่มี token เลย → AuthRequiredError โดยไม่ยิง fetch", async () => {
const fetchMock = installFetchMock();
await expect(apiFetch("/api/documents/doc-1/versions")).rejects.toThrow(AuthRequiredError);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("apiGetJson", () => {
it("parse JSON ตอน 200", async () => {
authService.setSessionForTest(makeFakeJwt(Date.now() + 600_000), null, Date.now() + 600_000);
installFetchMock(async () =>
mockResponse({ status: 200, body: [{ versionId: "v1", versionNumber: 1 }] })
);
await expect(apiGetJson("/api/documents/doc-1/versions", "fallback")).resolves.toEqual([
{ versionId: "v1", versionNumber: 1 },
]);
});
it("status ไม่สำเร็จ → ApiError พร้อม message จาก body ของ backend", async () => {
authService.setSessionForTest(makeFakeJwt(Date.now() + 600_000), null, Date.now() + 600_000);
installFetchMock(async () =>
mockResponse({ status: 400, text: "รองรับเฉพาะไฟล์ .docx (Word document)" })
);
await expect(apiGetJson("/api/documents/doc-1/versions", "fallback")).rejects.toMatchObject({
name: "ApiError",
status: 400,
message: "รองรับเฉพาะไฟล์ .docx (Word document)",
});
});
});
describe("readErrorMessage", () => {
it("อ่านได้ทั้ง plain text และ JSON problem details และว่างเปล่า", async () => {
// mock ไม่ใช่ Response จริง (jsdom ไม่มี) — duck-typing ครบตามที่ service ใช้
expect(
await readErrorMessage(
mockResponse({ status: 400, text: "bad request ตรงๆ" }) as unknown as Response,
"fallback"
)
).toBe("bad request ตรงๆ");
expect(
await readErrorMessage(
mockResponse({ status: 400, body: { detail: "จาก JSON" } }) as unknown as Response,
"fallback"
)
).toBe("จาก JSON");
expect(
await readErrorMessage(mockResponse({ status: 500 }) as unknown as Response, "fallback")
).toBe("fallback");
});
});

View file

@ -0,0 +1,119 @@
import { config } from "../config";
import * as authService from "./authService";
/**
* fetch wrapper ของ add-in — แนบ Bearer ทุก request
* โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError
* (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9)
*/
/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */
export class AuthRequiredError extends Error {
constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") {
super(message);
this.name = "AuthRequiredError";
}
}
/** backend ตอบมาแต่ status ไม่สำเร็จ — message อ่านได้จาก body ถ้ามี */
export class ApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export interface ApiRequestOptions {
method?: string;
headers?: Record<string, string>;
body?: BodyInit | null;
}
function toUrl(path: string): string {
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`;
}
async function doFetch(
path: string,
options: ApiRequestOptions,
accessToken: string
): Promise<Response> {
const headers = new Headers(options.headers ?? {});
headers.set("Authorization", `Bearer ${accessToken}`);
return fetch(toUrl(path), {
method: options.method ?? "GET",
headers,
body: options.body,
});
}
/**
* request พร้อม Bearer + retry-on-401 ครั้งเดียว
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError)
*/
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
const accessToken = await authService.getAccessToken();
if (!accessToken) {
throw new AuthRequiredError();
}
let response = await doFetch(path, options, accessToken);
if (response.status === 401) {
const refreshedToken = await authService.refreshAccessToken();
if (!refreshedToken) {
throw new AuthRequiredError();
}
response = await doFetch(path, options, refreshedToken);
if (response.status === 401) {
throw new AuthRequiredError();
}
}
return response;
}
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */
export async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const text = (await response.text()).trim();
if (!text) {
return fallback;
}
try {
const parsed = JSON.parse(text) as { detail?: unknown; message?: unknown; title?: unknown };
const detail = parsed.detail ?? parsed.message ?? parsed.title;
if (typeof detail === "string" && detail) {
return detail;
}
} catch {
// plain text — ใช้ตรงๆ
}
return text;
} catch {
return fallback;
}
}
/** GET แล้ว parse JSON — fail โยน ApiError พร้อม message จาก backend */
export async function apiGetJson<T>(path: string, fallbackError: string): Promise<T> {
const response = await apiFetch(path);
if (!response.ok) {
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
}
return (await response.json()) as T;
}
/** POST (form/json) แล้ว parse JSON — ใช้กับ upload version ที่ตอบ 201 */
export async function apiSendExpectJson<T>(
path: string,
options: ApiRequestOptions,
fallbackError: string
): Promise<T> {
const response = await apiFetch(path, options);
if (!response.ok) {
throw new ApiError(response.status, await readErrorMessage(response, fallbackError));
}
return (await response.json()) as T;
}

View file

@ -0,0 +1,283 @@
import { createHash } from "crypto";
import * as authService from "./authService";
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token";
function base64Url(buffer: Buffer): string {
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
interface OfficeDialogMock {
displayDialogAsync: jest.Mock;
dialog: { close: jest.Mock };
capturedUrl: () => string;
sendMessage: (payload: unknown) => void;
sendDialogEvent: () => void;
}
/** mock Office.context.ui.displayDialogAsync + dialog event handlers (FR-8 flow ผ่าน Dialog API) */
function installOfficeDialogMock(): OfficeDialogMock {
const globalAny = globalThis as unknown as Record<string, unknown>;
const messageHandlers: Array<(args: unknown) => void> = [];
const eventHandlers: Array<(args: unknown) => void> = [];
let capturedAuthUrl = "";
const dialog = {
close: jest.fn(),
addEventHandler: (eventType: string, handler: (args: unknown) => void) => {
(eventType === "dialogMessageReceived" ? messageHandlers : eventHandlers).push(handler);
},
};
const displayDialogAsync = jest.fn(
(startAddress: string, _options: unknown, callback: (result: unknown) => void) => {
capturedAuthUrl = startAddress;
callback({ status: "succeeded", value: dialog });
}
);
globalAny.Office = {
EventType: {
DialogMessageReceived: "dialogMessageReceived",
DialogEventReceived: "dialogEventReceived",
},
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: { ui: { displayDialogAsync } },
} as unknown as typeof Office;
return {
displayDialogAsync,
dialog: dialog as OfficeDialogMock["dialog"],
capturedUrl: () => capturedAuthUrl,
sendMessage: (payload: unknown) => {
messageHandlers.forEach((handler) => handler({ message: JSON.stringify(payload) }));
},
sendDialogEvent: () => {
eventHandlers.forEach((handler) => handler({ error: 12006 }));
},
};
}
/** รอจนกว่า login จะเรียก displayDialogAsync จริง (ต้องผ่าน crypto.subtle.digest ก่อน — ในเครื่องโหลดอาจช้ากว่า setTimeout(0)) */
async function waitForDialog(office: OfficeDialogMock): Promise<void> {
for (
let attempt = 0;
attempt < 200 && office.displayDialogAsync.mock.calls.length === 0;
attempt++
) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
expect(office.displayDialogAsync).toHaveBeenCalled();
}
beforeEach(() => {
authService.signOut();
});
afterEach(() => {
authService.signOut(); // เคลียร์ auto-refresh timer กัน handle ค้าง
});
describe("PKCE", () => {
it("code_verifier ยาว 43–128 chars และอยู่ในชุดตัวอักษร base64url ที่ RFC 7636 อนุญาต", () => {
for (let i = 0; i < 20; i++) {
const verifier = authService.createCodeVerifier();
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(verifier.length).toBeLessThanOrEqual(128);
expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/);
expect(verifier).not.toContain("=");
}
});
it("code_challenge = BASE64URL(SHA256(verifier)) — ตรวจกับ test vector ของ RFC 7636 appendix B", async () => {
// vector จาก RFC 7636 เอง
const challenge = await authService.createS256Challenge(
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
);
expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
});
it("code_challenge ของ verifier สุ่มตรงกับที่คำนวณเองด้วย node crypto", async () => {
const verifier = authService.createCodeVerifier();
const expected = base64Url(createHash("sha256").update(verifier).digest());
await expect(authService.createS256Challenge(verifier)).resolves.toBe(expected);
});
});
describe("buildAuthorizationUrl", () => {
it("ประกอบ authorization URL ครบทุกพารามิเตอร์ (client_id/redirect/response_type/scope/state/PKCE)", () => {
const url = new URL(
authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" })
);
expect(url.origin).toBe("http://localhost:8080");
expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth");
expect(url.searchParams.get("client_id")).toBe("word-addin-officejs");
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html");
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("scope")).toBe("openid");
expect(url.searchParams.get("state")).toBe("the-state");
expect(url.searchParams.get("code_challenge")).toBe("the-challenge");
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
});
});
describe("decodeJwtExpiry", () => {
it("อ่าน exp จาก JWT payload เป็น ms epoch", () => {
const expiresAt = Date.now() + 300_000;
expect(authService.decodeJwtExpiry(makeFakeJwt(expiresAt))).toBe(
Math.floor(expiresAt / 1000) * 1000
);
});
it("token ไม่ใช่รูป JWT → null", () => {
expect(authService.decodeJwtExpiry("not-a-jwt")).toBeNull();
});
});
describe("login (Office Dialog + token exchange)", () => {
it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => {
const office = installOfficeDialogMock();
let exchangedBody: URLSearchParams | null = null;
const fetchMock = installFetchMock();
fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => {
expect(String(input)).toBe(TOKEN_ENDPOINT);
exchangedBody = new URLSearchParams(String(init?.body));
return mockResponse({
status: 200,
body: {
access_token: makeFakeJwt(Date.now() + 300_000),
refresh_token: "refresh-1",
expires_in: 300,
},
});
});
const loginPromise = authService.login();
await waitForDialog(office);
// authorization URL ที่ส่งเข้า dialog
const authUrl = new URL(office.capturedUrl());
const challenge = authUrl.searchParams.get("code_challenge") ?? "";
const state = authUrl.searchParams.get("state") ?? "";
expect(challenge).not.toBe("");
expect(state).not.toBe("");
office.sendMessage({ code: "the-auth-code", state });
await expect(loginPromise).resolves.toEqual({ ok: true });
expect(authService.isTokenValid()).toBe(true);
expect(office.dialog.close).toHaveBeenCalled();
// verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง)
const verifier = exchangedBody?.get("code_verifier") ?? "";
expect(exchangedBody?.get("grant_type")).toBe("authorization_code");
expect(exchangedBody?.get("code")).toBe("the-auth-code");
expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs");
expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge);
});
it("state ไม่ตรง (CSRF) → login fail และไม่ยิง token endpoint", async () => {
const office = installOfficeDialogMock();
const fetchMock = installFetchMock();
const loginPromise = authService.login();
await waitForDialog(office);
office.sendMessage({ code: "code", state: "tampered-state" });
const result = await loginPromise;
expect(result).toEqual({ ok: false, error: expect.stringContaining("state") });
expect(fetchMock).not.toHaveBeenCalled();
expect(authService.isTokenValid()).toBe(false);
});
it("ผู้ใช้ปิด dialog เอง (DialogEventReceived) → คืน error แบบ graceful", async () => {
const office = installOfficeDialogMock();
installFetchMock();
const loginPromise = authService.login();
await waitForDialog(office);
office.sendDialogEvent();
const result = await loginPromise;
expect(result.ok).toBe(false);
expect(authService.isTokenValid()).toBe(false);
});
it("Keycloak ปฏิเสธ (error ใน callback) → ok:false พร้อมชื่อ error", async () => {
const office = installOfficeDialogMock();
installFetchMock();
const loginPromise = authService.login();
await waitForDialog(office);
office.sendMessage({ error: "access_denied", errorDescription: "user cancelled" });
const result = await loginPromise;
expect(result).toEqual({ ok: false, error: expect.stringContaining("access_denied") });
});
it("เรียก login ซ้อนพร้อมกัน → เปิด dialog ครั้งเดียว ได้ผลเดียวกันทั้งสอง promise", async () => {
const office = installOfficeDialogMock();
installFetchMock(async () =>
mockResponse({
status: 200,
body: { access_token: makeFakeJwt(Date.now() + 300_000), expires_in: 300 },
})
);
const first = authService.login();
const second = authService.login();
await waitForDialog(office);
const state = new URL(office.capturedUrl()).searchParams.get("state") ?? "";
office.sendMessage({ code: "code", state });
await expect(first).resolves.toEqual({ ok: true });
await expect(second).resolves.toEqual({ ok: true });
expect(office.displayDialogAsync).toHaveBeenCalledTimes(1);
});
});
describe("silent refresh (AC-9)", () => {
it("access token หมดอายุ + refresh token ใช้ได้ → getAccessToken refresh เงียบๆ แล้วคืน token ใหม่", async () => {
authService.setSessionForTest("expired-access-token", "refresh-old", Date.now() - 60_000);
const newToken = makeFakeJwt(Date.now() + 600_000);
const fetchMock = installFetchMock(async () =>
mockResponse({
status: 200,
body: { access_token: newToken, refresh_token: "refresh-new", expires_in: 600 },
})
);
const token = await authService.getAccessToken();
expect(token).toBe(newToken);
expect(authService.isTokenValid()).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = new URLSearchParams(String((fetchMock.mock.calls[0][1] as { body: string }).body));
expect(body.get("grant_type")).toBe("refresh_token");
expect(body.get("refresh_token")).toBe("refresh-old");
expect(body.get("client_id")).toBe("word-addin-officejs");
// Keycloak หมุน refresh token — เก็บอันใหม่ไว้ใช้รอบหน้า
expect(authService.hasRefreshToken()).toBe(true);
});
it("refresh token ตาย/ถูก revoke → getAccessToken คืน null ไม่ throw และล้าง session", async () => {
authService.setSessionForTest("expired-access-token", "refresh-dead", Date.now() - 60_000);
const fetchMock = installFetchMock(async () =>
mockResponse({ status: 400, text: "invalid_grant" })
);
await expect(authService.getAccessToken()).resolves.toBeNull();
expect(authService.isTokenValid()).toBe(false);
expect(authService.hasRefreshToken()).toBe(false);
// เรียกซ้ำหลัง session ถูกล้าง → null ทันทีโดยไม่ยิง fetch เพิ่ม
await expect(authService.getAccessToken()).resolves.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("ไม่เคย login เลย → isTokenValid false, getAccessToken null", async () => {
expect(authService.isTokenValid()).toBe(false);
await expect(authService.getAccessToken()).resolves.toBeNull();
});
});

View file

@ -0,0 +1,390 @@
/* global Office */
import { config } from "../config";
/**
* FR-8 ฝั่ง client — OIDC Authorization Code + PKCE (S256) ผ่าน Office Dialog API
*
* กติกาสำคัญ:
* - task pane รันใน iframe → ห้าม redirect ไป Keycloak ตรงๆ ต้องเปิด dialog (displayDialogAsync)
* แล้วรับผลกลับทาง messageParent
* - token เก็บใน JS variable ของ task pane session เท่านั้น — ห้าม localStorage
* - refresh token ตาย/ถูก revoke → คืน null / { ok: false } แบบ graceful ไม่ปล่อย error ค้าง
*/
/** ถือว่า token หมดอายุก่อนจริง 30 วินาที กันแข่งกับนาฬิกา backend */
const EXPIRY_SKEW_MS = 30_000;
/** auto-refresh เช็คทุก 1 นาที และรีเฟรชล่วงหน้าเมื่อเหลืออายุ < 2 นาที */
const AUTO_REFRESH_INTERVAL_MS = 60_000;
const AUTO_REFRESH_BEFORE_EXPIRY_MS = 120_000;
export type LoginResult = { ok: true } | { ok: false; error: string };
interface KeycloakTokenResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
token_type?: string;
}
interface TokenState {
accessToken: string | null;
refreshToken: string | null;
/** ms epoch ที่ access token หมดอายุจริง (ไม่หัก skew) */
expiresAt: number;
}
let tokenState: TokenState = { accessToken: null, refreshToken: null, expiresAt: 0 };
let pendingLogin: Promise<LoginResult> | null = null;
let refreshInFlight: Promise<string | null> | null = null;
let autoRefreshTimer: number | null = null;
// ---------------------------------------------------------------------------
// PKCE helpers (แยกเป็นฟังก์ชัน export เพื่อ unit test ได้)
// ---------------------------------------------------------------------------
/** base64url ไม่มี padding — ใช้ทั้ง verifier และ challenge */
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** code_verifier ตาม RFC 7636: random 43–128 chars — 48 bytes → 64 chars base64url */
export function createCodeVerifier(): string {
const bytes = new Uint8Array(48);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
/** code_challenge = BASE64URL(SHA256(verifier)) — method S256 */
export async function createS256Challenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return base64UrlEncode(new Uint8Array(digest));
}
/** state กัน CSRF — สุ่มและเทียบตอนรับ code กลับ */
function createState(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
/** authorization URL เต็มสำหรับเปิดใน Office Dialog */
export function buildAuthorizationUrl(params: { codeChallenge: string; state: string }): string {
const url = new URL(`${config.keycloak.authority}/protocol/openid-connect/auth`);
url.searchParams.set("client_id", config.keycloak.clientId);
url.searchParams.set("redirect_uri", config.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid");
url.searchParams.set("state", params.state);
url.searchParams.set("code_challenge", params.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
// ---------------------------------------------------------------------------
// Dialog (ของจริงเรียก Office.context.ui — mock ได้ใน test)
// ---------------------------------------------------------------------------
type DialogResult =
| { kind: "code"; code: string; state: string }
| { kind: "oauth-error"; error: string; description?: string }
| { kind: "closed" }
| { kind: "failed"; error: string };
interface CallbackMessage {
code?: string;
state?: string;
error?: string;
errorDescription?: string;
}
function openAuthDialog(url: string): Promise<DialogResult> {
return new Promise((resolve) => {
// ตาม spec: dialog สูง 60 / กว้าง 30 เปอร์เซ็นต์ของหน้าจอ
Office.context.ui.displayDialogAsync(url, { height: 60, width: 30 }, (asyncResult) => {
if (asyncResult.status !== Office.AsyncResultStatus.Succeeded) {
resolve({
kind: "failed",
error: asyncResult.error?.message || "เปิดหน้า login ไม่สำเร็จ",
});
return;
}
const dialog = asyncResult.value;
let settled = false;
const finish = (result: DialogResult) => {
if (settled) {
return;
}
settled = true;
try {
dialog.close();
} catch {
// ปิดไปแล้ว — ไม่เป็นไร
}
resolve(result);
};
// message กลับจาก auth-callback.html (JSON string)
dialog.addEventHandler(Office.EventType.DialogMessageReceived, (args) => {
const message = (args as { message?: string }).message ?? "";
try {
const payload = JSON.parse(message) as CallbackMessage;
if (payload.error) {
finish({
kind: "oauth-error",
error: payload.error,
description: payload.errorDescription,
});
} else if (typeof payload.code === "string" && typeof payload.state === "string") {
finish({ kind: "code", code: payload.code, state: payload.state });
} else {
finish({ kind: "closed" });
}
} catch {
finish({ kind: "closed" });
}
});
// 12006 = ผู้ใช้ปิด dialog เอง, รวมถึง load ไม่สำเร็จทั้งหลาย → จบ flow แบบ "closed"
dialog.addEventHandler(Office.EventType.DialogEventReceived, () => {
finish({ kind: "closed" });
});
});
});
}
// ---------------------------------------------------------------------------
// Token endpoint
// ---------------------------------------------------------------------------
async function requestTokens(body: URLSearchParams): Promise<KeycloakTokenResponse> {
const response = await fetch(`${config.keycloak.authority}/protocol/openid-connect/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
if (!response.ok) {
throw new Error(`token endpoint ตอบ ${response.status}`);
}
return (await response.json()) as KeycloakTokenResponse;
}
async function exchangeCodeForTokens(
code: string,
verifier: string
): Promise<KeycloakTokenResponse> {
const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: config.keycloak.clientId,
code,
redirect_uri: config.redirectUri,
code_verifier: verifier,
});
return requestTokens(body);
}
// ---------------------------------------------------------------------------
// JWT helper (อ่าน exp เท่านั้น — ไม่ใช่การ verify)
// ---------------------------------------------------------------------------
function base64UrlDecode(segment: string): string {
const base64 = segment
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(Math.ceil(segment.length / 4) * 4, "=");
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
/** คืน ms epoch ที่ token หมดอายุ จาก claim `exp` — parse ไม่ได้คืน null */
export function decodeJwtExpiry(token: string): number | null {
const parts = token.split(".");
if (parts.length !== 3) {
return null;
}
try {
const payload = JSON.parse(base64UrlDecode(parts[1])) as { exp?: unknown };
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
} catch {
return null;
}
}
// ---------------------------------------------------------------------------
// State transitions
// ---------------------------------------------------------------------------
function applyTokenResponse(tokens: KeycloakTokenResponse): void {
tokenState = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? null,
// เชื่อ exp จากตัว token เอง (fallback: expires_in เผื่อ Keycloak ไม่ใส่ exp)
expiresAt: decodeJwtExpiry(tokens.access_token) ?? Date.now() + tokens.expires_in * 1000,
};
}
function clearTokens(): void {
tokenState = { accessToken: null, refreshToken: null, expiresAt: 0 };
}
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** มี access token ที่ยังไม่หมดอายุใน memory หรือไม่ (FR-1 ใช้ตัดสินหน้า sign-in) */
export function isTokenValid(): boolean {
return tokenState.accessToken !== null && Date.now() < tokenState.expiresAt - EXPIRY_SKEW_MS;
}
/** มี refresh token ให้ยืด session ต่อได้หรือเปล่า */
export function hasRefreshToken(): boolean {
return tokenState.refreshToken !== null;
}
/**
* token สำหรับ request ถัดไป — หมดอายุแล้วมี refresh token ก็ silent refresh ให้ก่อน
* คืน null เมื่อต้อง login ใหม่ (ไม่ throw — UI เปลี่ยนเป็นหน้า sign-in)
*/
export async function getAccessToken(): Promise<string | null> {
if (isTokenValid()) {
return tokenState.accessToken;
}
if (hasRefreshToken()) {
return refreshAccessToken();
}
return null;
}
/**
* refresh ด้วย refresh_token — เรียกซ้ำระหว่าง refresh ครั้งเดียวกันจะแชร์ promise เดียว
* fail (token ตาย/ถูก revoke/network) → เคลียร์ session แล้วคืน null แบบ graceful
*/
export function refreshAccessToken(): Promise<string | null> {
if (refreshInFlight) {
return refreshInFlight;
}
const refresh = async (): Promise<string | null> => {
const refreshToken = tokenState.refreshToken;
if (!refreshToken) {
return null;
}
try {
const tokens = await requestTokens(
new URLSearchParams({
grant_type: "refresh_token",
client_id: config.keycloak.clientId,
refresh_token: refreshToken,
})
);
applyTokenResponse(tokens);
return tokenState.accessToken;
} catch {
// refresh token ใช้ไม่ได้อีก → ต้อง login ใหม่ แต่ห้ามโยน error ค้าง (AC-9)
clearTokens();
stopAutoRefresh();
return null;
}
};
refreshInFlight = refresh().finally(() => {
refreshInFlight = null;
});
return refreshInFlight;
}
/** เดิน login flow เต็ม (dialog + แลก token) — กันเรียกซ้อนพร้อมกันด้วย promise เดียว */
export async function login(): Promise<LoginResult> {
if (pendingLogin) {
return pendingLogin;
}
const flow = (async (): Promise<LoginResult> => {
const verifier = createCodeVerifier();
const codeChallenge = await createS256Challenge(verifier);
const state = createState();
const authUrl = buildAuthorizationUrl({ codeChallenge, state });
const result = await openAuthDialog(authUrl);
switch (result.kind) {
case "code":
if (result.state !== state) {
// ป้องกัน CSRF — state ไม่ตรงถือว่าเป็น response ปลอม
return { ok: false, error: "การ login ถูกตีกลับ (state ไม่ถูกต้อง) — ลองอีกครั้ง" };
}
try {
applyTokenResponse(await exchangeCodeForTokens(result.code, verifier));
startAutoRefresh();
return { ok: true };
} catch (error) {
return { ok: false, error: getErrorMessage(error, "แลก token จาก Keycloak ไม่สำเร็จ") };
}
case "oauth-error":
return {
ok: false,
error: `Keycloak ปฏิเสธการ login: ${result.error}${result.description ? ` (${result.description})` : ""}`,
};
case "closed":
return { ok: false, error: "ยกเลิกการ login" };
case "failed":
return { ok: false, error: result.error };
}
})();
pendingLogin = flow;
try {
return await flow;
} finally {
pendingLogin = null;
}
}
/** ล้าง session (ใช้ตอน UI สั่ง sign out หรือ 401 ซ้ำแล้วเลือกกลับหน้า sign-in) */
export function signOut(): void {
clearTokens();
stopAutoRefresh();
}
/**
* silent refresh ล่วงหน้า — เริ่มหลัง login สำเร็จ
* ตัวจับเวลาเช็คทุกนาที ถ้าเหลืออายุ < 2 นาทีก็ refresh เอง (ผู้ใช้ไม่รู้สึก — AC-9)
*/
export function startAutoRefresh(): void {
if (autoRefreshTimer !== null) {
return;
}
autoRefreshTimer = setInterval(() => {
if (
tokenState.refreshToken &&
Date.now() >= tokenState.expiresAt - AUTO_REFRESH_BEFORE_EXPIRY_MS
) {
void refreshAccessToken();
}
}, AUTO_REFRESH_INTERVAL_MS) as unknown as number;
}
export function stopAutoRefresh(): void {
if (autoRefreshTimer !== null) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
// ---------------------------------------------------------------------------
// สำหรับ unit test — seed session โดยไม่ต้องเดิน dialog จริง
// ---------------------------------------------------------------------------
/** test เท่านั้น: ใส่ token state ตรงๆ (access token เป็น JWT ปลอมก็ได้ — เอาแค่ exp) */
export function setSessionForTest(
accessToken: string | null,
refreshToken: string | null,
expiresAt: number
): void {
tokenState = { accessToken, refreshToken, expiresAt };
}

View file

@ -0,0 +1,98 @@
import { getCurrentDocumentFile, DOCX_CONTENT_TYPE } from "./fileService";
interface FileMockState {
getFileCalls: number;
requestedSlices: number[];
closeCalls: number;
}
/** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */
function installOfficeFileMock(
slices: Uint8Array[],
options: { failSliceIndex?: number; failClose?: boolean; sliceDataForm?: "buffer" | "view" } = {}
): FileMockState {
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
const file = {
sliceCount: slices.length,
getSliceAsync: (index: number, callback: (result: unknown) => void) => {
state.requestedSlices.push(index);
if (options.failSliceIndex === index) {
callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } });
} else {
// host จริงอาจส่งมาเป็น ArrayBuffer หรือ view — ทดสอบทั้งคู่
const data = options.sliceDataForm === "view" ? slices[index] : slices[index].buffer;
callback({ status: "succeeded", value: { index, data, size: slices[index].byteLength } });
}
},
closeAsync: (callback?: (result: unknown) => void) => {
state.closeCalls++;
callback?.({ status: options.failClose ? "failed" : "succeeded" });
},
};
(globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: {
document: {
getFileAsync: (
_fileType: unknown,
_options: unknown,
callback: (result: unknown) => void
) => {
state.getFileCalls++;
callback({ status: "succeeded", value: file });
},
},
},
};
return state;
}
function bytes(...values: number[]): Uint8Array {
return new Uint8Array(values);
}
describe("getCurrentDocumentFile", () => {
it("ประกอบ slice หลากขนาดเป็น Blob เดียว อ่านครบทุก slice ตามลำดับ และ closeAsync หนึ่งครั้ง", async () => {
const state = installOfficeFileMock([
bytes(1, 2, 3),
bytes(4, 5),
new Uint8Array(2048).fill(9),
]);
const blob = await getCurrentDocumentFile();
expect(blob.type).toBe(DOCX_CONTENT_TYPE);
expect(blob.size).toBe(3 + 2 + 2048);
expect(state.requestedSlices).toEqual([0, 1, 2]);
expect(state.getFileCalls).toBe(1);
expect(state.closeCalls).toBe(1);
});
it("slice มาเป็น typed array view (ไม่ใช่ ArrayBuffer) ก็ประกอบได้", async () => {
const state = installOfficeFileMock([bytes(10, 20), bytes(30)], { sliceDataForm: "view" });
const blob = await getCurrentDocumentFile();
expect(blob.size).toBe(3);
expect(state.closeCalls).toBe(1);
});
it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => {
const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 });
await expect(getCurrentDocumentFile()).rejects.toThrow("อ่าน slice 1 พัง (mock)");
expect(state.closeCalls).toBe(1);
expect(state.requestedSlices).toEqual([0, 1]);
});
it("closeAsync fail เอง → ไม่ทำ flow พัง (blob ยังคืนมา)", async () => {
installOfficeFileMock([bytes(7, 8, 9)], { failClose: true });
const blob = await getCurrentDocumentFile();
expect(blob.size).toBe(3);
});
});

View file

@ -0,0 +1,90 @@
/* global Office, console */
/**
* ดึงไฟล์ .docx ปัจจุบันผ่าน File/Slice API (FR-2 ขั้นตอนที่ 5)
* กติกา: Word เปิด File object พร้อมกันได้แค่ 2 ตัว → เรียก closeAsync เสมอใน finally
*/
export const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/** ขนาด slice ต่อครั้ง — 1MB กันกิน memory ยักษ์ในเครื่องรายงานน้อย */
const SLICE_SIZE = 1024 * 1024;
function officeError(result: Office.AsyncResult<unknown>, fallback: string): Error {
return new Error(result.error?.message || fallback);
}
function getFileAsync(): Promise<Office.File> {
return new Promise((resolve, reject) => {
try {
Office.context.document.getFileAsync(
Office.FileType.Compressed,
{ sliceSize: SLICE_SIZE },
(result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
resolve(result.value);
} else {
reject(officeError(result, "เปิดไฟล์เอกสารไม่สำเร็จ"));
}
}
);
} catch (error) {
reject(error instanceof Error ? error : new Error("เปิดไฟล์เอกสารไม่สำเร็จ"));
}
});
}
/** host ส่ง slice มาเป็น ArrayBuffer หรือ view — รับทั้งคู่แล้วคืนเป็น BlobPart ตรงๆ */
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
return new Promise((resolve, reject) => {
try {
file.getSliceAsync(sliceIndex, (result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) {
const data: unknown = result.value.data;
if (data instanceof ArrayBuffer) {
resolve(data);
} else if (ArrayBuffer.isView(data)) {
const view = data as ArrayBufferView;
resolve(new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength));
} else {
reject(new Error("รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)"));
}
} else {
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
}
});
} catch (error) {
reject(error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));
}
});
}
/** ปิด File object — fail ก็แค่ warn ไม่ทับ error ของ flow หลัก */
function closeFileQuietly(file: Office.File): Promise<void> {
return new Promise((resolve) => {
try {
file.closeAsync(() => resolve());
} catch (error) {
console.warn("closeAsync failed:", error);
resolve();
}
});
}
/**
* ประกอบไฟล์ทั้งไฟล์เป็น Blob — อ่าน slice ทีละชิ้นตามลำดับ
* เรียก closeAsync เสมอ (สำเร็จหรือ fail ก็ตาม)
*/
export async function getCurrentDocumentFile(): Promise<Blob> {
const file = await getFileAsync();
try {
const chunks: BlobPart[] = [];
for (let index = 0; index < file.sliceCount; index++) {
chunks.push(await getSliceAsync(file, index));
}
return new Blob(chunks, { type: DOCX_CONTENT_TYPE });
} finally {
await closeFileQuietly(file);
}
}

View file

@ -0,0 +1,195 @@
import {
ADDIN_SIGNATURE_GUID,
buildVersionHistoryXml,
CUSTOM_XML_NAMESPACE,
isTrackedDocument,
readContentControlFields,
readTrackedProperties,
readVersionHistoryFromXml,
stampTrackedProperties,
writeVersionHistoryToXml,
XmlVersionEntry,
} from "./metadataService";
import { installWordMock } from "../test/wordMock";
describe("readTrackedProperties", () => {
it("อ่านค่าได้ครบทั้ง 4 properties เมื่อมีทั้งหมด", async () => {
installWordMock({
properties: {
SavedByAddIn: ADDIN_SIGNATURE_GUID,
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
LastVersionId: "22222222-2222-2222-2222-222222222222",
LastVersionTimestamp: "2026-08-19T10:00:00Z",
},
});
await expect(readTrackedProperties()).resolves.toEqual({
savedByAddIn: ADDIN_SIGNATURE_GUID,
documentTrackingId: "11111111-1111-1111-1111-111111111111",
lastVersionId: "22222222-2222-2222-2222-222222222222",
lastVersionTimestamp: "2026-08-19T10:00:00Z",
});
});
it("property ที่ไม่มีในเอกสารเป็น undefined ไม่ throw", async () => {
installWordMock({ properties: { UnrelatedProperty: "x" } });
const properties = await readTrackedProperties();
expect(properties.savedByAddIn).toBeUndefined();
expect(properties.documentTrackingId).toBeUndefined();
expect(properties.lastVersionId).toBeUndefined();
expect(properties.lastVersionTimestamp).toBeUndefined();
});
});
describe("isTrackedDocument (FR-1)", () => {
it("true เมื่อ SavedByAddIn ตรง ADDIN_SIGNATURE_GUID", async () => {
installWordMock({ properties: { SavedByAddIn: ADDIN_SIGNATURE_GUID } });
await expect(isTrackedDocument()).resolves.toBe(true);
});
it("false เมื่อค่าไม่ตรง หรือไม่มี property", async () => {
installWordMock({ properties: { SavedByAddIn: "OTHER-ADDIN-GUID" } });
await expect(isTrackedDocument()).resolves.toBe(false);
installWordMock({ properties: {} });
await expect(isTrackedDocument()).resolves.toBe(false);
});
});
describe("stampTrackedProperties (FR-2 ขั้นตอนที่ 2)", () => {
it("stamp ครบ 4 properties ด้วย add() ทับค่าเดิมได้", async () => {
const state = installWordMock({
properties: { SavedByAddIn: "OTHER-ADDIN-GUID", DocumentTrackingId: "old-tracking-id" },
});
await stampTrackedProperties({
documentTrackingId: "new-tracking-id",
lastVersionId: "version-2",
lastVersionTimestamp: "2026-08-19T11:00:00Z",
});
expect(state.propertyAdds).toEqual([
{ key: "SavedByAddIn", value: ADDIN_SIGNATURE_GUID },
{ key: "DocumentTrackingId", value: "new-tracking-id" },
{ key: "LastVersionId", value: "version-2" },
{ key: "LastVersionTimestamp", value: "2026-08-19T11:00:00Z" },
]);
// add() ของ property ที่มีอยู่ = update ค่าในเอกสารจริง
expect(state.properties.SavedByAddIn).toBe(ADDIN_SIGNATURE_GUID);
expect(state.properties.DocumentTrackingId).toBe("new-tracking-id");
});
it("ไม่ส่งค่าใดมา = stamp เฉพาะ SavedByAddIn", async () => {
const state = installWordMock({});
await stampTrackedProperties({});
expect(state.propertyAdds).toEqual([{ key: "SavedByAddIn", value: ADDIN_SIGNATURE_GUID }]);
});
});
describe("Custom XML Part (delete-then-add, silent fail)", () => {
const entries: XmlVersionEntry[] = [
{ id: "version-3", number: 3, timestamp: "2026-08-19T12:00:00Z", user: "tester" },
{ id: "version-2", number: 2, timestamp: "2026-08-19T11:00:00Z", user: "other" },
];
it("writeVersionHistoryToXml ลบ part เดิมทั้งหมดก่อน add ของใหม่ แล้วคืน true", async () => {
const state = installWordMock({
xmlParts: [
buildVersionHistoryXml([{ id: "version-1", number: 1, timestamp: "t", user: "u" }]),
],
});
await expect(writeVersionHistoryToXml(entries)).resolves.toBe(true);
// อันเดิมถูกลบ เหลืออันใหม่อันเดียว (อยู่ใน namespace ของเรา)
expect(state.xmlParts).toHaveLength(1);
expect(state.xmlParts[0].namespace).toBe(CUSTOM_XML_NAMESPACE);
expect(state.xmlParts[0].xml).toContain('id="version-3"');
expect(state.xmlParts[0].xml).toContain('number="3"');
expect(state.xmlParts[0].xml).toContain('user="tester"');
});
it("เขียนไม่ได้ (known bug บน Mac) → คืน false ไม่ throw (AC-10)", async () => {
installWordMock({ failXmlAdd: true });
await expect(writeVersionHistoryToXml(entries)).resolves.toBe(false);
});
it("readVersionHistoryFromXml parse รายการและเรียงใหม่ → เก่า", async () => {
installWordMock({
xmlParts: [
buildVersionHistoryXml([
{ id: "version-1", number: 1, timestamp: "2026-08-19T09:00:00Z", user: "a" },
{ id: "version-3", number: 3, timestamp: "2026-08-19T12:00:00Z", user: "b" },
{ id: "version-2", number: 2, timestamp: "2026-08-19T11:00:00Z", user: "c" },
]),
],
});
const result = await readVersionHistoryFromXml();
expect(result.map((entry) => entry.number)).toEqual([3, 2, 1]);
expect(result[0]).toEqual({
id: "version-3",
number: 3,
timestamp: "2026-08-19T12:00:00Z",
user: "b",
});
});
it("อ่าน fail → คืน [] ไม่ throw (XML part เป็น secondary เท่านั้น)", async () => {
installWordMock({ failXmlRead: true });
await expect(readVersionHistoryFromXml()).resolves.toEqual([]);
});
it("buildVersionHistoryXml escape อักขระพิเศษใน attribute", () => {
const xml = buildVersionHistoryXml([
{ id: 'a"b', number: 1, timestamp: "<t>&", user: "u'ser" },
]);
expect(xml).toContain('id="a&quot;b"');
expect(xml).toContain('timestamp="&lt;t&gt;&amp;"');
expect(xml).toContain('user="u&apos;ser"');
});
});
describe("readContentControlFields (FR-11)", () => {
it("แปลง contentControls เป็น map tag → text", async () => {
installWordMock({
contentControls: [
{ tag: "employee_name", text: "สมชาย ใจดี" },
{ tag: "salary", text: "50000" },
{ tag: "start_date", text: "2026-08-01" },
],
});
await expect(readContentControlFields()).resolves.toEqual({
employee_name: "สมชาย ใจดี",
salary: "50000",
start_date: "2026-08-01",
});
});
it("tag ซ้ำเอาตัวแรกตามลำดับในเอกสาร / tag ว่างข้าม", async () => {
installWordMock({
contentControls: [
{ tag: "position", text: "first" },
{ tag: "position", text: "second" },
{ tag: "", text: "ไม่มี tag" },
{ tag: " ", text: "tag วรรคล้วน" },
{ tag: "note", text: "ok" },
],
});
await expect(readContentControlFields()).resolves.toEqual({
position: "first",
note: "ok",
});
});
it("เอกสารไม่มี content controls → คืน {} ไม่ error", async () => {
installWordMock({ contentControls: [] });
await expect(readContentControlFields()).resolves.toEqual({});
});
});

View file

@ -0,0 +1,231 @@
/* global Word, console */
/**
* Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8):
* - Custom Document Properties = primary flag ตรวจจับ tracked document
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
*
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
* (known bug บน Mac บาง build — เขียนไม่ได้ก็ปล่อยผ่าน)
*/
/** GUID คงที่ของ add-in — ต้องตรงเป๊ะกับ `AddInMetadata.AddInSignatureGuid` ฝั่ง backend (spec ส่วน 8) */
export const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
export const CUSTOM_XML_NAMESPACE = "urn:myaddin:versioning";
export const SAVED_BY_ADD_IN_PROPERTY = "SavedByAddIn";
export const DOCUMENT_TRACKING_ID_PROPERTY = "DocumentTrackingId";
export const LAST_VERSION_ID_PROPERTY = "LastVersionId";
export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
export interface TrackedDocumentProperties {
savedByAddIn?: string;
documentTrackingId?: string;
lastVersionId?: string;
lastVersionTimestamp?: string;
}
/** item ใน Custom XML Part `<VersionHistory>` */
export interface XmlVersionEntry {
id: string;
number: number;
timestamp: string;
user: string;
}
/** อ่าน custom properties ทั้ง 4 ตัวที่ระบบสนใจ */
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
return Word.run(async (context) => {
const properties = context.document.properties.customProperties;
const savedByAddIn = properties.getItemOrNullObject(SAVED_BY_ADD_IN_PROPERTY);
const documentTrackingId = properties.getItemOrNullObject(DOCUMENT_TRACKING_ID_PROPERTY);
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
savedByAddIn.load("value");
documentTrackingId.load("value");
lastVersionId.load("value");
lastVersionTimestamp.load("value");
await context.sync();
const read = (property: Word.CustomProperty): string | undefined =>
property.isNullObject || property.value === undefined || property.value === null
? undefined
: String(property.value);
return {
savedByAddIn: read(savedByAddIn),
documentTrackingId: read(documentTrackingId),
lastVersionId: read(lastVersionId),
lastVersionTimestamp: read(lastVersionTimestamp),
};
});
}
/** FR-1 — ไฟล์นี้ผ่าน add-in นี้หรือเปล่า (รวมไฟล์ที่ generate จากเว็บ ที่ backend stamp ไว้แล้ว) */
export async function isTrackedDocument(): Promise<boolean> {
const properties = await readTrackedProperties();
return properties.savedByAddIn === ADDIN_SIGNATURE_GUID;
}
export interface StampPropertiesInput {
/** tracking id ของเอกสาร — ส่งเฉพาะตอนสร้างใหม่ (Start Versioning) หรือตอน stamp รอบแรก */
documentTrackingId?: string;
lastVersionId?: string;
lastVersionTimestamp?: string;
}
/**
* เขียน/stamp custom properties — ใช้ add() ทับของเดิม (add กับ key ที่มีอยู่ = update ค่า
* ต่างจาก XML part ที่ต้อง delete ก่อน) พร้อมกันทั้งชุดใน Word.run เดียว
*/
export async function stampTrackedProperties(input: StampPropertiesInput): Promise<void> {
await Word.run(async (context) => {
const properties = context.document.properties.customProperties;
properties.add(SAVED_BY_ADD_IN_PROPERTY, ADDIN_SIGNATURE_GUID);
if (input.documentTrackingId !== undefined) {
properties.add(DOCUMENT_TRACKING_ID_PROPERTY, input.documentTrackingId);
}
if (input.lastVersionId !== undefined) {
properties.add(LAST_VERSION_ID_PROPERTY, input.lastVersionId);
}
if (input.lastVersionTimestamp !== undefined) {
properties.add(LAST_VERSION_TIMESTAMP_PROPERTY, input.lastVersionTimestamp);
}
await context.sync();
});
}
/** GUID ใหม่สำหรับ tracking id / clientVersionId */
export function generateGuid(): string {
if (typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
// ---------------------------------------------------------------------------
// Custom XML Part — secondary history (ไม่ใช่ source of truth)
// ---------------------------------------------------------------------------
function escapeXmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** สร้าง XML ของ `<VersionHistory>` ตามรูปใน spec ส่วน 8 */
export function buildVersionHistoryXml(entries: XmlVersionEntry[]): string {
const elements = entries
.map(
(entry) =>
` <Version id="${escapeXmlAttribute(entry.id)}" number="${entry.number}" timestamp="${escapeXmlAttribute(
entry.timestamp
)}" user="${escapeXmlAttribute(entry.user)}"/>`
)
.join("\n");
return `<VersionHistory xmlns="${CUSTOM_XML_NAMESPACE}">\n${elements}\n</VersionHistory>`;
}
function parseVersionHistoryXml(xml: string): XmlVersionEntry[] {
const doc = new DOMParser().parseFromString(xml, "application/xml");
const nodes = Array.from(doc.getElementsByTagNameNS(CUSTOM_XML_NAMESPACE, "Version"));
return nodes
.map((node) => ({
id: node.getAttribute("id") ?? "",
number: Number(node.getAttribute("number") ?? Number.NaN),
timestamp: node.getAttribute("timestamp") ?? "",
user: node.getAttribute("user") ?? "",
}))
.filter((entry) => entry.id !== "" && Number.isFinite(entry.number));
}
/** อ่าน compact history จาก XML part — fail อ่านเป็น [] เสมอ (ไม่ throw) */
export async function readVersionHistoryFromXml(): Promise<XmlVersionEntry[]> {
try {
return await Word.run(async (context) => {
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
context.load(scoped);
await context.sync();
const xmlResults = scoped.items.map((part) => part.getXml());
await context.sync();
const entries: XmlVersionEntry[] = [];
for (const result of xmlResults) {
entries.push(...parseVersionHistoryXml(result.value));
}
entries.sort((a, b) => b.number - a.number); // ใหม่ → เก่า
return entries;
});
} catch (error) {
console.warn("readVersionHistoryFromXml failed (secondary storage — ปล่อยผ่าน):", error);
return [];
}
}
/**
* เขียน XML part ด้วย pattern delete-then-add (แก้ part เดิมตรงๆ ไม่ได้)
* ⚠️ ห้าม throw แม้จะล้มเหลว — known bug บน Word for Mac บาง build
* คืน true = เขียนสำเร็จ, false = fail แบบ silent (backend ยังเป็น source of truth อยู่ดี — AC-10)
*/
export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Promise<boolean> {
try {
await Word.run(async (context) => {
// 1) ลบ part เดิมใน namespace นี้ทั้งหมดก่อน
const scoped = context.document.customXmlParts.getByNamespace(CUSTOM_XML_NAMESPACE);
context.load(scoped);
await context.sync();
for (const part of scoped.items) {
part.delete();
}
if (scoped.items.length > 0) {
await context.sync();
}
// 2) add ของใหม่
context.document.customXmlParts.add(buildVersionHistoryXml(entries));
await context.sync();
});
return true;
} catch (error) {
console.warn("writeVersionHistoryToXml failed (silent per spec — known Mac bug):", error);
return false;
}
}
// ---------------------------------------------------------------------------
// Content controls → fields (FR-11)
// ---------------------------------------------------------------------------
/**
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
* - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error)
* - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร
*/
export async function readContentControlFields(): Promise<Record<string, string>> {
return Word.run(async (context) => {
const controls = context.document.contentControls;
controls.load("tag, text");
await context.sync();
const fields: Record<string, string> = {};
for (const control of controls.items) {
const tag = (control.tag ?? "").trim();
if (tag === "") {
continue;
}
if (!Object.prototype.hasOwnProperty.call(fields, tag)) {
fields[tag] = control.text;
}
}
return fields;
});
}

View file

@ -0,0 +1,60 @@
import { config } from "../config";
import { UploadVersionResult, VersionRecord } from "../models/versionRecord";
import { apiFetch, apiGetJson, apiSendExpectJson, ApiError, readErrorMessage } from "./apiClient";
import { generateGuid } from "./metadataService";
/**
* เรียก version API ของ backend (FR-5/6/7 ฝั่ง client)
* `fields` = map จาก `readContentControlFields()` ตาม FR-11
*/
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (แนบ fields map ถ้ามี) */
export async function uploadVersion(
documentId: string,
file: Blob,
clientVersionId: string,
fields?: Record<string, string>,
fileName?: string
): Promise<UploadVersionResult> {
const form = new FormData();
form.append("file", file, fileName || `${documentId}.docx`);
form.append("clientVersionId", clientVersionId);
if (fields && Object.keys(fields).length > 0) {
form.append("fields", JSON.stringify(fields));
}
return apiSendExpectJson<UploadVersionResult>(
`/api/documents/${documentId}/versions`,
{ method: "POST", body: form },
"อัปโหลด version ไม่สำเร็จ"
);
}
/** FR-6 — version history เรียงใหม่ → เก่า */
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
return apiGetJson<VersionRecord[]>(
`/api/documents/${documentId}/versions`,
"โหลด version history ไม่สำเร็จ"
);
}
/** FR-7 — URL ของ download endpoint (ผ่าน backend proxy เท่านั้น — MinIO ไม่ถูก expose) */
export function buildDownloadUrl(documentId: string, versionId: string): string {
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
}
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
const response = await apiFetch(buildDownloadUrl(documentId, versionId));
if (!response.ok) {
throw new ApiError(
response.status,
await readErrorMessage(response, `ดาวน์โหลด version ไม่สำเร็จ (${response.status})`)
);
}
return response.blob();
}
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
export function newClientVersionId(): string {
return generateGuid();
}

View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="th">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<title>Sign-in</title>
<!-- Office JavaScript API — ต้องโหลดก่อนเรียก messageParent -->
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body style="width: 100%; height: 100%; margin: 0; padding: 16px; font-family: 'Segoe UI', sans-serif; color: #333;">
<p id="status">กำลังกลับสู่ add-in…</p>
</body>
</html>

View file

@ -0,0 +1,61 @@
/* global Office, window, URLSearchParams, document */
/**
* หน้า redirect ของ Keycloak — รัน "ใน Office Dialog" ไม่ใช่ task pane (FR-8)
* หน้าที่: parse code/state จาก URL → messageParent กลับ task pane → ปิด dialog
* ตัวแลก token อยู่ฝั่ง task pane (authService.login) เพราะ verifier ถูก generate ที่นั่น
*/
interface CallbackPayload {
code?: string;
state?: string;
error?: string;
errorDescription?: string;
}
function setStatus(text: string): void {
const element = document.getElementById("status");
if (element) {
element.textContent = text;
}
}
Office.onReady(() => {
const params = new URLSearchParams(window.location.search);
const payload: CallbackPayload = {};
const error = params.get("error");
if (error) {
payload.error = error;
const description = params.get("error_description");
if (description) {
payload.errorDescription = description;
}
setStatus("เข้าสู่ระบบไม่สำเร็จ — กำลังปิดหน้าต่าง…");
} else {
const code = params.get("code");
const state = params.get("state");
if (code && state) {
payload.code = code;
payload.state = state;
setStatus("เข้าสู่ระบบสำเร็จ — กำลังกลับสู่ add-in…");
} else {
// เปิดตรงๆ ไม่ใช่ redirect จาก Keycloak — ไม่มีอะไรจะส่งกลับ
payload.error = "invalid_callback";
payload.errorDescription = "ไม่พบ code/state ใน URL";
setStatus("หน้านี้เปิดโดยตรงไม่ได้ — ต้องเข้าผ่านการ login ของ add-in");
}
}
try {
Office.context.ui.messageParent(JSON.stringify(payload));
} catch (error) {
// เช่นเปิดเป็น tab เองนอก dialog — บอกผู้ใช้แทนที่จะ throw
setStatus("ไม่สามารถติดต่อกลับ task pane ได้ (หน้านี้ต้องเปิดผ่าน add-in)");
console.error("messageParent failed:", error);
return;
}
// ปิด dialog ตัวเองหลังส่ง message กลับแล้ว (task pane ก็จะเรียก dialog.close() อีกชั้น)
window.close();
});

View file

@ -5,7 +5,7 @@ import { Button, Field, Textarea, tokens, makeStyles } from "@fluentui/react-com
/* global HTMLTextAreaElement */
interface TextInsertionProps {
insertText: (text: string) => void;
insertText: (_text: string) => void;
}
const useStyles = makeStyles({

View file

@ -0,0 +1,46 @@
/**
* Mock response แบบ duck-typing แทน `Response` จริง (jsdom ไม่มี Response/fetch global)
* service ที่ถูก test ใช้แค่ ok / status / json() / text() / blob()
*/
export interface MockHttpResponse {
ok: boolean;
status: number;
json: () => Promise<unknown>;
text: () => Promise<string>;
blob?: () => Promise<Blob>;
}
export function mockResponse(
options: { status?: number; body?: unknown; text?: string; blob?: Blob } = {}
): MockHttpResponse {
const status = options.status ?? 200;
const text = options.text ?? (options.body !== undefined ? JSON.stringify(options.body) : "");
return {
ok: status >= 200 && status < 300,
status,
json: async () => JSON.parse(text),
text: async () => text,
...(options.blob !== undefined ? { blob: async () => options.blob as Blob } : {}),
};
}
/** แทน globalThis.fetch ด้วย mock — คืนตัวจับ call กลับไปให้ assert */
export function installFetchMock(
implementation?: (...args: unknown[]) => Promise<MockHttpResponse>
): jest.Mock {
const fetchMock = jest.fn(
implementation ?? (async () => mockResponse({ status: 200, body: {} }))
);
(globalThis as unknown as Record<string, unknown>).fetch = fetchMock;
return fetchMock;
}
/** JWT ปลอมเพื่อ seed session — service อ่านแค่ claim `exp` */
export function makeFakeJwt(expiresAtMs: number, subject = "user-1"): string {
const encode = (value: string): string =>
btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const header = encode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = encode(JSON.stringify({ sub: subject, exp: Math.floor(expiresAtMs / 1000) }));
return `${header}.${payload}.${encode("fake-signature")}`;
}

28
addin/src/test/setup.ts Normal file
View file

@ -0,0 +1,28 @@
/**
* Setup ก่อนรัน test ทุกไฟล์:
* - polyfill WebCrypto.subtle / TextEncoder / TextDecoder (jsdom ไม่มี — ดึงจาก node)
* - ประกาศค่า DefinePlugin constants ที่ webpack ฉีดให้ตอน build (ค่า dev ตรง webpack.config.js)
*/
import { webcrypto } from "crypto";
const globalAny = globalThis as unknown as Record<string, unknown>;
if (!(globalAny.crypto as Crypto | undefined)?.subtle) {
// globalThis.crypto ใน jsdom เป็น getter-only (assign ตรงๆ ไม่ได้) แต่ configurable → defineProperty
Object.defineProperty(globalThis, "crypto", {
value: webcrypto,
configurable: true,
writable: true,
});
}
if (typeof globalAny.TextEncoder === "undefined") {
const util = require("util");
globalAny.TextEncoder = util.TextEncoder;
globalAny.TextDecoder = util.TextDecoder;
}
globalAny.__KEYCLOAK_AUTHORITY__ = "http://localhost:8080/realms/word-versioning";
globalAny.__KEYCLOAK_CLIENT_ID__ = "word-addin-officejs";
globalAny.__API_BASE_URL__ = "http://localhost:5075";

116
addin/src/test/wordMock.ts Normal file
View file

@ -0,0 +1,116 @@
import { CUSTOM_XML_NAMESPACE } from "../services/metadataService";
/**
* Mock `Word.run` ให้เหมือน Office.js จริงพอให้ metadataService ทำงานได้:
* - customProperties: getItemOrNullObject คืน proxy ที่มี isNullObject/value, add() บันทึกลง state
* - customXmlParts: getByNamespace/add/delete ใช้ state.xmlParts (string ต่อ part)
* - contentControls: items จาก state.contentControls
* หมายเหตุ: mock ถือว่าทุก xml part อยู่ใน namespace `urn:myaddin:versioning` ของเรา
*/
export interface WordMockOptions {
properties?: Record<string, string>;
contentControls?: Array<{ tag: string; text: string }>;
xmlParts?: string[];
/** จำลอง known bug บน Mac: add() XML part โดย throw */
failXmlAdd?: boolean;
/** จำลอง read XML part fail */
failXmlRead?: boolean;
}
export interface WordMockState {
properties: Record<string, string>;
/** ทุกครั้งที่โค้ดเรียก customProperties.add(key, value) */
propertyAdds: Array<{ key: string; value: unknown }>;
/** part ทั้งหมดที่ "มีอยู่" ในเอกสารจริงๆ (หลัง delete/add) */
xmlParts: Array<{ xml: string; namespace: string }>;
contentControls: Array<{ tag: string; text: string }>;
}
export function installWordMock(options: WordMockOptions = {}): WordMockState {
const state: WordMockState = {
properties: { ...(options.properties ?? {}) },
propertyAdds: [],
// part ที่ป้อนเข้า mock ถือว่าเป็นของ namespace ของเรา (จะเขียนทับ/delete โดย service)
xmlParts: (options.xmlParts ?? []).map((xml) => ({ xml, namespace: CUSTOM_XML_NAMESPACE })),
contentControls: (options.contentControls ?? []).map((control) => ({ ...control })),
};
const makeProperty = (key: string): unknown => {
const exists = Object.prototype.hasOwnProperty.call(state.properties, key);
return {
key: exists ? key : "",
get value() {
return exists ? state.properties[key] : undefined;
},
isNullObject: !exists,
load: () => undefined,
};
};
const makePart = (part: { xml: string; namespace: string }): unknown => ({
getXml: () => ({ value: part.xml }),
delete: () => {
const index = state.xmlParts.indexOf(part);
if (index >= 0) {
state.xmlParts.splice(index, 1);
}
},
});
const customXmlParts = {
getByNamespace: (namespaceUri: string) => {
if (options.failXmlRead) {
throw new Error("getByNamespace failed (mock)");
}
return {
items: state.xmlParts.filter((part) => part.namespace === namespaceUri).map(makePart),
load: () => undefined,
};
},
add: (xml: string) => {
if (options.failXmlAdd) {
throw new Error("custom xml part add failed (mock Mac bug)");
}
const part = { xml, namespace: CUSTOM_XML_NAMESPACE };
state.xmlParts.push(part);
return makePart(part);
},
};
const context = {
document: {
properties: {
customProperties: {
getItemOrNullObject: (key: string) => makeProperty(key),
add: (key: string, value: unknown) => {
state.propertyAdds.push({ key, value });
state.properties[key] = String(value);
return makeProperty(key);
},
load: () => undefined,
},
},
// ใน Word จริง customXmlParts อยู่บน context.document (Api set 1.4)
customXmlParts,
contentControls: {
get items() {
return state.contentControls.map((control) => ({ tag: control.tag, text: control.text }));
},
load: () => undefined,
},
},
sync: async () => undefined,
load: () => undefined,
};
(globalThis as unknown as Record<string, unknown>).Word = {
run: async <T>(batch: (context: unknown) => Promise<T>): Promise<T> =>
batch(context as unknown as Word.RequestContext),
} as unknown as typeof Word;
// กันชัดว่า mock ตรงกับ namespace ที่ service ใช้
expect(CUSTOM_XML_NAMESPACE).toBe("urn:myaddin:versioning");
return state;
}

View file

@ -13,8 +13,17 @@
"sourceMap": true,
"target": "es6",
"lib": [
"es7",
"dom"
"es2020",
"dom",
"dom.iterable"
],
"types": [
"office-js",
"office-runtime",
"react",
"react-dom",
"jest",
"node"
],
},
"exclude": [

View file

@ -8,6 +8,11 @@ const webpack = require("webpack");
const urlDev = "https://localhost:3000/";
const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION
// config ที่ inject เข้าโค้ดตอน build (อ่านจาก env — ห้าม hardcode ตามกติกา) — default คือ local dev
const keycloakAuthority = process.env.KEYCLOAK_AUTHORITY || "http://localhost:8080/realms/word-versioning";
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID || "word-addin-officejs";
const apiBaseUrl = (process.env.API_BASE_URL || "http://localhost:5075").replace(/\/$/, "");
async function getHttpsOptions() {
const httpsOptions = await devCerts.getHttpsServerOptions();
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
@ -24,6 +29,7 @@ module.exports = async (env, options) => {
import: ["./src/taskpane/index.tsx", "./src/taskpane/taskpane.html"],
dependOn: "react",
},
authCallback: "./src/taskpane/auth-callback.ts",
commands: "./src/commands/commands.ts",
},
output: {
@ -66,6 +72,12 @@ module.exports = async (env, options) => {
template: "./src/taskpane/taskpane.html",
chunks: ["polyfill", "taskpane", "react"],
}),
// หน้า redirect ของ Keycloak ที่รันใน Office Dialog (FR-8) — ต้องอยู่ที่ root ตรง redirect URI
new HtmlWebpackPlugin({
filename: "auth-callback.html",
template: "./src/taskpane/auth-callback.html",
chunks: ["polyfill", "authCallback"],
}),
new CopyWebpackPlugin({
patterns: [
{
@ -93,6 +105,12 @@ module.exports = async (env, options) => {
new webpack.ProvidePlugin({
Promise: ["es6-promise", "Promise"],
}),
// ค่า config ที่โค้ดอ่านผ่าน src/config.ts — แทนที่ตอน build ด้วยค่าจาก env
new webpack.DefinePlugin({
__KEYCLOAK_AUTHORITY__: JSON.stringify(keycloakAuthority),
__KEYCLOAK_CLIENT_ID__: JSON.stringify(keycloakClientId),
__API_BASE_URL__: JSON.stringify(apiBaseUrl),
}),
],
devServer: {
hot: true,