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 e8565f5..b0adf95 100644 --- a/app/controllers/build.controller.js +++ b/app/controllers/build.controller.js @@ -5,6 +5,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'); @@ -103,7 +104,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) => { @@ -349,9 +353,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. @@ -469,6 +476,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).`); @@ -534,6 +542,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 8fb84e7..79036aa 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'); @@ -42,10 +43,12 @@ exports.create = (req, res) => { throw new ForbiddenError('You do not have access to this build'); } 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) => { @@ -229,6 +232,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": ["