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 the saved reports in body `{ ids }` |
| 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
64 changes: 64 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.deleteSavedScans = this.deleteSavedScans.bind(this);
this.getProblem = this.getProblem.bind(this);
}

Expand Down Expand Up @@ -174,6 +175,69 @@ class ScanController {
}
}

/**
* DELETE /api/scans — remove the saved reports named in `{ ids }`.
* Only those ids are deleted, so scans saved from another device after the
* client loaded its list are kept. Keeps the account manifest and repository.
*/
async deleteSavedScans(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.deleteScans(
req.user,
req.body?.ids,
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 === 'SCAN_IDS_REQUIRED') {
return res.status(400).json({ error: err.message });
}
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 (body: { ids })
* - 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.deleteSavedScans);
router.get('/scans/:id', controller.getSavedScan);
router.delete('/scans/:id', controller.deleteSavedScan);
return router;
Expand Down
139 changes: 139 additions & 0 deletions backend/services/storageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,145 @@ class StorageService {
};
}

/**
* Delete the given saved scans in one commit and refresh index/manifest caches.
* Only the listed ids are touched — scans saved elsewhere since the caller
* loaded its list survive. Ids with no file are treated as already deleted.
* Leaves vizably.json identity and the repository itself intact.
* @param {object} account session user (with storage binding)
* @param {string[]} scanIds
* @param {StorageClients} clients
* @returns {Promise<{ deletedCount: number, scanCount: number, scans: object[] }>}
*/
async deleteScans(account, scanIds, 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');
}
if (
!Array.isArray(scanIds) ||
scanIds.length === 0 ||
!scanIds.every((id) => typeof id === 'string' && id)
) {
const err = new Error('Scan ids are required');
err.status = 400;
err.code = 'SCAN_IDS_REQUIRED';
throw err;
}

const maxAttempts = 3;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
return await this._deleteScansOnce(account, new Set(scanIds), 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 {Set<string>} ids
* @param {StorageClients} clients
* @private
*/
async _deleteScansOnce(account, ids, 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 idOf = (name) => name.slice(0, name.indexOf('_'));
const scanFiles = scanEntries.filter(
(entry) =>
entry.type === 'file' &&
entry.name.endsWith('.json') &&
entry.name !== 'index.json' &&
ids.has(idOf(entry.name)),
);

const { index } = await this._reconcileGitHubIndex(octokit, owner, repo, branch);
index.scans = index.scans.filter((entry) => !ids.has(entry.id));

// Nothing left to remove (already deleted elsewhere) — report current state.
if (scanFiles.length === 0) {
return { deletedCount: 0, scanCount: index.scans.length, scans: index.scans };
}

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 updatedManifest = this._updateManifestSummary(
manifest,
index,
index.scans[0]?.scannedAt,
);
if (index.scans.length === 0) {
updatedManifest.summary.lastScanAt = null;
}
updatedManifest.account.updatedAt = new Date().toISOString();

const indexFile = await this._readGitHubFile(octokit, owner, repo, INDEX_PATH, branch);

await this._writeGitHubFiles(
octokit,
owner,
repo,
branch,
[
...scanFiles.map((entry) => ({
path: `${SCANS_DIR}/${entry.name}`,
delete: true,
sha: entry.sha,
})),
{
path: INDEX_PATH,
content: JSON.stringify(index, null, 2) + '\n',
sha: indexFile?.sha,
},
{
path: MANIFEST_PATH,
content: JSON.stringify(updatedManifest, null, 2) + '\n',
...(manifestFile.path === MANIFEST_PATH ? { sha: manifestFile.sha } : {}),
},
],
`Delete accessibility scans (${scanFiles.length})`,
);

return {
deletedCount: scanFiles.length,
scanCount: index.scans.length,
scans: index.scans,
};
}

/**
* One save attempt: reconcile from scan-file truth, append prepared scan, write.
* @param {object} account
Expand Down
78 changes: 78 additions & 0 deletions backend/tests/scan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -508,3 +508,81 @@ test('deleteSavedScan returns 404 for SCAN_NOT_FOUND', async () => {
);
assert.equal(out.statusCode, 404);
});

test('deleteSavedScans deletes the given ids 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: {
deleteScans: async (_user, ids) => {
assert.deepEqual(ids, ['a', 'b']);
return { deletedCount: 2, scanCount: 0, scans: [] };
},
},
});

const req = {
isAuthenticated: () => true,
user: {
storage: { id: 'R_kg', full_name: 'sam/repo' },
account: { scanCount: 2 },
},
body: { ids: ['a', 'b'] },
};
const out = mockRes();
await ctrl.deleteSavedScans(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('deleteSavedScans 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.deleteSavedScans(
{
isAuthenticated: () => false,
user: null,
},
out.res,
);
assert.equal(out.statusCode, 401);
});

test('deleteSavedScans rejects a request without ids', async () => {
const ScanController = require('../controllers/scanController');
const ctrl = new ScanController({
mockScanResults,
scanRunner: mockScanRunner,
authService: { clientsFor: async () => ({ githubClient: {} }) },
storageService: {
deleteScans: async () => {
const err = new Error('Scan ids are required');
err.code = 'SCAN_IDS_REQUIRED';
throw err;
},
},
});
const out = mockRes();
await ctrl.deleteSavedScans(
{ isAuthenticated: () => true, user: { storage: { id: 'R_kg' } }, body: {} },
out.res,
);
assert.equal(out.statusCode, 400);
});
Loading
Loading