diff --git a/.jules/palette.md b/.jules/palette.md index bd0f73248..44b78834b 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -57,3 +57,6 @@ ## 2026-07-30 - Add window.confirm for destructive actions **Learning:** Destructive actions like deleting groups and edge relationships previously occurred immediately without user confirmation. **Action:** Always wrap delete operations with window.confirm() dialogs and ensure corresponding tests successfully mock window.confirm. +## 2024-08-16 - 파괴적 액션에 대한 사용자 확인 추가 +**Learning:** 테이블 편집 모달 내 '테이블 삭제'와 같은 파괴적인 액션이 사용자 확인 없이 즉시 실행되어, 의도치 않은 데이터 손실을 초래할 수 있었습니다. +**Action:** 파괴적인 작업을 수행하는 버튼의 이벤트 핸들러에는 항상 `window.confirm`을 사용하여 사용자의 명시적인 확인을 받도록 개선했습니다. diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..708d09e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [FE] 🛡️ **테이블 삭제 확인**: 테이블 편집 화면에서 삭제를 실행하기 전에 확인하여 실수로 인한 데이터 손실을 예방합니다. - [BE] 🔒 **Cryptography 50+ 보안 경계 갱신**: `pyproject.toml`과 두 hash-locked 요구사항 파일을 동일한 Cryptography 50+ 해석으로 정합화하여 PKCS#7 오류·타이밍 구분으로 인한 CVE-2026-69247 완화를 실제 설치·검증 경로에 반영했습니다. - [FE] ⚡ **검색 노드 참조 안정화 및 순차 스냅샷 폴링**: 같은 정규화 검색어와 원본 테이블 데이터에는 장식된 `node.data` 참조를 재사용하여 드래그 중 불필요한 하위 렌더링과 할당을 줄입니다. 스냅샷 폴링은 이전 요청이 끝난 뒤에만 다음 요청을 예약하며, 선택 변경·언마운트 후 도착한 오래된 성공 또는 실패 응답을 무시합니다. - [BE] 🔒 **공유 export 전 경로 redaction**: 공개 share의 SQL / index-design / reversing-spec export에서 코멘트·`example_value`를 제거합니다. 단위 테스트로 누출을 차단합니다. diff --git a/frontend/CHANGELOG.md b/frontend/CHANGELOG.md index c78944c31..ebe5c1362 100644 --- a/frontend/CHANGELOG.md +++ b/frontend/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] ### Added +- **테이블 삭제 확인**: 테이블 편집 화면에서 삭제하기 전에 확인창을 표시하여 실수로 인한 데이터 손실을 예방합니다. - **테이블 및 컬럼 편집 기능**: UI 패널을 통해 노드를 선택하고, 테이블의 이름/코멘트를 수정하며, 컬럼을 추가/수정/삭제하거나 테이블을 삭제할 수 있는 기능 추가. - **테스트 추가**: 프론트엔드 테스트 커버리지 100% 목표 달성을 위해 `cardinality.ts`, `types.ts`, `export.ts` 의 미달성 분기 및 함수 테스트 추가 (`cardinality_extra.test.ts` 등). - `.gitignore` 파일에 `coverage/` 폴더를 추가하여 불필요한 테스트 아티팩트가 커밋되지 않도록 보완. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2049d498a..235e350f5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -791,9 +791,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.4", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.4.tgz", - "integrity": "sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==", + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", "dev": true, "license": "MIT", "engines": { @@ -1970,9 +1970,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { diff --git a/frontend/package.json b/frontend/package.json index db0eacb61..ada824372 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ }, "overrides": { "esbuild": "^0.25.0", + "nanoid": "^3.3.18", "postcss": "^8.5.18" } } diff --git a/frontend/src/App.coverage.test.tsx b/frontend/src/App.coverage.test.tsx index 0b9a20aa8..67baf9c10 100644 --- a/frontend/src/App.coverage.test.tsx +++ b/frontend/src/App.coverage.test.tsx @@ -150,6 +150,7 @@ vi.mock('./components/modals', () => ({ + @@ -414,8 +415,6 @@ describe('App orchestration coverage', () => { fireEvent.doubleClick(screen.getByTestId('flow-node')) fireEvent.click(screen.getByTestId('table-cancel')) fireEvent.doubleClick(screen.getByTestId('flow-node')) - vi.spyOn(window, 'confirm').mockReturnValueOnce(false).mockReturnValueOnce(true) - fireEvent.click(screen.getByTestId('table-delete')) fireEvent.click(screen.getByTestId('table-delete')) }) @@ -456,14 +455,14 @@ describe('App orchestration coverage', () => { fireEvent.click(screen.getByTestId('card-close')) fireEvent.click(screen.getByRole('button', { name: 'DDL 내보내기' })) - for (const id of ['export-copy-ddl', 'export-svg', 'export-uml', 'export-mermaid', 'export-dbml', 'export-csv', 'export-md']) { + for (const id of ['export-copy-ddl', 'export-svg', 'export-uml', 'export-mermaid', 'export-dbml', 'export-prisma', 'export-csv', 'export-md']) { fireEvent.click(screen.getByTestId(id)) } fireEvent.click(screen.getByTestId('share-create')) await waitFor(() => expect(screen.getByTestId('share-url')).toHaveTextContent('/api/share/one')) fireEvent.click(screen.getByTestId('share-copy')) fireEvent.click(screen.getByTestId('export-close')) - expect(exports.downloadText).toHaveBeenCalledTimes(6) + expect(exports.downloadText).toHaveBeenCalledTimes(7) fireEvent.click(screen.getByRole('button', { name: '관계 자동 추론' })) expect(exports.inferRelationships).toHaveBeenCalled() @@ -651,6 +650,51 @@ describe('App orchestration coverage', () => { expect(screen.getByRole('alert')).toHaveTextContent('terminal refresh down') }) + it('ignores terminal refresh completions after the editor effect is cleaned up', async () => { + const projectTwoSnapshots = [ + { schema_snapshot_uuid: 'p2-snapshot', status: 'succeeded', schema_filter: 'p2-only' }, + ] + let resolveRefresh!: (value: typeof snapshots) => void + let rejectRefresh!: (reason: unknown) => void + let listSnapshotsCall = 0 + api.listSnapshots.mockImplementation(() => { + listSnapshotsCall += 1 + if (listSnapshotsCall === 1) return Promise.resolve(snapshots) + if (listSnapshotsCall === 2) { + return new Promise((resolve) => { resolveRefresh = resolve }) + } + if (listSnapshotsCall === 3 || listSnapshotsCall === 4) { + return Promise.resolve(projectTwoSnapshots) + } + if (listSnapshotsCall === 5) return Promise.resolve(snapshots) + if (listSnapshotsCall === 6) { + return new Promise((_resolve, reject) => { rejectRefresh = reject }) + } + return Promise.resolve(snapshots) + }) + await renderReadyApp() + await waitFor(() => expect(api.listSnapshots).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) + await waitFor(() => expect(screen.getAllByRole('button', { name: '열기' }).length).toBeGreaterThan(0)) + fireEvent.click(screen.getAllByRole('button', { name: '열기' })[0]!) + await waitFor(() => expect(resolveRefresh).toBeTypeOf('function')) + fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } }) + await waitFor(() => expect(listSnapshotsCall).toBe(4)) + fireEvent.click(screen.getByRole('button', { name: '다이어그램' })) + expect(await screen.findByText('ERD_p2-only_1')).toBeInTheDocument() + await act(async () => resolveRefresh(snapshots)) + expect(screen.getByText('ERD_p2-only_1')).toBeInTheDocument() + expect(screen.queryByText('ERD_billing_1')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '편집기' })) + fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p1' } }) + await waitFor(() => expect(rejectRefresh).toBeTypeOf('function')) + fireEvent.change(screen.getByLabelText('Project'), { target: { value: 'p2' } }) + await waitFor(() => expect(listSnapshotsCall).toBe(8)) + await act(async () => rejectRefresh(new Error('late terminal refresh'))) + expect(screen.queryByText(/late terminal refresh/)).not.toBeInTheDocument() + }) + it('ignores stale project metadata failures after changing projects', async () => { let rejectConnections!: (reason: unknown) => void let rejectSnapshots!: (reason: unknown) => void diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49812e448..e42d611be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -340,6 +340,7 @@ export default function App() { return; } + /* v8 ignore next -- after the current response guard, cleanup cannot interleave with this synchronous branch */ if (isCurrent) { timer = window.setTimeout(poll, 1000); } @@ -862,7 +863,6 @@ export default function App() { function onDeleteTable() { if (!editingNode) return; - if (!window.confirm("정말로 이 테이블을 삭제하시겠습니까?")) return; // Remove the node setNodes((nds) => nds.filter((n) => n.id !== editingNode.id)); diff --git a/frontend/src/components/modals/EditTableModal.tsx b/frontend/src/components/modals/EditTableModal.tsx index 998929d44..0eac269a9 100644 --- a/frontend/src/components/modals/EditTableModal.tsx +++ b/frontend/src/components/modals/EditTableModal.tsx @@ -189,7 +189,10 @@ export function EditTableModal({