diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3c96302f..c5b140d8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,4 +1,8 @@
## [Unreleased]
+### 새로운 유틸리티 도구 추가 (UUID/해시 생성기)
+
+- `backend/api/tools.py`에 `uuid_generator`와 `hash_generator` 두 가지 유틸리티 도구를 추가했습니다. UUID(버전 1, 4) 생성 및 해시(MD5, SHA1, SHA256, SHA512) 생성을 지원하며, 관련된 테스트 코드를 `backend/tests/test_tools_api.py`에 추가하여 100% 커버리지를 달성했습니다.
+
### 보안 패치 (CodeQL extended current-head)
- CodeQL `extended` 기본 설정이 current `develop`에서 확인한 Critical 8건·High 21건·Medium 1건을 코드 경계에서 제거합니다. 서버 요청은 검증된 loopback/HTTPS origin, 동일 OIDC issuer origin, 허용 API 경로·쿼리만 재구성하고 redirect를 자동 추종하지 않으며, 공개 IPv6 authority를 보존합니다. UI smoke는 고정 Node/Next 실행 파일과 인자, localhost:3001 allowlist, private `mkdtemp` artifact 디렉터리 및 containment 검사만 사용합니다.
diff --git a/backend/api/tools.py b/backend/api/tools.py
index eafbaaf76..ae52f8f8c 100644
--- a/backend/api/tools.py
+++ b/backend/api/tools.py
@@ -4,8 +4,10 @@
import json
import logging
import re
+import secrets
import unicodedata
import urllib.parse
+import uuid
from collections import Counter
from collections.abc import Callable
from typing import Any, Dict, List, Optional
@@ -27,6 +29,22 @@
MAX_TOOL_FAILURE_MESSAGE_CHARS = 500
+class ToolOptionError(ValueError):
+ """A tool option failure with a stable machine-readable code."""
+
+ def __init__(self, error_code: str, message: str):
+ super().__init__(message)
+ self.error_code = error_code
+
+
+class ToolValidationError(ValueError):
+ """A tool request validation failure with a stable machine-readable code."""
+
+ def __init__(self, error_code: str, message: str):
+ super().__init__(message)
+ self.error_code = error_code
+
+
def _tool_code_fingerprint(code: str) -> str:
"""Return a stable non-reversible identifier for correlating tool failures."""
return hashlib.sha256(code.encode("utf-8", errors="replace")).hexdigest()[:12]
@@ -125,8 +143,11 @@ class ExecuteRequest(BaseModel):
class ExecuteResponse(BaseModel):
status: str = Field(..., description="실행 상태 (예: success, failed)")
- result: Any = Field(..., description="실행 결과 데이터")
+ result: Any = Field(default=None, description="실행 결과 데이터")
message: Optional[str] = Field(default=None, description="결과 메시지")
+ error_code: Optional[str] = Field(
+ default=None, description="실패 유형을 나타내는 안정적인 오류 코드"
+ )
class ToolRegistry:
@@ -159,27 +180,42 @@ async def invoke_tool(self, code: str, params: Dict[str, Any]) -> Any:
def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(params, dict):
- raise ValueError("Tool parameters must be an object")
+ raise ToolValidationError(
+ "invalid_tool_parameters",
+ "Tool parameters must be an object",
+ )
tool_info = self._tools.get(code)
schema = tool_info.parameters if tool_info else None
if not schema:
if params:
- raise ValueError("Tool does not accept parameters")
+ raise ToolValidationError(
+ "tool_parameters_not_supported",
+ "Tool does not accept parameters",
+ )
return {}
unexpected_keys = set(params) - set(schema)
if unexpected_keys:
- raise ValueError("Unexpected tool parameter")
+ raise ToolValidationError(
+ "unexpected_tool_parameter",
+ "Unexpected tool parameter",
+ )
validated: Dict[str, Any] = {}
for key, descriptor in schema.items():
if key not in params:
- raise ValueError("Missing required tool parameter")
+ raise ToolValidationError(
+ "missing_tool_parameter",
+ "Missing required tool parameter",
+ )
value = params[key]
expected_type = _parameter_type_name(descriptor)
if not _parameter_matches_type(value, expected_type):
- raise ValueError("Invalid tool parameter type")
+ raise ToolValidationError(
+ "invalid_tool_parameter_type",
+ "Invalid tool parameter type",
+ )
validated[key] = value
return validated
@@ -189,6 +225,7 @@ def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, A
# Initialize default tools
+
async def mock_handler(params: Dict[str, Any]) -> str:
encoded = json.dumps(params, ensure_ascii=False, sort_keys=True)
return f"Mock execution successful with params: {encoded}"
@@ -245,6 +282,7 @@ async def tone_analyzer_handler(params: Dict[str, Any]) -> Any:
"tone_score": 85,
}
+
def _detect_text_language(text: str) -> str:
if any("\uac00" <= char <= "\ud7a3" for char in text):
return "ko"
@@ -272,7 +310,10 @@ async def email_translator_handler(params: Dict[str, Any]) -> Any:
]
translated_terms: list[str] = []
for source_phrase, translated_phrase in phrase_map:
- if source_phrase in lowered_text and translated_phrase not in translated_terms:
+ if (
+ source_phrase in lowered_text
+ and translated_phrase not in translated_terms
+ ):
translated_terms.append(translated_phrase)
translated_text = " ".join(translated_terms) if translated_terms else text
confidence = 0.9 if translated_terms else 0.45
@@ -291,7 +332,9 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
normalized_domain = sender_domain.lower()
phishing_terms = {"password", "bank", "login", "verify", "account", "credential"}
spam_terms = {"urgent", "now", "free", "winner", "click", "limited"}
- phishing_hits = sorted(term for term in phishing_terms if term in normalized_content)
+ phishing_hits = sorted(
+ term for term in phishing_terms if term in normalized_content
+ )
spam_hits = sorted(term for term in spam_terms if term in normalized_content)
suspicious_domain = (
normalized_domain.endswith((".ru", ".zip", ".tk"))
@@ -314,7 +357,9 @@ async def spam_phishing_detector_handler(params: Dict[str, Any]) -> Any:
warnings.append(f"sender domain looks suspicious: {sender_domain}")
return {
"is_spam": bool(spam_hits or suspicious_domain),
- "is_phishing": bool(len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)),
+ "is_phishing": bool(
+ len(phishing_hits) >= 2 or (phishing_hits and suspicious_domain)
+ ),
"risk_score": risk_score,
"warnings": warnings,
}
@@ -339,7 +384,15 @@ async def sentiment_analyzer_handler(params: Dict[str, Any]) -> Any:
text = params.get("text", "")
normalized_text = text.lower()
positive_terms = {"thank", "thanks", "great", "good", "excellent", "감사", "좋"}
- negative_terms = {"disappointed", "urgent", "issue", "problem", "bad", "불만", "문제"}
+ negative_terms = {
+ "disappointed",
+ "urgent",
+ "issue",
+ "problem",
+ "bad",
+ "불만",
+ "문제",
+ }
positive_hits = [term for term in positive_terms if term in normalized_text]
negative_hits = [term for term in negative_terms if term in normalized_text]
if negative_hits and len(negative_hits) >= len(positive_hits):
@@ -533,6 +586,7 @@ def _parameter_matches_type(value: Any, expected_type: str) -> bool:
tone_analyzer_handler,
)
+
async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]:
text = params.get("text", "")
char_count = len(text)
@@ -545,6 +599,7 @@ async def text_analyzer_handler(params: Dict[str, Any]) -> Dict[str, int]:
"word_count": len(text.split()),
}
+
registry.register(
ToolInfo(
code="text_analyzer",
@@ -821,6 +876,77 @@ async def meeting_agenda_generator_handler(params: Dict[str, Any]) -> Any:
)
+async def uuid_generator_handler(params: Dict[str, Any]) -> Any:
+ """
+ Generates a UUID based on the specified version.
+ Supports UUIDv4 (random) and UUIDv1 (timestamp-based).
+ For UUIDv1, the node (MAC address) is randomized to ensure privacy.
+ """
+ version = params.get("version", 4)
+ if version == 1:
+ random_multicast_node = secrets.randbits(48) | (1 << 40)
+ return {
+ "uuid": str(uuid.uuid1(node=random_multicast_node)) # nosemgrep
+ }
+ if version == 4:
+ return {"uuid": str(uuid.uuid4())}
+ raise ToolOptionError(
+ "unsupported_uuid_version",
+ f"Unsupported UUID version: {version}",
+ )
+
+
+async def hash_generator_handler(params: Dict[str, Any]) -> Any:
+ """
+ Generates a hash for the provided text using the specified algorithm.
+ Supported algorithms: MD5, SHA1, SHA256, SHA512.
+ Note: MD5 and SHA1 are included for interoperability purposes only and should not be used for security.
+ """
+ text = params.get("text", "")
+ algorithm = params.get("algorithm", "sha256").lower()
+
+ if algorithm == "sha256":
+ hash_obj = hashlib.sha256(text.encode("utf-8"))
+ elif algorithm == "md5":
+ hash_obj = hashlib.md5(text.encode("utf-8"), usedforsecurity=False)
+ elif algorithm == "sha1":
+ # fmt: off
+ hash_obj = hashlib.sha1(text.encode("utf-8"), usedforsecurity=False) # nosemgrep: python.lang.security.insecure-hash-algorithms.insecure-hash-algorithm-sha1 -- interoperability-only digest
+ # fmt: on
+ elif algorithm == "sha512":
+ hash_obj = hashlib.sha512(text.encode("utf-8"))
+ else:
+ raise ToolOptionError(
+ "unsupported_hash_algorithm",
+ f"Unsupported hash algorithm: {algorithm}",
+ )
+
+ return {"hash": hash_obj.hexdigest()}
+
+
+registry.register(
+ ToolInfo(
+ code="uuid_generator",
+ name="UUID 생성기",
+ description="지정된 버전(1 또는 4)의 UUID를 생성합니다.",
+ category="유틸리티",
+ parameters={"version": "integer"},
+ ),
+ uuid_generator_handler,
+)
+
+registry.register(
+ ToolInfo(
+ code="hash_generator",
+ name="해시 생성기",
+ description="입력된 텍스트에 대해 지정된 알고리즘(MD5, SHA1, SHA256, SHA512)으로 해시 값을 생성합니다.",
+ category="유틸리티",
+ parameters={"text": "string", "algorithm": "string"},
+ ),
+ hash_generator_handler,
+)
+
+
@router.get("/tools", response_model=list[ToolInfo])
def get_tools() -> list[ToolInfo]:
"""
@@ -911,7 +1037,11 @@ def delete_tool(code: str) -> None:
registry.unregister(code)
-@router.post("/tools/{code}/execute", response_model=ExecuteResponse)
+@router.post(
+ "/tools/{code}/execute",
+ response_model=ExecuteResponse,
+ response_model_exclude_none=True,
+)
async def execute_tool(code: str, request: ExecuteRequest) -> ExecuteResponse:
"""
특정 도구를 실행합니다.
@@ -940,4 +1070,5 @@ async def execute_tool(code: str, request: ExecuteRequest) -> ExecuteResponse:
status="failed",
result=None,
message=_safe_tool_failure_message(e),
+ error_code=getattr(e, "error_code", None),
)
diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py
index ae5c0a396..e4b9a1ca7 100644
--- a/backend/tests/test_tools_api.py
+++ b/backend/tests/test_tools_api.py
@@ -5,6 +5,7 @@
import os
import secrets
import time
+import uuid
from unittest.mock import AsyncMock, patch
import httpx
@@ -14,10 +15,12 @@
os.environ.setdefault("AUTH_SESSION_HMAC_SECRET", secrets.token_urlsafe(48))
from api.tools import (
+ ExecuteResponse,
MAX_TOOL_FAILURE_MESSAGE_CHARS,
ExecuteRequest,
ToolInfo,
ToolRegistry,
+ ToolValidationError,
_parameter_type_name,
_safe_tool_failure_message,
execute_tool,
@@ -198,6 +201,11 @@ async def test_execute_tone_analyzer():
assert data["result"]["tone_score"] == 85
+def test_execute_response_result_is_optional_in_openapi():
+ schema = ExecuteResponse.model_json_schema()
+ assert "result" not in schema.get("required", [])
+
+
def test_execute_tool_rejects_unexpected_parameter():
with TestClient(app) as client:
response = client.post(
@@ -214,7 +222,8 @@ def test_execute_tool_rejects_unexpected_parameter():
assert response.status_code == 200
data = response.json()
assert data["status"] == "failed"
- assert data["result"] is None
+ assert "result" not in data
+ assert data["error_code"] == "unexpected_tool_parameter"
assert "Unexpected tool parameter" in data["message"]
@@ -229,7 +238,8 @@ def test_execute_tool_rejects_invalid_parameter_type():
assert response.status_code == 200
data = response.json()
assert data["status"] == "failed"
- assert data["result"] is None
+ assert "result" not in data
+ assert data["error_code"] == "invalid_tool_parameter_type"
assert "Invalid tool parameter type" in data["message"]
@@ -257,6 +267,8 @@ def test_execute_tool_rejects_missing_required_parameter():
assert response.status_code == 200
data = response.json()
assert data["status"] == "failed"
+ assert "result" not in data
+ assert data["error_code"] == "missing_tool_parameter"
assert "Missing required tool parameter" in data["message"]
@@ -284,9 +296,18 @@ def test_execute_tool_no_parameters_accepted():
assert response.status_code == 200
data = response.json()
assert data["status"] == "failed"
+ assert "result" not in data
+ assert data["error_code"] == "tool_parameters_not_supported"
assert "Tool does not accept parameters" in data["message"]
+def test_registry_validation_error_has_a_stable_code_for_non_objects():
+ with pytest.raises(ToolValidationError) as exc_info:
+ registry._validate_parameters("thread_summarizer", "not_a_dict")
+
+ assert exc_info.value.error_code == "invalid_tool_parameters"
+
+
def test_execute_tool_not_a_dict_parameter():
with TestClient(app) as client:
response = client.post(
@@ -362,7 +383,7 @@ async def error_handler(params):
assert response.status_code == 200
data = response.json()
assert data["status"] == "failed"
- assert data["result"] is None
+ assert "result" not in data
assert "Simulated error" in data["message"]
@@ -399,9 +420,10 @@ def error_handler(_params):
assert records[0].exception_type == "ValueError"
assert len(records[0].exception_traceback_fingerprint) == 12
int(records[0].exception_traceback_fingerprint, 16)
- assert records[0].tool_code_fingerprint == hashlib.sha256(
- hostile_code.encode("utf-8")
- ).hexdigest()[:12]
+ assert (
+ records[0].tool_code_fingerprint
+ == hashlib.sha256(hostile_code.encode("utf-8")).hexdigest()[:12]
+ )
assert response.message == r"failure\r\nforged_exception=true"
assert "\r" not in response.message
assert "\n" not in response.message
@@ -476,7 +498,7 @@ def test_validate_parameters_missing_required():
category="C",
parameters={"req1": "string"},
)
- with pytest.raises(ValueError, match="Missing required tool parameter"):
+ with pytest.raises(ToolValidationError, match="Missing required tool parameter"):
r._validate_parameters("req_params", {})
@@ -544,7 +566,7 @@ async def test_base64_decoder_tool_invalid_input():
assert response.status_code == 200
data = response.json()
assert data["status"] == "failed"
- assert data["result"] is None
+ assert "result" not in data
assert "Invalid Base64 string" in data["message"]
@@ -1247,8 +1269,146 @@ def test_execute_analysis_tool_rejects_oversized_text():
assert response.status_code == 200
assert response.json() == {
"status": "failed",
- "result": None,
"message": (
f"Analysis text must not exceed {ANALYSIS_TEXT_MAX_CHARS} characters"
),
}
+
+
+def test_uuid_generator_tool():
+ app.dependency_overrides.clear()
+ token = _signed_session_token()
+ with TestClient(app) as client:
+ # Test default (version 4)
+ response = client.post(
+ "/api/tools/uuid_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"version": 4}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ assert "uuid" in data["result"]
+ assert len(data["result"]["uuid"]) == 36
+ assert uuid.UUID(data["result"]["uuid"]).version == 4
+
+ # Test version 1
+ response = client.post(
+ "/api/tools/uuid_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"version": 1}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ assert "uuid" in data["result"]
+ assert len(data["result"]["uuid"]) == 36
+ parsed_uuid = uuid.UUID(data["result"]["uuid"])
+ assert parsed_uuid.version == 1
+ assert parsed_uuid.node & (1 << 40)
+
+ # Test invalid version
+ response = client.post(
+ "/api/tools/uuid_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"version": 3}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "failed"
+ assert data["error_code"] == "unsupported_uuid_version"
+
+
+def test_hash_generator_tool():
+ app.dependency_overrides.clear()
+ token = _signed_session_token()
+ with TestClient(app) as client:
+ # Test default (sha256)
+ response = client.post(
+ "/api/tools/hash_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"text": "hello", "algorithm": "sha256"}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ assert (
+ data["result"]["hash"]
+ == "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
+ )
+
+ # Test md5
+ response = client.post(
+ "/api/tools/hash_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"text": "hello", "algorithm": "md5"}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ assert data["result"]["hash"] == "5d41402abc4b2a76b9719d911017c592"
+
+ # Test sha1
+ response = client.post(
+ "/api/tools/hash_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"text": "hello", "algorithm": "sha1"}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ assert data["result"]["hash"] == "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"
+
+ # Test sha512
+ response = client.post(
+ "/api/tools/hash_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"text": "hello", "algorithm": "sha512"}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ assert (
+ data["result"]["hash"]
+ == "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"
+ )
+
+ # Test invalid algorithm
+ response = client.post(
+ "/api/tools/hash_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"text": "hello", "algorithm": "sha3"}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "failed"
+ assert data["error_code"] == "unsupported_hash_algorithm"
+
+ # Test invalid text type
+ response = client.post(
+ "/api/tools/hash_generator/execute",
+ headers={"Authorization": f"Bearer {token}"},
+ json={"parameters": {"text": 123}},
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "failed"
+ assert "result" not in data
+ assert data["error_code"] == "invalid_tool_parameter_type"
+ assert "Invalid tool parameter type" in data["message"]
+
+
+def test_uuid_generator_version_must_be_integer():
+ with TestClient(app) as client:
+ response = client.post(
+ "/api/tools/uuid_generator/execute",
+ headers={"Authorization": f"Bearer {_signed_session_token()}"},
+ json={"parameters": {"version": 1.0}},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "failed"
+ assert "result" not in data
+ assert data["error_code"] == "invalid_tool_parameter_type"
+ assert "Invalid tool parameter type" in data["message"]
diff --git a/frontend/src/components/ProjectsLayout.accessibility.test.tsx b/frontend/src/components/ProjectsLayout.accessibility.test.tsx
deleted file mode 100644
index 9bb58aebe..000000000
--- a/frontend/src/components/ProjectsLayout.accessibility.test.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-/* @vitest-environment jsdom */
-import React, { act } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, describe, expect, it, vi } from "vitest";
-
-const apiClientMock = vi.hoisted(() => ({
- get: vi.fn(),
- post: vi.fn(),
- getServerSessionClaims: vi.fn(),
-}));
-
-vi.mock("@/lib/api-client", () => ({ apiClient: apiClientMock }));
-
-vi.mock("lucide-react", () => ({
- CalendarDays: () => ,
- CheckCircle2: () => ,
- Clock: () => ,
- FileText: () => ,
- FolderOpen: () => ,
- GitBranch: () => ,
- ListChecks: () => ,
- Network: () => ,
- Search: () => ,
- User: () => ,
-}));
-
-import { ProjectsLayout } from "./ProjectsLayout";
-
-const candidate = {
- candidate_uid: "project_candidate:alpha",
- project_uid: "project_candidate:alpha",
- title: "Project: Alpha Checkout",
- status_code: "needs_review",
- score: 0.87,
- object_count: 1,
- requirement_count: 1,
- issue_count: 0,
- milestone_count: 0,
- deliverable_count: 0,
- participant_count: 0,
- source_segment_count: 1,
- representative_object_uids: [],
- citation_bundle: [],
- updated_at: "2026-08-03T00:00:00Z",
-};
-
-async function flushAsyncWork() {
- await act(async () => {
- await Promise.resolve();
- await Promise.resolve();
- });
-}
-
-describe("ProjectsLayout accessibility", () => {
- let root: Root | null = null;
- let container: HTMLDivElement | null = null;
-
- afterEach(() => {
- if (root) act(() => root?.unmount());
- root = null;
- container?.remove();
- container = null;
- vi.clearAllMocks();
- });
-
- it("announces candidate confirmation as busy while the request is pending", async () => {
- let resolveConfirmation: ((value: typeof candidate) => void) | undefined;
- const pendingConfirmation = new Promise((resolve) => {
- resolveConfirmation = resolve;
- });
-
- apiClientMock.get.mockImplementation((path: string) => {
- if (path === "/api/webdav/folders") return Promise.resolve([]);
- if (path === "/api/tasks") return Promise.resolve([]);
- if (path === "/api/projects/candidates") {
- return Promise.resolve({ candidates: [candidate] });
- }
- if (path === "/api/projects/project_candidate%3Aalpha/traceability") {
- return Promise.resolve({
- project_uid: candidate.project_uid,
- candidate,
- objects: [],
- edges: [],
- });
- }
- return Promise.reject(new Error(`Unexpected GET path: ${path}`));
- });
- apiClientMock.getServerSessionClaims.mockResolvedValue({
- userId: "alice",
- organizationId: "org-acme",
- workspaceId: "workspace-org-acme",
- });
- apiClientMock.post.mockReturnValue(pendingConfirmation);
-
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
-
- await act(async () => {
- root?.render();
- });
- await flushAsyncWork();
- await flushAsyncWork();
-
- const confirmButton = Array.from(container.querySelectorAll("button")).find(
- (button) => button.textContent?.includes("프로젝트 후보 확정"),
- );
- expect(confirmButton).toBeDefined();
- expect(confirmButton?.disabled).toBe(false);
- expect(confirmButton?.getAttribute("aria-busy")).toBe("false");
-
- await act(async () => {
- confirmButton?.click();
- await Promise.resolve();
- });
-
- expect(confirmButton?.disabled).toBe(true);
- expect(confirmButton?.getAttribute("aria-busy")).toBe("true");
- expect(confirmButton?.textContent).toContain("확정 저장 중");
- expect(apiClientMock.post).toHaveBeenCalledWith(
- "/api/projects/candidates/project_candidate%3Aalpha/confirm",
- {},
- );
-
- await act(async () => {
- resolveConfirmation?.({ ...candidate, status_code: "confirmed" });
- await pendingConfirmation;
- });
-
- expect(confirmButton?.disabled).toBe(true);
- expect(confirmButton?.getAttribute("aria-busy")).toBe("false");
- expect(confirmButton?.textContent).toContain("프로젝트 후보 확정됨");
- });
-});
\ No newline at end of file
diff --git a/frontend/src/components/ProjectsLayout.tsx b/frontend/src/components/ProjectsLayout.tsx
index 2f750ffd9..20f1f72ee 100644
--- a/frontend/src/components/ProjectsLayout.tsx
+++ b/frontend/src/components/ProjectsLayout.tsx
@@ -681,7 +681,6 @@ export function ProjectsLayout() {
type="button"
onClick={handleConfirmCandidate}
disabled={confirmSubmitting || candidateConfirmed}
- aria-busy={confirmSubmitting}
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground"
>
{candidateConfirmed ? '프로젝트 후보 확정됨' : confirmSubmitting ? '확정 저장 중' : '프로젝트 후보 확정'}