Skip to content
Merged
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
118 changes: 116 additions & 2 deletions app/controllers/attachment.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -90,19 +93,117 @@ 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
// are returned as a 500 with an HTML stack trace instead of this 400.
// 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 }) => {
Expand Down Expand Up @@ -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));
};

Expand Down Expand Up @@ -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;
Expand Down
17 changes: 13 additions & 4 deletions app/controllers/build.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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).`);
Expand Down Expand Up @@ -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 } })
Expand Down
8 changes: 6 additions & 2 deletions app/controllers/execution.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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));
};
36 changes: 34 additions & 2 deletions app/models/attachment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>)` and resolved by the UI to /attachment/:id/file.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
14 changes: 11 additions & 3 deletions app/models/execution.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 19 additions & 1 deletion app/routes/attachment.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions app/routes/build.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`, [
Expand Down Expand Up @@ -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`, [
Expand Down
Loading
Loading