690419:1831 feat: update CI/CD to use SSH key authentication #05
CI / CD Pipeline / build (push) Failing after 4m57s
CI / CD Pipeline / deploy (push) Has been skipped

This commit is contained in:
2026-04-19 18:31:30 +07:00
parent 733f3c3987
commit 13745e5874
61 changed files with 6709 additions and 1241 deletions
@@ -91,9 +91,39 @@ describe('useWorkflowAction — T027a error handling (Clarify Q1+Q2)', () => {
);
});
it('403: should show unauthorized toast', async () => {
it('M1 (403): should use backend-provided message instead of hardcoded string', async () => {
// backend ส่ง message แบบ contextual (cross-contract) — frontend ต้อง preserve
vi.mocked(workflowEngineService.transition).mockRejectedValue(
makeApiError(403, 'ไม่มีสิทธิ์', ['ติดต่อ Admin'])
makeApiError(
403,
'คุณไม่มีสิทธิ์เข้าถึง Workflow ของสัญญานี้',
['ตรวจสอบสิทธิ์กับ Project Admin']
)
);
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
// ✓ toast ต้องใช้ backend message (ไม่ใช่ "คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้" generic)
expect(toast.error).toHaveBeenCalledWith(
'คุณไม่มีสิทธิ์เข้าถึง Workflow ของสัญญานี้',
expect.objectContaining({
description: 'ตรวจสอบสิทธิ์กับ Project Admin',
})
);
});
it('M1 (403): should fallback to generic message when backend message missing', async () => {
vi.mocked(workflowEngineService.transition).mockRejectedValue(
makeApiError(403, '', ['ติดต่อ Admin'])
);
const { wrapper } = createTestQueryClient();
@@ -115,6 +145,81 @@ describe('useWorkflowAction — T027a error handling (Clarify Q1+Q2)', () => {
);
});
it('M3 (409): should reset idempotency key after 409 so retry uses fresh key', async () => {
// First call → 409
vi.mocked(workflowEngineService.transition).mockRejectedValueOnce(
makeApiError(409, 'ไม่สามารถอัปโหลดในสถานะนี้ได้', ['รีเฟรชหน้า'])
);
// Second call → success
vi.mocked(workflowEngineService.transition).mockResolvedValueOnce({
success: true,
nextState: 'APPROVED',
});
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
// First mutate → 409
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => expect(result.current.isError).toBe(true));
const firstKey = vi
.mocked(workflowEngineService.transition)
.mock.calls[0][2];
// Second mutate → success
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const secondKey = vi
.mocked(workflowEngineService.transition)
.mock.calls[1][2];
// ✓ Key ต้องแตกต่างกัน (reset แล้วหลัง 409)
expect(firstKey).not.toBe(secondKey);
expect(firstKey).toMatch(/^[0-9a-f-]{36}$/);
expect(secondKey).toMatch(/^[0-9a-f-]{36}$/);
});
it('M3 (503): should NOT reset idempotency key (user can retry with same key)', async () => {
// First call → 503
vi.mocked(workflowEngineService.transition).mockRejectedValueOnce(
makeApiError(503, 'ระบบยุ่ง')
);
// Second call → success
vi.mocked(workflowEngineService.transition).mockResolvedValueOnce({
success: true,
});
const { wrapper } = createTestQueryClient();
const { result } = renderHook(() => useWorkflowAction('inst-1'), { wrapper });
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => expect(result.current.isError).toBe(true));
const firstKey = vi
.mocked(workflowEngineService.transition)
.mock.calls[0][2];
await act(async () => {
result.current.mutate({ action: 'APPROVE' });
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const secondKey = vi
.mocked(workflowEngineService.transition)
.mock.calls[1][2];
// ✓ Key ต้องเหมือนเดิม (503 = retryable, same intent)
expect(firstKey).toBe(secondKey);
});
it('should show success toast on 200', async () => {
vi.mocked(workflowEngineService.transition).mockResolvedValue({
success: true,
+36
View File
@@ -0,0 +1,36 @@
import { useMutation } from '@tanstack/react-query';
import apiClient from '../lib/api/client';
export interface RagCitation {
chunkId: string;
docNumber: string | null;
docType: string;
revision: string | null;
snippet: string;
score: number;
}
export interface RagQueryRequest {
question: string;
projectPublicId: string;
}
export interface RagQueryResponse {
answer: string;
citations: RagCitation[];
confidence: number;
usedFallbackModel: boolean;
cachedAt?: string;
}
export function useRagQuery() {
return useMutation<RagQueryResponse, Error, RagQueryRequest>({
mutationFn: async (payload) => {
const idempotencyKey = `rag-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const res = await apiClient.post<{ data: RagQueryResponse }>('/rag/query', payload, {
headers: { 'Idempotency-Key': idempotencyKey },
});
return res.data.data;
},
});
}
+10 -8
View File
@@ -12,13 +12,13 @@ import type { ApiErrorResponse } from '@/lib/api/client';
import type { WorkflowTransitionWithAttachmentsDto } from '@/types/dto/workflow-engine/workflow-engine.dto';
// Type guard — ตรวจสอบว่า error ที่ได้มาเป็น ApiErrorResponse (จาก parseApiError interceptor)
// S3: ป้องกัน edge case `{ error: null }` ซึ่ง typeof null === 'object' แต่ destructure จะ throw
function isApiErrorResponse(err: unknown): err is ApiErrorResponse {
return (
typeof err === 'object' &&
err !== null &&
'error' in err &&
typeof (err as ApiErrorResponse).error === 'object'
);
if (typeof err !== 'object' || err === null || !('error' in err)) {
return false;
}
const inner = (err as { error: unknown }).error;
return typeof inner === 'object' && inner !== null;
}
export function useWorkflowAction(instanceId: string | undefined) {
@@ -69,15 +69,17 @@ export function useWorkflowAction(instanceId: string | undefined) {
// Clarify Q1: 409 Conflict (ไม่อยู่ในสถานะที่อนุญาตให้อัปโหลด)
if (statusCode === 409) {
// M3: reset idempotency key — user intent กับ state เดิมใช้ไม่ได้แล้ว
setIdempotencyKey(uuidv4());
toast.error(message || 'ไม่สามารถดำเนินการในสถานะนี้ได้', {
description: recoveryActions?.[0],
});
return;
}
// 403 Forbidden — ไม่มีสิทธิ์
// 403 Forbidden — ไม่มีสิทธิ์ (M1: ใช้ message จาก backend เพื่อคง context)
if (statusCode === 403) {
toast.error('คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', {
toast.error(message || 'คุณไม่มีสิทธิ์ดำเนินการในขั้นตอนนี้', {
description: recoveryActions?.[0],
});
return;