diff --git a/backend/README.md b/backend/README.md index f3a3fdd..b194816 100644 --- a/backend/README.md +++ b/backend/README.md @@ -224,6 +224,7 @@ See also [`docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation. | POST | `/api/scan` | run a live Puppeteer + axe-core scan | | GET | `/api/scan-results?url=` | re-run a scan for a URL (used by deep links) | | GET | `/api/scans` | list saved scans from the user's store | +| DELETE | `/api/scans` | delete every saved report (keep account store) | | GET | `/api/scans/:id` | load one saved report from the user's store | | DELETE | `/api/scans/:id` | delete one saved report from the user's store | | GET | `/api/problems/:id` | look up a single problem (legacy mock lookup) | diff --git a/backend/controllers/scanController.js b/backend/controllers/scanController.js index 34cd434..8528273 100644 --- a/backend/controllers/scanController.js +++ b/backend/controllers/scanController.js @@ -31,6 +31,7 @@ class ScanController { this.getSavedScan = this.getSavedScan.bind(this); this.getSavedScans = this.getSavedScans.bind(this); this.deleteSavedScan = this.deleteSavedScan.bind(this); + this.deleteAllSavedScans = this.deleteAllSavedScans.bind(this); this.getProblem = this.getProblem.bind(this); } @@ -174,6 +175,61 @@ class ScanController { } } + /** + * DELETE /api/scans — remove every saved report from attached storage. + * Keeps the account manifest and repository; only clears scan files + caches. + */ + async deleteAllSavedScans(req, res) { + if ( + typeof req.isAuthenticated !== 'function' || + !req.isAuthenticated() || + !req.user?.storage + ) { + return res.status(401).json({ error: 'Not authenticated' }); + } + if (!this.authService || !this.storageService) { + return res.status(503).json({ error: 'Storage is not configured' }); + } + + try { + const clients = await this.authService.clientsFor(req.user, { + storageRef: req.user.storage, + }); + const result = await this.storageService.deleteAllScans(req.user, clients); + + if (!req.user.account) { + req.user.account = { + settings: { autoDelete90d: true }, + scanCount: 0, + }; + } + req.user.account.scanCount = result.scanCount; + await this.authService.persistUser(req); + + return res.json({ + deletedCount: result.deletedCount, + scanCount: result.scanCount, + scans: result.scans, + }); + } catch (err) { + if (err.code === 'PROVIDER_NOT_AVAILABLE' || err.status === 501) { + return res.status(501).json({ + error: err.message, + code: err.code, + }); + } + if ( + err.code === 'STORAGE_ACCESS_DENIED' || + err.code === 'STORAGE_IDENTITY_MISMATCH' || + err.status === 403 + ) { + return res.status(403).json({ error: err.message }); + } + console.error(err); + return res.status(500).json({ error: 'Internal server error' }); + } + } + /** * DELETE /api/scans/:id — remove one saved report from attached storage. */ diff --git a/backend/routes/scan.js b/backend/routes/scan.js index dd15992..c0dc26f 100644 --- a/backend/routes/scan.js +++ b/backend/routes/scan.js @@ -3,6 +3,7 @@ * - POST /api/scan * - GET /api/scan-results * - GET /api/scans + * - DELETE /api/scans * - GET /api/scans/:id * - DELETE /api/scans/:id * @@ -21,6 +22,8 @@ function makeScanRouter(controller) { router.post('/scan', controller.postScan); router.get('/scan-results', controller.getScanResults); router.get('/scans', controller.getSavedScans); + // Bulk delete must be registered before /scans/:id. + router.delete('/scans', controller.deleteAllSavedScans); router.get('/scans/:id', controller.getSavedScan); router.delete('/scans/:id', controller.deleteSavedScan); return router; diff --git a/backend/services/storageService.js b/backend/services/storageService.js index ee46cc0..c19560f 100644 --- a/backend/services/storageService.js +++ b/backend/services/storageService.js @@ -955,6 +955,132 @@ class StorageService { }; } + /** + * Delete every immutable saved scan and reset index/manifest caches. + * Leaves vizably.json identity and the repository itself intact. + * @param {object} account session user (with storage binding) + * @param {StorageClients} clients + * @returns {Promise<{ deletedCount: number, scanCount: number, scans: object[] }>} + */ + async deleteAllScans(account, clients) { + if (account?.storage?.provider === 'google') { + const err = new Error(GOOGLE_NOT_AVAILABLE); + err.status = 501; + err.code = 'PROVIDER_NOT_AVAILABLE'; + throw err; + } + if (!clients.githubClient) { + throw new Error('GitHub client is required to delete saved scans'); + } + + const maxAttempts = 3; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + try { + return await this._deleteAllScansOnce(account, clients); + } catch (err) { + const canRetry = this._isRefConflict(err) && attempt < maxAttempts - 1; + if (!canRetry) { + throw err; + } + } + } + + throw new Error('GitHub write failed after retries'); + } + + /** + * @param {object} account + * @param {StorageClients} clients + * @private + */ + async _deleteAllScansOnce(account, clients) { + const storageRef = account.storageRef ?? account.storage; + const { owner, repo } = this._parseGitHubRef(storageRef); + const octokit = clients.githubClient; + const branch = await this._resolveGitHubBranch( + octokit, + owner, + repo, + storageRef.branch, + ); + + const scanEntries = await this._listGitHubDirectory( + octokit, + owner, + repo, + SCANS_DIR, + branch, + ); + const scanFiles = scanEntries.filter( + (entry) => + entry.type === 'file' && + entry.name.endsWith('.json') && + entry.name !== 'index.json', + ); + + const manifestFile = await this._readAccountManifest(octokit, owner, repo, branch); + if (!manifestFile) { + throw new Error('Account manifest not found'); + } + + const { manifest } = this._normalizeManifestBrand( + this._parseJson(manifestFile.content, 'manifest'), + ); + const emptyIndex = { schemaVersion: 1, scans: [] }; + const updatedManifest = this._updateManifestSummary(manifest, emptyIndex, null); + updatedManifest.summary.lastScanAt = null; + updatedManifest.account.updatedAt = new Date().toISOString(); + + const indexFile = await this._readGitHubFile(octokit, owner, repo, INDEX_PATH, branch); + + /** @type {Array<{ path: string, delete?: boolean, sha?: string, content?: string }>} */ + const files = scanFiles.map((entry) => ({ + path: `${SCANS_DIR}/${entry.name}`, + delete: true, + sha: entry.sha, + })); + + files.push({ + path: INDEX_PATH, + content: JSON.stringify(emptyIndex, null, 2) + '\n', + sha: indexFile?.sha, + }); + files.push({ + path: MANIFEST_PATH, + content: JSON.stringify(updatedManifest, null, 2) + '\n', + ...(manifestFile.path === MANIFEST_PATH ? { sha: manifestFile.sha } : {}), + }); + + // Idempotent when already empty — still refresh caches if they disagree. + if (scanFiles.length === 0 && indexFile) { + const currentIndex = this._parseJson(indexFile.content, 'index'); + if ( + Array.isArray(currentIndex?.scans) && + currentIndex.scans.length === 0 && + (manifest.summary?.scanCount ?? 0) === 0 + ) { + return { deletedCount: 0, scanCount: 0, scans: [] }; + } + } + + await this._writeGitHubFiles( + octokit, + owner, + repo, + branch, + files, + scanFiles.length === 0 + ? 'Reset accessibility scan caches' + : `Delete all accessibility scans (${scanFiles.length})`, + ); + + return { + deletedCount: scanFiles.length, + scanCount: 0, + scans: [], + }; + } + /** * One save attempt: reconcile from scan-file truth, append prepared scan, write. * @param {object} account diff --git a/backend/tests/scan.test.js b/backend/tests/scan.test.js index bd31db3..aa2eb0a 100644 --- a/backend/tests/scan.test.js +++ b/backend/tests/scan.test.js @@ -508,3 +508,59 @@ test('deleteSavedScan returns 404 for SCAN_NOT_FOUND', async () => { ); assert.equal(out.statusCode, 404); }); + +test('deleteAllSavedScans clears every scan and updates session scanCount', async () => { + const ScanController = require('../controllers/scanController'); + let persisted = false; + const ctrl = new ScanController({ + mockScanResults, + scanRunner: mockScanRunner, + authService: { + clientsFor: async () => ({ githubClient: {} }), + persistUser: async () => { + persisted = true; + }, + }, + storageService: { + deleteAllScans: async () => ({ + deletedCount: 2, + scanCount: 0, + scans: [], + }), + }, + }); + + const req = { + isAuthenticated: () => true, + user: { + storage: { id: 'R_kg', full_name: 'sam/repo' }, + account: { scanCount: 2 }, + }, + }; + const out = mockRes(); + await ctrl.deleteAllSavedScans(req, out.res); + + assert.equal(out.statusCode, 200); + assert.deepEqual(out.body, { deletedCount: 2, scanCount: 0, scans: [] }); + assert.equal(req.user.account.scanCount, 0); + assert.equal(persisted, true); +}); + +test('deleteAllSavedScans requires auth and attached storage', async () => { + const ScanController = require('../controllers/scanController'); + const ctrl = new ScanController({ + mockScanResults, + scanRunner: mockScanRunner, + authService: {}, + storageService: {}, + }); + const out = mockRes(); + await ctrl.deleteAllSavedScans( + { + isAuthenticated: () => false, + user: null, + }, + out.res, + ); + assert.equal(out.statusCode, 401); +}); diff --git a/backend/tests/storageService.test.js b/backend/tests/storageService.test.js index 883b313..0f67ae6 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -1332,6 +1332,121 @@ test('deleteScanById removes one scan file and leaves the others', async () => { assert.equal(updatedManifest.summary.scanCount, 1); }); +test('deleteAllScans removes every scan file and resets caches', async () => { + const storageService = new StorageService(); + const account = { + storage: { ...STORAGE_REF, provider: 'github', branch: 'main' }, + }; + const client = createMockGitHubClient({ + files: { + 'vizably.json': { + content: JSON.stringify( + manifest({ summary: { scanCount: 2, lastScanAt: '2026-07-11T12:00:00Z' } }), + ), + sha: 'sha-manifest', + }, + 'scans/index.json': { + content: JSON.stringify({ + schemaVersion: 1, + scans: [ + { + id: 'a', + url: 'https://a.example', + host: 'a.example', + scannedAt: '2026-07-11T12:00:00Z', + file: 'scans/a_a.example.json', + }, + { + id: 'b', + url: 'https://b.example', + host: 'b.example', + scannedAt: '2026-07-10T12:00:00Z', + file: 'scans/b_b.example.json', + }, + ], + }), + sha: 'sha-index', + }, + 'scans/a_a.example.json': { + content: JSON.stringify({ + id: 'a', + url: 'https://a.example', + scannedAt: '2026-07-11T12:00:00Z', + result: { problems: {} }, + }), + sha: 'sha-a', + }, + 'scans/b_b.example.json': { + content: JSON.stringify({ + id: 'b', + url: 'https://b.example', + scannedAt: '2026-07-10T12:00:00Z', + result: { problems: {} }, + }), + sha: 'sha-b', + }, + 'README.md': { content: '# keep\n', sha: 'sha-readme' }, + }, + }); + + const result = await storageService.deleteAllScans(account, { + githubClient: client, + }); + + assert.equal(result.deletedCount, 2); + assert.equal(result.scanCount, 0); + assert.deepEqual(result.scans, []); + assert.equal(client.files['scans/a_a.example.json'], undefined); + assert.equal(client.files['scans/b_b.example.json'], undefined); + assert.ok(client.files['scans/index.json']); + assert.ok(client.files['vizably.json']); + assert.equal(client.files['README.md'].content, '# keep\n'); + + const index = JSON.parse(client.files['scans/index.json'].content); + assert.deepEqual(index.scans, []); + + const updatedManifest = JSON.parse(client.files['vizably.json'].content); + assert.equal(updatedManifest.summary.scanCount, 0); + assert.equal(updatedManifest.summary.lastScanAt, null); + assert.ok(updatedManifest.account.id); +}); + +test('deleteAllScans is idempotent when already empty', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { + content: JSON.stringify(manifest({ summary: { scanCount: 0, lastScanAt: null } })), + sha: 'sha-manifest', + }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + }, + }); + + const result = await storageService.deleteAllScans( + { storage: { ...STORAGE_REF, provider: 'github', branch: 'main' } }, + { githubClient: client }, + ); + + assert.equal(result.deletedCount, 0); + assert.equal(result.scanCount, 0); +}); + +test('deleteAllScans stubs google until Phase 3', async () => { + const storageService = new StorageService(); + await assert.rejects( + () => + storageService.deleteAllScans( + { storage: { provider: 'google', id: 'folder' } }, + {}, + ), + (err) => err.code === 'PROVIDER_NOT_AVAILABLE' && err.status === 501, + ); +}); + test('deleteScanById returns not found for unknown id', async () => { const storageService = new StorageService(); const client = createMockGitHubClient({ diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md index 4e700c9..910e013 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -129,6 +129,7 @@ Provider-neutral routes; Google OAuth endpoints stubbed until Phase 3. - [x] In `postScan`: if authenticated and `req.user.storage`, build clients and `saveScanResults(...)` — a storage failure logs a warning, **never** fails the scan - [x] `DELETE /api/scans/:id` — `deleteSavedScan` → `storageService.deleteScanById` +- [x] `DELETE /api/scans` — `deleteAllSavedScans` → `storageService.deleteAllScans` (#110) (see [`scanDeletion.md`](./scanDeletion.md)) ### Phase 1 follow-ups (post-merge) @@ -155,6 +156,7 @@ Provider-neutral API shape; **GitHub picker wired**, Google deferred to Phase 3. - [x] `setupStorage(provider, storageRef, action)` → `POST /api/auth/storage` - [x] Keep `runScan`, `getScanResults`, `getProblem` - [x] `deleteScan(id)` → `DELETE /api/scans/:id` +- [x] `deleteAllScans()` → `DELETE /api/scans` ### ConnectView (`frontend/src/views/ConnectView.jsx`) — the picker diff --git a/docs/guides/auth_storage_guide/accountStorageContract.md b/docs/guides/auth_storage_guide/accountStorageContract.md index 21d5493..f356271 100644 --- a/docs/guides/auth_storage_guide/accountStorageContract.md +++ b/docs/guides/auth_storage_guide/accountStorageContract.md @@ -194,6 +194,11 @@ summary) must be atomic or partial-write tolerant: ### Deleting a scan `DELETE /api/scans/:id` removes one immutable file `scans/_.json` +and refreshes `scans/index.json` + `vizably.json` summary caches. + +`DELETE /api/scans` removes **all** immutable scan files under `scans/` (except +`index.json`), then writes an empty index and `summary.scanCount: 0`. The +account manifest identity and repository remain. and refreshes rebuildable caches (`scans/index.json` + manifest `summary`). Other scan files and account identity stay. GitHub history may still contain the deleted blob unless history is rewritten — disclose that in the UI if you diff --git a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md index 7b07da6..77b10a3 100644 --- a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md +++ b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md @@ -359,6 +359,7 @@ Session cookies, not Bearer tokens. All calls use `credentials: 'include'`; no - `listScans()` → `GET /api/scans` - `getSavedScan(id)` → `GET /api/scans/:id` - `deleteScan(id)` → `DELETE /api/scans/:id` +- `deleteAllScans()` → `DELETE /api/scans` - `runScan`, `getScanResults`, `getProblem` unchanged. For Google, selection is done with the **Google Picker** client library; the diff --git a/docs/guides/auth_storage_guide/scanDeletion.md b/docs/guides/auth_storage_guide/scanDeletion.md index df7a4ca..9a236dc 100644 --- a/docs/guides/auth_storage_guide/scanDeletion.md +++ b/docs/guides/auth_storage_guide/scanDeletion.md @@ -30,7 +30,8 @@ Vizably has **no scan database**. Saved reports live in storage the user owns **Not in scope for #112** - Whole-account wipe / optional repo delete (that’s [#82](https://github.com/codrlabs/vizably/issues/82) / account deletion). -- Bulk “delete all” on Account settings (still a later phase). +- Bulk “delete all” on Account settings — that’s [#110](https://github.com/codrlabs/vizably/issues/110) + (`DELETE /api/scans`). - Rewriting Git history — after delete, GitHub history may still contain the blob; disclose that lightly in UI copy if you mention permanence. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b8e9a1a..48dc310 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -322,6 +322,20 @@ function AppRoutes() { } } + /** Remove every saved scan from attached storage; keep the account connected. */ + const deleteAllSaved = async () => { + const result = await apiClient.deleteAllScans() + setUser((prev) => mergeAccountUpdate(prev, { + scanCount: result.scanCount, + scans: result.scans, + })) + if (location.search.includes('scanId=')) { + setScan(null) + setProblem(null) + } + return result + } + const auth = (p) => { if (p === 'google') return apiClient.githubLogin() @@ -425,6 +439,7 @@ function AppRoutes() { { fireEvent.click(screen.getByRole('button', { name: /yes, sign out/i })) expect(onSignOut).toHaveBeenCalledTimes(2) }) + + it('asks for confirm before delete-all and calls onDeleteAllScans', async () => { + const onDeleteAllScans = vi.fn().mockResolvedValue({ + deletedCount: 3, + scanCount: 0, + scans: [], + }) + + const { rerender } = render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) + expect(screen.getByText(/delete all 3 saved scans/i)).toBeInTheDocument() + expect(onDeleteAllScans).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: /yes, delete all/i })) + await waitFor(() => expect(onDeleteAllScans).toHaveBeenCalledTimes(1)) + + rerender( + , + ) + + expect(screen.getByRole('button', { name: /^cleared$/i })).toBeDisabled() + }) + + it('keeps scans when confirm is cancelled', () => { + const onDeleteAllScans = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) + fireEvent.click(screen.getByRole('button', { name: /keep scans/i })) + expect(screen.getByRole('button', { name: /^delete all$/i })).toBeInTheDocument() + expect(onDeleteAllScans).not.toHaveBeenCalled() + }) + + it('shows delete-all errors without clearing', async () => { + const onDeleteAllScans = vi.fn().mockRejectedValue(new Error('GitHub refused the wipe')) + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /^delete all$/i })) + fireEvent.click(screen.getByRole('button', { name: /yes, delete all/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/refused the wipe/i) + expect(screen.getByText(/saved scans · 3/i)).toBeInTheDocument() + }) }) diff --git a/frontend/src/__tests__/apiClient.test.js b/frontend/src/__tests__/apiClient.test.js index 7a05ebf..0e0731b 100644 --- a/frontend/src/__tests__/apiClient.test.js +++ b/frontend/src/__tests__/apiClient.test.js @@ -241,4 +241,21 @@ describe('ApiClient', () => { expect(result.scanCount).toBe(1) expect(result.scans).toEqual([{ id: 'scan-2' }]) }) + + it('deletes all saved scans', async () => { + const fetchImpl = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ deletedCount: 2, scanCount: 0, scans: [] }), + }) + + const client = new ApiClient({ fetchImpl }) + const result = await client.deleteAllScans() + + expect(fetchImpl).toHaveBeenCalledWith( + '/api/scans', + expect.objectContaining({ method: 'DELETE' }), + ) + expect(result).toEqual({ deletedCount: 2, scanCount: 0, scans: [] }) + }) }) diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index ba8756e..cedf472 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -221,6 +221,16 @@ export class ApiClient { }) } + /** + * Delete every saved scan from attached storage (keeps the account store). + * @returns {Promise<{ deletedCount: number, scanCount: number, scans: object[] }>} + */ + deleteAllScans() { + return this._request('/api/scans', { + method: 'DELETE', + }) + } + /** * Look up a single problem by id. * @param {string} id diff --git a/frontend/src/views/AccountView.jsx b/frontend/src/views/AccountView.jsx index 7ec93a6..c814f8d 100644 --- a/frontend/src/views/AccountView.jsx +++ b/frontend/src/views/AccountView.jsx @@ -2,15 +2,34 @@ import { useState } from 'react' import { Button, Card, Input } from '../design-system' import { Ico, GoogleMark } from '../lib/icons' import { PROVIDERS } from '../data/placeholders' +import { apiClient } from '../lib/apiClient' /** * Account settings — profile + data/storage controls + delete account. * Deliberately framed around using LESS storage, not more. + * + * @param {object} props + * @param {() => void | Promise} props.onSignOut + * @param {(result: { scanCount: number, scans: object[] }) => void | Promise} [props.onDeleteAllScans] + * @param {object} props.user + * @param {object} [props.shellUser] + * @param {'github' | 'google'} props.provider + * @param {import('../lib/apiClient').ApiClient} [props.client] */ -export default function AccountView({ onSignOut, user, shellUser, provider }) { +export default function AccountView({ + onSignOut, + onDeleteAllScans, + user, + shellUser, + provider, + client = apiClient, +}) { const pv = PROVIDERS[provider] || PROVIDERS.github const [autoDelete, setAutoDelete] = useState(user?.account?.settings?.autoDelete90d ?? true) const [confirmDelete, setConfirmDelete] = useState(false) + /** @type {'idle' | 'confirm' | 'busy'} */ + const [deleteAllStep, setDeleteAllStep] = useState('idle') + const [deleteAllError, setDeleteAllError] = useState(null) const savedCount = user?.account?.scanCount ?? user?.account?.scans?.length ?? 0 const storageLabel = user?.storage?.full_name || pv.dest @@ -18,6 +37,7 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) { name: user?.displayName || user?.username || 'User', email: user?.email || '', } + const deletingAll = deleteAllStep === 'busy' const Section = ({ title, desc, children }) => (
@@ -48,6 +68,21 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) { ) + const handleDeleteAll = async () => { + setDeleteAllError(null) + setDeleteAllStep('busy') + try { + const result = onDeleteAllScans + ? await onDeleteAllScans() + : await client.deleteAllScans() + setDeleteAllStep('idle') + return result + } catch (err) { + setDeleteAllError(err?.message || 'Failed to delete saved scans') + setDeleteAllStep('confirm') + } + } + return (
@@ -83,11 +118,71 @@ export default function AccountView({ onSignOut, user, shellUser, provider }) {
- - + + {savedCount === 0 ? ( + + ) : deleteAllStep === 'idle' ? ( + + ) : null} + + {deleteAllStep !== 'idle' && savedCount > 0 && ( +
+

+ Delete all {savedCount} saved scan{savedCount === 1 ? '' : 's'} from {storageLabel}? +

+

+ Removes every report under scans/. Your Vizably account and repository stay. This can’t be undone. +

+ {deleteAllError && ( +
+ {deleteAllError} +
+ )} +
+ + +
+
+ )} +
setAutoDelete((v) => !v)} />