Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
56 changes: 56 additions & 0 deletions backend/controllers/scanController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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.
*/
Expand Down
3 changes: 3 additions & 0 deletions backend/routes/scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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;
Expand Down
126 changes: 126 additions & 0 deletions backend/services/storageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions backend/tests/scan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading