From 32afb842a51c5edfc4fe01acd8ee56e9f0bc437a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 14:05:14 +0000 Subject: [PATCH] feat(attachments): let automated tests attach logs, HAR files, videos, traces and HTML Automated tests can now upload files with their results and show them on the execution: - POST /build/:buildId/attachment stores a file against the build while the test runs (the execution does not exist yet, the same reason screenshots hang off the build). The kind and the type it is served with come from the file extension, never the client's mime type: log/txt, json, har, webm/mp4, zip (trace or archive), html/htm and images. The size limit is ANGLES_ATTACHMENT_MAX_SIZE_MB (default 100). - Executions list the returned ids in `attachments`, or on a step's `attachments`, for POST /execution and PUT /build/:id/executions. Ids that were not uploaded against the execution's own build are dropped, and the rest are linked to the execution when it is saved. - GET /attachment?executionId= or ?buildId= lists them without server paths. - Files are served with nosniff and a sandbox CSP. HTML snapshots, traces and archives are always downloads, so a snapshot's scripts never run on the API origin. Range requests work, so videos can be seeked. - Deleting an attachment removes its references; deleting an execution or build (including the nightly clean-up) removes its files. Also documents the endpoints in swagger.json and docs/test-attachments.md, adds the attachment indexes to the Mongo init scripts, and exposes the size limit in docker-compose and the Kubernetes config. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KgQXSUjuLVXmLWxobnMfSf --- app/controllers/attachment.controller.js | 118 +++++- app/controllers/build.controller.js | 17 +- app/controllers/execution.controller.js | 8 +- app/models/attachment.js | 36 +- app/models/execution.js | 14 +- app/routes/attachment.routes.js | 20 +- app/routes/build.routes.js | 10 + app/routes/execution.routes.js | 6 + app/utils/attachment-utils.js | 127 ++++++ app/utils/build-utils.js | 3 +- app/utils/multer-config-test-attachments.js | 108 +++++ docs/test-attachments.md | 87 ++++ setup/docker-compose.yml | 3 + setup/kubernetes/02-angles-config.yaml | 5 + setup/mongo-init.js | 2 + swagger/swagger.json | 199 ++++++++- test/test-attachment.tests.js | 425 ++++++++++++++++++++ 17 files changed, 1165 insertions(+), 23 deletions(-) create mode 100644 app/utils/multer-config-test-attachments.js create mode 100644 docs/test-attachments.md create mode 100644 test/test-attachment.tests.js diff --git a/app/controllers/attachment.controller.js b/app/controllers/attachment.controller.js index 7e0695b..17bb7b3 100644 --- a/app/controllers/attachment.controller.js +++ b/app/controllers/attachment.controller.js @@ -3,9 +3,12 @@ const { validationResult } = require('express-validator'); const debug = require('debug'); const Attachment = require('../models/attachment.js'); +const Build = require('../models/build.js'); +const TestExecution = require('../models/execution.js'); const ManualTestCase = require('../models/manual-test-case.js'); const SharedStep = require('../models/shared-step.js'); const attachmentUtils = require('../utils/attachment-utils.js'); +const { describeFile } = require('../utils/multer-config-test-attachments.js'); const authMiddleware = require('../utils/auth-middleware.js'); const { NotFoundError, @@ -90,6 +93,61 @@ exports.create = (req, res) => { }); }; +/* +Stores a file an automated test uploaded against a build: a console log, HAR file, video, +Playwright trace, HTML snapshot or image. The test lists the returned id on the execution +(or a step) when it saves the execution, and the attachment is linked to it then. + */ +exports.createForBuild = (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(422).json({ errors: errors.array() }); + } + if (!req.file) { + return res.status(400).send({ message: 'A file is required in the "attachment" field.' }); + } + const { buildId } = req.params; + + return Build.findById(buildId).select('_id team').lean().exec() + .then(async (build) => { + if (!build) { + throw new NotFoundError(`No build found with id ${buildId}`); + } + if (!authMiddleware.hasTeamAccess(req.user, build.team)) { + throw new ForbiddenError('You do not have access to this build'); + } + // multer has already accepted the extension, so this always describes the file. + const { kind, mimeType } = describeFile(req.file.originalname); + const imageDetails = kind === 'image' + ? await attachmentUtils.generateThumbnail(req.file.path) : {}; + + const attachment = new Attachment({ + team: build.team, + scope: 'build', + build: build._id, + kind, + filename: req.file.filename, + originalName: path.basename(req.file.originalname || ''), + mimeType, + size: req.file.size, + path: req.file.path, + ...imageDetails, + uploadedBy: req.user ? req.user._id : undefined, + }); + const saved = await attachment.save(); + log(`Stored ${kind} attachment ${saved._id} for build ${build._id}`); + return Attachment.findById(saved._id).select(attachmentUtils.PUBLIC_FIELDS).lean().exec(); + }) + .then((saved) => res.status(201).send(saved)) + .catch(async (err) => { + if (req.file) { + await attachmentUtils.removeFiles({ path: req.file.path }); + await attachmentUtils.removeDirectoryIfEmpty(path.dirname(req.file.path)); + } + return handleError(err, res); + }); +}; + // Express only treats a middleware as an error handler when it declares four parameters, // so `next` must stay in the signature even though it is unused - without it multer's // rejections (bad mime type, missing owner id) fall through to the default handler and @@ -97,12 +155,55 @@ exports.create = (req, res) => { // eslint-disable-next-line no-unused-vars exports.createFail = (error, req, res, next) => res.status(400).send({ error: error.message }); +// Resolves the team behind a build-scoped listing: the build itself, or the build an +// automated execution belongs to. +const resolveBuildListing = async ({ executionId, buildId }) => { + if (executionId) { + const execution = await TestExecution.findById(executionId).select('_id build').lean().exec(); + if (!execution) { + throw new NotFoundError(`No execution found with id ${executionId}`); + } + const build = await Build.findById(execution.build).select('_id team').lean().exec(); + if (!build) { + throw new NotFoundError(`No build found for execution with id ${executionId}`); + } + return { query: { execution: execution._id, scope: 'build' }, team: build.team }; + } + const build = await Build.findById(buildId).select('_id team').lean().exec(); + if (!build) { + throw new NotFoundError(`No build found with id ${buildId}`); + } + return { query: { build: build._id, scope: 'build' }, team: build.team }; +}; + +const findBuildAttachments = (req, res) => { + const { executionId, buildId } = req.query; + return resolveBuildListing({ executionId, buildId }) + .then(({ query, team }) => { + if (!authMiddleware.hasTeamAccess(req.user, team)) { + throw new ForbiddenError('You do not have access to this build'); + } + return Attachment.find(query) + .select(attachmentUtils.PUBLIC_FIELDS) + .sort('createdAt') + .lean() + .exec(); + }) + .then((attachments) => res.status(200).send({ attachments })) + .catch((err) => handleError(err, res)); +}; + exports.findAll = (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(422).json({ errors: errors.array() }); } - const { testCaseId, sharedStepId } = req.query; + const { + testCaseId, sharedStepId, executionId, buildId, + } = req.query; + if (executionId || buildId) { + return findBuildAttachments(req, res); + } return resolveOwner({ testCaseId, sharedStepId }) .then(({ field, owner }) => { @@ -150,7 +251,10 @@ exports.findFile = (req, res) => { return res.status(422).json({ errors: errors.array() }); } return findWithAccess(req.params.attachmentId, req.user) - .then((attachment) => res.sendFile(path.resolve(attachment.path))) + .then((attachment) => res.sendFile( + path.resolve(attachment.path), + { headers: attachmentUtils.fileHeaders(attachment, req.query.download === 'true') }, + )) .catch((err) => handleError(err, res)); }; @@ -199,6 +303,16 @@ exports.delete = (req, res) => { { 'steps.attachments': attachment._id }, { $pull: { 'steps.$[].attachments': attachment._id } }, ).exec(); + if (attachment.scope === 'build') { + await TestExecution.updateMany( + { attachments: attachment._id }, + { $pull: { attachments: attachment._id } }, + ).exec(); + await TestExecution.updateMany( + { 'actions.steps.attachments': attachment._id }, + { $pull: { 'actions.$[].steps.$[].attachments': attachment._id } }, + ).exec(); + } log(`Deleted attachment ${attachmentId}`); return true; diff --git a/app/controllers/build.controller.js b/app/controllers/build.controller.js index 62be9bf..ec78d68 100644 --- a/app/controllers/build.controller.js +++ b/app/controllers/build.controller.js @@ -4,6 +4,7 @@ const debug = require('debug'); const buildMetricsUtils = require('../utils/build-utils.js'); const imageUtils = require('../utils/image-utils.js'); +const attachmentUtils = require('../utils/attachment-utils.js'); const validationUtils = require('../utils/validation-utils.js'); const Build = require('../models/build.js'); @@ -101,7 +102,10 @@ exports.create = (req, res) => { } const testExecutions = executions .map((execution) => buildMetricsUtils.buildExecution(execution, savedBuild)); - return Execution.insertMany(testExecutions) + // The build is brand new, so nothing can have been uploaded against it yet; this + // drops any attachment ids the executions carry. + return attachmentUtils.restrictToBuild(testExecutions, savedBuild._id) + .then(() => Execution.insertMany(testExecutions)) .then((savedExecutions) => buildMetricsUtils .addExecutionsToBuild(savedBuild, savedExecutions)) .catch(async (err) => { @@ -339,9 +343,12 @@ exports.addExecutions = (req, res) => { } const testExecutions = executions .map((execution) => buildMetricsUtils.buildExecution(execution, existingBuild)); - return Execution.insertMany(testExecutions) - .then((savedExecutions) => buildMetricsUtils - .addExecutionsToBuild(existingBuild, savedExecutions)) + return attachmentUtils.restrictToBuild(testExecutions, existingBuild._id) + .then(() => Execution.insertMany(testExecutions)) + .then(async (savedExecutions) => { + await attachmentUtils.linkToExecutions(savedExecutions); + return buildMetricsUtils.addExecutionsToBuild(existingBuild, savedExecutions); + }) .catch(async (err) => { // Unlike create, the build may already hold executions and screenshots, so only the // executions from this failed batch are removed - the caller retries the batch. @@ -459,6 +466,7 @@ exports.delete = (req, res) => { // Cascade the same way deleteMany does: image files on disk first, then the // screenshot and execution documents, then the build itself. await imageUtils.removeScreenshotDirectories([existingBuild]); + await attachmentUtils.removeAttachmentsForBuilds([existingBuild._id]); await Screenshot.deleteMany({ build: existingBuild._id }).exec(); await Execution.deleteMany({ build: existingBuild._id }).exec(); log(`Deleting build ${buildId} along with ${screenshotIds.length} screenshot(s).`); @@ -524,6 +532,7 @@ exports.deleteMany = (req, res) => { }; const promises = [ imageUtils.removeScreenshotDirectories(buildsToDelete), + attachmentUtils.removeAttachmentsForBuilds(buildsToDeleteIds), // deleteMany rather than the deprecated remove(): remove() is gone in Mongoose 7, // where it would silently stop cleaning these up. Screenshot.deleteMany({ build: { $in: buildsToDeleteIds } }) diff --git a/app/controllers/execution.controller.js b/app/controllers/execution.controller.js index fc5e328..486d396 100644 --- a/app/controllers/execution.controller.js +++ b/app/controllers/execution.controller.js @@ -3,6 +3,7 @@ const debug = require('debug'); const TestExecution = require('../models/execution.js'); const Build = require('../models/build.js'); const buildMetricsUtils = require('../utils/build-utils.js'); +const attachmentUtils = require('../utils/attachment-utils.js'); const authMiddleware = require('../utils/auth-middleware.js'); const { handleError, NotFoundError, ForbiddenError } = require('../exceptions/errors.js'); @@ -39,10 +40,12 @@ exports.create = (req, res) => { throw new NotFoundError(`No build found with id ${buildId}`); } testExecution = buildMetricsUtils.createExecution(req, buildFound); - return testExecution.save(); + return attachmentUtils.restrictToBuild([testExecution], buildFound._id) + .then(() => testExecution.save()); }) - .then((savedExecution) => { + .then(async (savedExecution) => { testExecution = savedExecution; + await attachmentUtils.linkToExecutions([testExecution]); return buildMetricsUtils.addExecutionToBuild(testExecution.build, testExecution); }) .then((savedBuild) => { @@ -226,6 +229,7 @@ exports.delete = (req, res) => { } return TestExecution.findByIdAndRemove(executionId); }) + .then(() => attachmentUtils.removeAttachmentsForExecution(executionId)) .then(() => res.status(200).send({ message: 'Test execution deleted successfully!' })) .catch((err) => handleError(err, res)); }; diff --git a/app/models/attachment.js b/app/models/attachment.js index 5434b4f..cc91327 100644 --- a/app/models/attachment.js +++ b/app/models/attachment.js @@ -3,8 +3,13 @@ const mongoose = require('mongoose'); const { Schema } = mongoose; // What the attachment hangs off. `execution` is accepted now so the upload path does not -// need reworking when manual runs land; nothing writes it yet. -const attachmentScopes = ['testcase', 'sharedstep', 'execution']; +// need reworking when manual runs land; nothing writes it yet. `build` is a file an +// automated test uploaded while it ran (see below). +const attachmentScopes = ['testcase', 'sharedstep', 'execution', 'build']; + +// What a build-scoped attachment holds, decided server-side from the file extension (see +// test-attachment-utils). The UI picks a viewer from this, never from the client's mime type. +const attachmentKinds = ['image', 'log', 'json', 'har', 'video', 'trace', 'archive', 'html']; // An image a QA attached to a manual test step, referenced from a step's expected result // as `![alt](attachment:)` and resolved by the UI to /attachment/:id/file. @@ -16,6 +21,12 @@ const attachmentScopes = ['testcase', 'sharedstep', 'execution']; // // An attachment referenced by a frozen ManualTestCaseVersion is never hard-deleted - a // historical execution has to render the image the tester actually saw. +// +// Automated tests upload attachments too (logs, HAR files, videos, traces, HTML +// snapshots). Those are scoped to the build, because the execution does not exist yet +// while the test runs - the same reason screenshots hang off the build. The test then +// lists the returned ids on the execution (or one of its steps) when it saves it, and +// `execution` is filled in at that point. const AttachmentSchema = mongoose.Schema({ team: { type: Schema.Types.ObjectId, @@ -43,6 +54,24 @@ const AttachmentSchema = mongoose.Schema({ ref: 'TestExecution', required: false, }, + // Build scope only: the build the file was uploaded against, and the execution that + // referenced it once that execution was saved. `execution` stays unset for a file no + // execution has claimed (yet). + build: { + type: Schema.Types.ObjectId, + ref: 'Build', + required: false, + }, + execution: { + type: Schema.Types.ObjectId, + ref: 'TestExecution', + required: false, + }, + kind: { + type: String, + enum: attachmentKinds, + required: false, + }, // Server-generated. The client's filename is never used to build a path. filename: { type: String, @@ -93,6 +122,9 @@ AttachmentSchema.index({ team: 1 }, { unique: false }); AttachmentSchema.index({ testCase: 1 }, { unique: false }); AttachmentSchema.index({ sharedStep: 1 }, { unique: false }); AttachmentSchema.index({ manualExecution: 1 }, { unique: false }); +AttachmentSchema.index({ build: 1 }, { unique: false }); +AttachmentSchema.index({ execution: 1 }, { unique: false }); module.exports = mongoose.model('Attachment', AttachmentSchema); module.exports.attachmentScopes = attachmentScopes; +module.exports.attachmentKinds = attachmentKinds; diff --git a/app/models/execution.js b/app/models/execution.js index 229c0eb..60857c3 100644 --- a/app/models/execution.js +++ b/app/models/execution.js @@ -37,9 +37,10 @@ const Step = mongoose.Schema({ ref: 'Screenshot', required: false, }, - // Images a QA attached while recording a manual step result. Automated runs use - // `screenshot` above; this is the manual equivalent and shares the same subdocument so - // there is no parallel step structure to keep in sync. + // Images a QA attached while recording a manual step result, or files an automated test + // uploaded for this step (e.g. the page's HTML when the step failed). Automated + // screenshots still use `screenshot` above; this shares the same subdocument so there is + // no parallel step structure to keep in sync. attachments: [{ type: Schema.Types.ObjectId, ref: 'Attachment', @@ -110,6 +111,13 @@ const TestExecutionSchema = mongoose.Schema({ type: Platform, required: false, }], + // Files the automated test uploaded for the whole execution - a video, a trace, a HAR + // file or a console log. Step-level files live on the step (see Step.attachments). + attachments: [{ + type: Schema.Types.ObjectId, + ref: 'Attachment', + required: false, + }], tags: [{ type: String, required: false, diff --git a/app/routes/attachment.routes.js b/app/routes/attachment.routes.js index aae92eb..62155a6 100644 --- a/app/routes/attachment.routes.js +++ b/app/routes/attachment.routes.js @@ -5,6 +5,7 @@ const { oneOf, } = require('express-validator'); const multerConfig = require('../utils/multer-config-attachments.js'); +const testAttachmentMulter = require('../utils/multer-config-test-attachments.js'); const attachmentController = require('../controllers/attachment.controller.js'); module.exports = (app, path) => { @@ -25,13 +26,30 @@ module.exports = (app, path) => { attachmentController.createFail, ); + // A file an automated test uploads while it runs: log, HAR, video, trace, HTML snapshot + // or image. The build id is in the path so multer has it before writing the file. + app.post( + `${path}/build/:buildId/attachment`, + testAttachmentMulter.single('attachment'), + [ + param('buildId').exists().isMongoId(), + ], + attachmentController.createForBuild, + attachmentController.createFail, + ); + app.get(`${path}/attachment`, [ oneOf([ query('testCaseId').exists().isMongoId(), query('sharedStepId').exists().isMongoId(), - ], 'A valid testCaseId or sharedStepId is required'), + query('executionId').exists().isMongoId(), + query('buildId').exists().isMongoId(), + ], 'A valid testCaseId, sharedStepId, executionId or buildId is required'), ], attachmentController.findAll); + // `?download=true` serves any attachment as a download instead of inline. + // (Build-scoped HTML snapshots, traces and archives are always downloads.) + app.get(`${path}/attachment/:attachmentId`, [ param('attachmentId').exists().isMongoId(), ], attachmentController.findOne); diff --git a/app/routes/build.routes.js b/app/routes/build.routes.js index ebeb15a..6af5ab7 100644 --- a/app/routes/build.routes.js +++ b/app/routes/build.routes.js @@ -44,6 +44,12 @@ module.exports = (app, path) => { check('executions.*.platforms.*.browserVersion').optional().isString(), check('executions.*.platforms.*.deviceName').optional().isString(), check('executions.*.platforms.*.userAgent').optional().isString(), + // Attachments are uploaded against an existing build, so executions posted together + // with a new build cannot reference any yet; ids here are validated and then dropped. + check('executions.*.attachments').optional().isArray(), + check('executions.*.attachments.*').isMongoId(), + check('executions.*.actions.*.steps.*.attachments').optional().isArray(), + check('executions.*.actions.*.steps.*.attachments.*').isMongoId(), ], buildController.create); app.get(`${path}/build`, [ @@ -172,6 +178,10 @@ module.exports = (app, path) => { check('executions.*.platforms.*.browserVersion').optional().isString(), check('executions.*.platforms.*.deviceName').optional().isString(), check('executions.*.platforms.*.userAgent').optional().isString(), + check('executions.*.attachments').optional().isArray(), + check('executions.*.attachments.*').isMongoId(), + check('executions.*.actions.*.steps.*.attachments').optional().isArray(), + check('executions.*.actions.*.steps.*.attachments.*').isMongoId(), ], buildController.addExecutions); app.put(`${path}/build/:buildId/artifacts`, [ diff --git a/app/routes/execution.routes.js b/app/routes/execution.routes.js index 4e4cd69..a040028 100644 --- a/app/routes/execution.routes.js +++ b/app/routes/execution.routes.js @@ -29,6 +29,12 @@ module.exports = (app, path) => { check('platforms.*.browserVersion').optional().isString(), check('platforms.*.deviceName').optional().isString(), check('platforms.*.userAgent').optional().isString(), + // Ids returned by POST /build/:buildId/attachment. Ids that were not uploaded against + // this execution's build are dropped when the execution is saved. + check('attachments').optional().isArray(), + check('attachments.*').isMongoId(), + check('actions.*.steps.*.attachments').optional().isArray(), + check('actions.*.steps.*.attachments.*').isMongoId(), ], executionController.create); app.get(`${path}/execution`, [ diff --git a/app/utils/attachment-utils.js b/app/utils/attachment-utils.js index 1667324..3f8656c 100644 --- a/app/utils/attachment-utils.js +++ b/app/utils/attachment-utils.js @@ -133,4 +133,131 @@ attachmentUtils.removeAttachmentsForOwner = async (scope, ownerId) => { return result.deletedCount || 0; }; +// ── Build-scoped attachments (files uploaded by automated tests) ───────────── + +// Kinds that are always downloaded rather than shown inline when opened directly. An HTML +// snapshot opened inline on the API's origin would run its scripts with the viewer's +// session cookie; archives have nothing to show inline. +const DOWNLOAD_ONLY_KINDS = ['html', 'trace', 'archive']; + +// Keeps a stored display name safe inside a Content-Disposition header. +const dispositionFilename = (name) => (name || 'attachment').replace(/[^\w.\- ]+/g, '_'); + +/* +Headers for serving an attachment's file. + +Every file is served with `nosniff`, so a browser never second-guesses the stored type, +and with a `sandbox` CSP, so even a file a browser does render (an HTML snapshot opened +from a link, an SVG inside a zip viewer) runs in an opaque origin with scripts disabled. +The UI fetches files with XHR and renders HTML in a sandboxed iframe of its own, so none +of this changes what it shows. + */ +attachmentUtils.fileHeaders = (attachment, download) => { + const headers = { + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': 'sandbox', + }; + if (attachment.scope !== 'build') { + return headers; + } + headers['Content-Type'] = attachment.mimeType || 'application/octet-stream'; + const disposition = download || DOWNLOAD_ONLY_KINDS.includes(attachment.kind) ? 'attachment' : 'inline'; + headers['Content-Disposition'] = `${disposition}; filename="${dispositionFilename(attachment.originalName)}"`; + return headers; +}; + +// The fields a reader of an execution needs to list and open its attachments. `path` and +// `filename` are deliberately left out: they are server paths, not something to hand out. +attachmentUtils.PUBLIC_FIELDS = '_id kind originalName mimeType size execution build createdAt'; + +const idsOf = (list) => (list || []).map((id) => id.toString()); + +// Every attachment id an execution references, at execution level and on its steps. +const referencedIds = (execution) => { + const ids = idsOf(execution.attachments); + (execution.actions || []).forEach((action) => { + (action.steps || []).forEach((step) => ids.push(...idsOf(step.attachments))); + }); + return ids; +}; +attachmentUtils.referencedIds = referencedIds; + +/* +Drops any attachment id that was not uploaded against the execution's own build. + +An execution can only claim files from its own build. Without this a caller could list +another team's attachment id on their execution and read its name and size back through +the execution's attachment list (the file itself is still guarded by a team check). +Unknown ids are dropped rather than rejected, matching how a missing screenshot id on a +step is tolerated: the test results are worth more than a stale reference. + +Updates the (unsaved) execution documents in place. + */ +attachmentUtils.restrictToBuild = async (executions, buildId) => { + const allIds = [...new Set(executions.flatMap(referencedIds))]; + if (allIds.length === 0) { + return; + } + const valid = await Attachment.find({ _id: { $in: allIds }, build: buildId, scope: 'build' }) + .distinct('_id') + .exec(); + const validIds = new Set(valid.map((id) => id.toString())); + if (validIds.size < allIds.length) { + log(`Dropping ${allIds.length - validIds.size} attachment reference(s) that do not belong to build ${buildId}`); + } + const keep = (list) => (list || []).filter((id) => validIds.has(id.toString())); + executions.forEach((execution) => { + execution.set('attachments', keep(execution.attachments)); + (execution.actions || []).forEach((action) => { + (action.steps || []).forEach((step) => { + if (step.attachments && step.attachments.length) { + step.set('attachments', keep(step.attachments)); + } + }); + }); + }); +}; + +/* +Records which execution each referenced attachment now belongs to, once the executions +are saved. This is what lets the attachment list for an execution be a single indexed +query, and what removes an execution's files when the execution is deleted. + */ +attachmentUtils.linkToExecutions = async (executions) => { + await Promise.all(executions.map((execution) => { + const ids = referencedIds(execution); + if (ids.length === 0) { + return undefined; + } + return Attachment.updateMany( + { _id: { $in: ids }, build: execution.build, scope: 'build' }, + { $set: { execution: execution._id } }, + ).exec(); + })); +}; + +/* +Removes the build-scoped attachments of the given builds: documents and directories. +Called wherever builds are deleted, alongside removeScreenshotDirectories. + */ +attachmentUtils.removeAttachmentsForBuilds = async (buildIds) => { + if (!buildIds || buildIds.length === 0) { + return 0; + } + const result = await Attachment.deleteMany({ build: { $in: buildIds }, scope: 'build' }).exec(); + await Promise.all(buildIds.map((buildId) => attachmentUtils.removeAttachmentDirectory(buildId))); + return result.deletedCount || 0; +}; + +/* +Removes the attachments an execution claimed, files and documents. Files no execution has +claimed stay with the build and go when the build does. + */ +attachmentUtils.removeAttachmentsForExecution = async (executionId) => { + const attachments = await Attachment.find({ execution: executionId, scope: 'build' }).lean().exec(); + await Promise.all(attachments.map((attachment) => attachmentUtils.removeFiles(attachment))); + await Attachment.deleteMany({ execution: executionId, scope: 'build' }).exec(); + return attachments.length; +}; + module.exports = attachmentUtils; diff --git a/app/utils/build-utils.js b/app/utils/build-utils.js index 4d08907..790d1a2 100644 --- a/app/utils/build-utils.js +++ b/app/utils/build-utils.js @@ -153,7 +153,7 @@ buildMetricsUtils.determineNewState = (existingState, newState) => { buildMetricsUtils.buildExecution = (executionDetails, build) => { const { - title, suite, start, end, platforms, tags, meta, actions, feature, + title, suite, start, end, platforms, tags, meta, actions, feature, attachments, } = executionDetails; const testExecution = new TestExecution({ @@ -167,6 +167,7 @@ buildMetricsUtils.buildExecution = (executionDetails, build) => { tags, meta, actions, + attachments, status: buildMetricsUtils.executionStates[0], }); if (actions) { diff --git a/app/utils/multer-config-test-attachments.js b/app/utils/multer-config-test-attachments.js new file mode 100644 index 0000000..9be573b --- /dev/null +++ b/app/utils/multer-config-test-attachments.js @@ -0,0 +1,108 @@ +const multer = require('multer'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { ATTACHMENT_ROOT } = require('./multer-config-attachments.js'); + +// Files an automated test uploads against a build: console logs, HAR files, videos, +// Playwright traces, HTML snapshots and plain images. Stored beside the manual-testing +// attachments, grouped by build id so deleting a build is one recursive remove (the same +// shape as screenshots/). +// +// The build id comes from the URL (POST /build/:buildId/attachment) rather than a form +// field, so it is available before multer writes the file regardless of the order the +// client put the multipart fields in. +const MONGO_ID_PATTERN = /^[a-f\d]{24}$/i; + +// Default 100 MB: a test video or a trace can be tens of megabytes. Configurable because +// the right ceiling depends on how long a team's tests run and how much disk they have. +const DEFAULT_MAX_SIZE_MB = 100; +const configuredMaxSize = Number(process.env.ANGLES_ATTACHMENT_MAX_SIZE_MB); +const MAX_SIZE_MB = Number.isFinite(configuredMaxSize) && configuredMaxSize > 0 + ? configuredMaxSize : DEFAULT_MAX_SIZE_MB; + +// Everything the server knows about a file comes from this table, keyed by the extension +// of the client's filename. The client's mime type is ignored: test frameworks report +// `application/octet-stream` for most of these, and the mime type stored here is what the +// file is served with later, so it must not be attacker-chosen. +const TYPES_BY_EXTENSION = { + '.png': { kind: 'image', mimeType: 'image/png' }, + '.jpg': { kind: 'image', mimeType: 'image/jpeg' }, + '.jpeg': { kind: 'image', mimeType: 'image/jpeg' }, + '.gif': { kind: 'image', mimeType: 'image/gif' }, + '.webp': { kind: 'image', mimeType: 'image/webp' }, + '.log': { kind: 'log', mimeType: 'text/plain' }, + '.txt': { kind: 'log', mimeType: 'text/plain' }, + '.json': { kind: 'json', mimeType: 'application/json' }, + '.har': { kind: 'har', mimeType: 'application/json' }, + '.webm': { kind: 'video', mimeType: 'video/webm' }, + '.mp4': { kind: 'video', mimeType: 'video/mp4' }, + '.zip': { kind: 'archive', mimeType: 'application/zip' }, + '.html': { kind: 'html', mimeType: 'text/html' }, + '.htm': { kind: 'html', mimeType: 'text/html' }, +}; + +/** + * Decides how a test attachment is stored and shown, from the client's filename alone. + * Returns undefined for an extension that is not supported. + * + * A zip whose name mentions "trace" is treated as a Playwright trace (Playwright names + * them trace.zip); any other zip is a plain archive offered for download. + */ +const describeFile = (originalName) => { + const base = path.basename(originalName || '').toLowerCase(); + const extension = path.extname(base); + const type = TYPES_BY_EXTENSION[extension]; + if (!type) { + return undefined; + } + const kind = type.kind === 'archive' && base.includes('trace') ? 'trace' : type.kind; + return { ...type, kind, extension: extension === '.jpeg' ? '.jpg' : extension }; +}; + +const SUPPORTED_EXTENSIONS = Object.keys(TYPES_BY_EXTENSION).join(', '); +const unsupportedError = () => new Error(`Unsupported attachment type. Supported file extensions: ${SUPPORTED_EXTENSIONS}`); + +const multerConfig = multer({ + limits: { fileSize: MAX_SIZE_MB * 1024 * 1024 }, + storage: multer.diskStorage({ + destination(req, file, next) { + const { buildId } = req.params; + if (!MONGO_ID_PATTERN.test(buildId || '')) { + return next(new Error('A valid buildId is required to upload an attachment')); + } + const directory = path.join(ATTACHMENT_ROOT, buildId); + // Defence in depth: even with the pattern above, never write outside the root. + if (!directory.startsWith(ATTACHMENT_ROOT + path.sep)) { + return next(new Error('A valid buildId is required to upload an attachment')); + } + if (!fs.existsSync(directory)) { + fs.mkdirSync(directory, { recursive: true }); + } + return next(null, directory); + }, + filename(req, file, next) { + const description = describeFile(file.originalname); + if (!description) { + return next(unsupportedError()); + } + // The client's filename is never used to build a path; only the extension it maps + // to, which comes from the table above. + const unique = crypto.randomBytes(8).toString('hex'); + return next(null, `${Date.now()}-${unique}${description.extension}`); + }, + }), + fileFilter(req, file, next) { + if (!file) { + return next(null, false); + } + if (describeFile(file.originalname)) { + return next(null, true); + } + return next(unsupportedError()); + }, +}); + +module.exports = multerConfig; +module.exports.describeFile = describeFile; +module.exports.MAX_SIZE_MB = MAX_SIZE_MB; diff --git a/docs/test-attachments.md b/docs/test-attachments.md new file mode 100644 index 0000000..ef58d26 --- /dev/null +++ b/docs/test-attachments.md @@ -0,0 +1,87 @@ +# Test attachments + +Automated tests can attach files to their results: console and browser logs, network HAR +files, video recordings, Playwright traces, page HTML snapshots and images. They are shown +on the execution in the Angles UI, each with a viewer that suits the file. + +## How it works + +A test uploads files while it runs, but the execution is only saved when the test +finishes (or, in batch mode, when the whole run finishes). So, like screenshots, files are +uploaded against the **build**, and the execution claims them when it is saved: + +1. `POST /rest/api/v1.0/build/{buildId}/attachment` with the file in the multipart field + `attachment`. The response contains the attachment's `_id` and `kind`. +2. List the id when saving the execution, either for the whole test or for one step: + + ```json + { + "title": "Guest user can pay with a saved card", + "suite": "Checkout", + "build": "", + "attachments": ["