From 2a7a687aaf775cc0c51b7df6483a21bbdbbf5a81 Mon Sep 17 00:00:00 2001 From: Andrew Burnes Date: Wed, 15 Jan 2025 15:18:35 -0700 Subject: [PATCH] feat: Add API endpoints for file storage #2082 Signed-off-by: Andrew Burnes --- api/admin/controllers/file-storage.js | 27 + api/admin/controllers/index.js | 2 + api/admin/routers/api.js | 4 + api/authorizers/file-storage.js | 76 ++ api/authorizers/site.js | 42 +- api/authorizers/utils.js | 90 ++ api/controllers/file-storage-service.js | 168 ++++ api/middlewares/index.js | 2 + api/middlewares/mulipart-form.js | 6 + api/models/file-storage-domain.js | 96 ++ api/models/file-storage-file.js | 57 ++ api/models/file-storage-service.js | 103 ++ api/models/file-storage-user-action.js | 76 ++ api/models/index.js | 4 + api/models/organization.js | 12 +- api/models/site.js | 4 + api/models/user.js | 4 + api/responses/siteErrors.js | 8 + api/routers/file-storage-service.js | 38 + api/routers/index.js | 1 + api/serializers/file-storage.js | 67 ++ api/services/S3Helper.js | 11 + api/services/file-storage/index.js | 292 ++++++ api/utils/index.js | 57 +- ...16164506-add-file-storage-model-columns.js | 48 + package.json | 5 +- public/swagger/FileStorageFile.json | 32 + public/swagger/FileStorageList.json | 21 + public/swagger/FileStorageService.json | 20 + public/swagger/FileStorageUserAction.json | 43 + public/swagger/FileStorageUserActionList.json | 21 + public/swagger/index.yml | 210 ++++ test/api/admin/requests/file-storage.test.js | 84 ++ .../api/requests/file-storage-service.test.js | 906 ++++++++++++++++++ test/api/requests/published-branch.test.js | 8 +- test/api/requests/published-file.test.js | 4 +- test/api/requests/user-action.test.js | 10 +- test/api/support/factory/file-storage-file.js | 107 +++ .../support/factory/file-storage-service.js | 55 ++ .../factory/file-storage-user-action.js | 97 ++ test/api/support/factory/index.js | 6 + test/api/support/file-storage-service.js | 206 ++++ test/api/support/fixtures/logo.svg | 74 ++ test/api/support/fixtures/lorem.txt | 39 + test/api/support/site-user.js | 9 +- .../api/unit/authorizers/file-storage.test.js | 165 ++++ test/api/unit/authorizers/site.test.js | 21 +- test/api/unit/authorizers/utils.test.js | 149 +++ .../unit/models/file-storage-domain.test.js | 149 +++ .../api/unit/models/file-storage-file.test.js | 127 +++ .../unit/models/file-storage-service.test.js | 172 ++++ .../models/file-storage-user-action.test.js | 109 +++ test/api/unit/services/FileStorage.test.js | 739 ++++++++++++++ test/api/unit/utils/utils.test.js | 111 +++ yarn.lock | 104 +- 55 files changed, 5028 insertions(+), 70 deletions(-) create mode 100644 api/admin/controllers/file-storage.js create mode 100644 api/authorizers/file-storage.js create mode 100644 api/authorizers/utils.js create mode 100644 api/controllers/file-storage-service.js create mode 100644 api/middlewares/mulipart-form.js create mode 100644 api/models/file-storage-domain.js create mode 100644 api/models/file-storage-file.js create mode 100644 api/models/file-storage-service.js create mode 100644 api/models/file-storage-user-action.js create mode 100644 api/routers/file-storage-service.js create mode 100644 api/serializers/file-storage.js create mode 100644 api/services/file-storage/index.js create mode 100644 migrations/20250116164506-add-file-storage-model-columns.js create mode 100644 public/swagger/FileStorageFile.json create mode 100644 public/swagger/FileStorageList.json create mode 100644 public/swagger/FileStorageService.json create mode 100644 public/swagger/FileStorageUserAction.json create mode 100644 public/swagger/FileStorageUserActionList.json create mode 100644 test/api/admin/requests/file-storage.test.js create mode 100644 test/api/requests/file-storage-service.test.js create mode 100644 test/api/support/factory/file-storage-file.js create mode 100644 test/api/support/factory/file-storage-service.js create mode 100644 test/api/support/factory/file-storage-user-action.js create mode 100644 test/api/support/file-storage-service.js create mode 100755 test/api/support/fixtures/logo.svg create mode 100644 test/api/support/fixtures/lorem.txt create mode 100644 test/api/unit/authorizers/file-storage.test.js create mode 100644 test/api/unit/authorizers/utils.test.js create mode 100644 test/api/unit/models/file-storage-domain.test.js create mode 100644 test/api/unit/models/file-storage-file.test.js create mode 100644 test/api/unit/models/file-storage-service.test.js create mode 100644 test/api/unit/models/file-storage-user-action.test.js create mode 100644 test/api/unit/services/FileStorage.test.js diff --git a/api/admin/controllers/file-storage.js b/api/admin/controllers/file-storage.js new file mode 100644 index 000000000..892cb9988 --- /dev/null +++ b/api/admin/controllers/file-storage.js @@ -0,0 +1,27 @@ +const EventCreator = require('../../services/EventCreator'); +const { canAdminCreateSiteFileStorage } = require('../../authorizers/file-storage'); +const { serializeFileStorageService } = require('../../serializers/file-storage'); +const { wrapHandlers } = require('../../utils'); +const { Event } = require('../../models'); +const { adminCreateSiteFileStorage } = require('../../services/file-storage'); + +module.exports = wrapHandlers({ + async createSiteFileStorage(req, res) { + const { params, user } = req; + + const siteId = parseInt(params.id, 10); + const { site } = await canAdminCreateSiteFileStorage(siteId); + + const fss = await adminCreateSiteFileStorage(site); + + EventCreator.audit( + Event.labels.ADMIN, + user, + 'Site File Storage Service Created', + fss, + ); + + const data = serializeFileStorageService(fss); + return res.send(data); + }, +}); diff --git a/api/admin/controllers/index.js b/api/admin/controllers/index.js index 789f39194..4004f66cf 100644 --- a/api/admin/controllers/index.js +++ b/api/admin/controllers/index.js @@ -1,6 +1,7 @@ const Build = require('./build'); const Domain = require('./domain'); const Event = require('./event'); +const FileStorage = require('./file-storage'); const Organization = require('./organization'); const OrganizationRole = require('./organization-role'); const Role = require('./role'); @@ -13,6 +14,7 @@ module.exports = { Build, Domain, Event, + FileStorage, Organization, OrganizationRole, Role, diff --git a/api/admin/routers/api.js b/api/admin/routers/api.js index b3ea6a152..a5e6beda0 100644 --- a/api/admin/routers/api.js +++ b/api/admin/routers/api.js @@ -76,6 +76,10 @@ apiRouter.get('/sites', AdminControllers.Site.list); apiRouter.get('/sites/raw', AdminControllers.Site.listRaw); apiRouter.get('/sites/:id', AdminControllers.Site.findById); apiRouter.put('/sites/:id', AdminControllers.Site.update); +apiRouter.post( + '/sites/:id/file-storage', + AdminControllers.FileStorage.createSiteFileStorage, +); apiRouter.get('/sites/:id/webhooks', AdminControllers.Site.listWebhooks); apiRouter.post('/sites/:id/webhooks', AdminControllers.Site.createWebhook); apiRouter.delete('/sites/:id', authorize(['pages.admin']), AdminControllers.Site.destroy); diff --git a/api/authorizers/file-storage.js b/api/authorizers/file-storage.js new file mode 100644 index 000000000..17f50db0c --- /dev/null +++ b/api/authorizers/file-storage.js @@ -0,0 +1,76 @@ +const siteErrors = require('../responses/siteErrors'); +const { FileStorageService, Site } = require('../models'); +const { isSiteOrgManager, isOrgManager, isOrgUser } = require('./utils'); + +const canCreateSiteStorage = async (userId, siteId) => { + const { site, organization } = await isSiteOrgManager(userId, siteId); + + const fss = await FileStorageService.findOne({ where: { siteId } }); + + if (fss) { + throw { + status: 403, + message: siteErrors.SITE_FILE_STORAGE_EXISTS, + }; + } + + return { site, organization }; +}; + +const canAdminCreateSiteFileStorage = async (siteId) => { + const site = await Site.findByPk(siteId); + + if (!site) { + throw { + status: 403, + message: siteErrors.SITE_DOES_NOT_EXIST, + }; + } + + const fss = await FileStorageService.findOne({ where: { siteId } }); + + if (fss) { + throw { + status: 403, + message: siteErrors.SITE_FILE_STORAGE_EXISTS, + }; + } + + return { site }; +}; + +async function hasFileStorage(fssId) { + const fileStorageService = await FileStorageService.findByPk(fssId); + + if (!fileStorageService) { + throw { + status: 404, + message: siteErrors.NOT_FOUND, + }; + } + + return { fileStorageService }; +} + +async function isFileStorageManager(userId, fssId) { + const { fileStorageService } = await hasFileStorage(fssId); + + const { organization } = await isOrgManager(userId, fileStorageService.organizationId); + + return { organization, fileStorageService }; +} + +async function isFileStorageUser(userId, fssId) { + const { fileStorageService } = await hasFileStorage(fssId); + + const { organization } = await isOrgUser(userId, fileStorageService.organizationId); + + return { fileStorageService, organization }; +} + +module.exports = { + canAdminCreateSiteFileStorage, + canCreateSiteStorage, + isFileStorageManager, + isFileStorageUser, +}; diff --git a/api/authorizers/site.js b/api/authorizers/site.js index 317db0a93..397f1e60a 100644 --- a/api/authorizers/site.js +++ b/api/authorizers/site.js @@ -1,35 +1,7 @@ const GitHub = require('../services/GitHub'); const siteErrors = require('../responses/siteErrors'); -const { Organization, Site } = require('../models'); -const { fetchModelById } = require('../utils/queryDatabase'); - -const authorize = async ({ id: userId }, { id: siteId }) => { - const site = await fetchModelById( - siteId, - Site.forUser({ - id: userId, - }), - ); - - if (!site) { - throw 403; - } - - if (!site.isActive) { - // if site is not active - throw 403; - } - - if (site.organizationId) { - // if site exists in an org - const org = await site.getOrganization(); - if (!org.isActive) { - throw 403; - } - } - - return site; -}; +const { Organization } = require('../models'); +const { authorize } = require('./utils'); const authorizeRepositoryAdmin = (user, site) => GitHub.checkPermissions(user, site.owner, site.repository) @@ -101,16 +73,16 @@ const create = async (user, siteParams) => { } }; -const createBuild = (user, site) => authorize(user, site); +const createBuild = (user, site) => authorize(user.id, site.id); -const showActions = (user, site) => authorize(user, site); +const showActions = (user, site) => authorize(user.id, site.id); -const findOne = (user, site) => authorize(user, site); +const findOne = (user, site) => authorize(user.id, site.id); -const update = (user, site) => authorize(user, site); +const update = (user, site) => authorize(user.id, site.id); const destroy = (user, site) => - authorize(user, site).then(() => authorizeRepositoryAdmin(user, site)); + authorize(user.id, site.id).then(() => authorizeRepositoryAdmin(user, site)); module.exports = { create, diff --git a/api/authorizers/utils.js b/api/authorizers/utils.js new file mode 100644 index 000000000..c48246ee0 --- /dev/null +++ b/api/authorizers/utils.js @@ -0,0 +1,90 @@ +const siteErrors = require('../responses/siteErrors'); +const { Organization, Site } = require('../models'); +const { fetchModelById } = require('../utils/queryDatabase'); + +const authorize = async (userId, siteId) => { + const site = await fetchModelById( + siteId, + Site.forUser({ + id: userId, + }), + ); + + if (!site) { + throw { + status: 404, + message: siteErrors.NOT_FOUND, + }; + } + + if (!site.isActive) { + // if site is not active + throw { + status: 403, + message: siteErrors.ORGANIZATION_INACTIVE, + }; + } + + if (site.organizationId) { + // if site exists in an org + const org = await site.getOrganization(); + if (!org.isActive) { + throw { + status: 403, + message: siteErrors.ORGANIZATION_INACTIVE, + }; + } + } + + return site; +}; + +const isSiteOrgManager = async (userId, siteId) => { + const site = await authorize(userId, siteId); + const organization = await fetchModelById( + site.organizationId, + Organization.forManagerRole({ id: userId }), + ); + + if (!organization) { + throw { + status: 403, + message: siteErrors.ORGANIZATION_MANAGER_ACCESS, + }; + } + + return { site, organization }; +}; + +const isOrgManager = async (userId, orgId) => { + const organization = await fetchModelById( + orgId, + Organization.forManagerRole({ id: userId }), + ); + + if (!organization) { + throw { + status: 403, + message: siteErrors.ORGANIZATION_MANAGER_ACCESS, + }; + } + + return { organization }; +}; + +const isOrgUser = async (userId, orgId) => { + const organization = await fetchModelById(orgId, Organization.forUser({ id: userId })); + + if (!organization) { + throw { + status: 403, + message: siteErrors.ORGANIZATION_USER_ACCESS, + }; + } + + return { organization }; +}; + +const isSiteUser = authorize; + +module.exports = { authorize, isOrgManager, isOrgUser, isSiteOrgManager, isSiteUser }; diff --git a/api/controllers/file-storage-service.js b/api/controllers/file-storage-service.js new file mode 100644 index 000000000..c7d544e3c --- /dev/null +++ b/api/controllers/file-storage-service.js @@ -0,0 +1,168 @@ +const { isEmpty } = require('underscore'); +const EventCreator = require('../services/EventCreator'); +const { + canCreateSiteStorage, + isFileStorageUser, + isFileStorageManager, +} = require('../authorizers/file-storage'); +const { + serializeFileStorageService, + serializeFileStorageFile, +} = require('../serializers/file-storage'); +const { wrapHandlers } = require('../utils'); +const { Event } = require('../models'); +const { SiteFileStorageSerivce } = require('../services/file-storage'); +const badRequest = require('../responses/badRequest'); + +module.exports = wrapHandlers({ + async create(req, res) { + const { params, user } = req; + const siteId = parseInt(params.site_id, 10); + const { site } = await canCreateSiteStorage(user.id, siteId); + + const siteStorageService = new SiteFileStorageSerivce(site, user.id); + const client = await siteStorageService.createClient(); + const fss = await client.createFileStorageService(); + + EventCreator.audit( + Event.labels.ORG_MANAGER_ACTION, + user, + 'Site File Storage Service Created', + fss, + ); + + const data = serializeFileStorageService(fss); + return res.send(data); + }, + + async createDirectory(req, res) { + const { + body: { parent, name }, + params, + user, + } = req; + + const fssId = parseInt(params.file_storage_id, 10); + const { fileStorageService } = await isFileStorageUser(user.id, fssId); + + const siteStorageService = new SiteFileStorageSerivce(fileStorageService, user.id); + const client = await siteStorageService.createClient(); + const fss = await client.createDirectory(parent, name); + + const data = serializeFileStorageFile(fss); + return res.send(data); + }, + + async deleteFile(req, res) { + const { params, user } = req; + + const fssId = parseInt(params.file_storage_id, 10); + const fileId = parseInt(params.file_id, 10); + const { fileStorageService } = await isFileStorageUser(user.id, fssId); + + const siteStorageService = new SiteFileStorageSerivce(fileStorageService, user.id); + const client = await siteStorageService.createClient(); + const results = await client.deleteFile(fileId); + + if (!results) { + return res.notFound(); + } + + return res.send(results); + }, + + async getFile(req, res) { + const { params, user } = req; + + const fssId = parseInt(params.file_storage_id, 10); + const fileId = parseInt(params.file_id, 10); + + const { fileStorageService } = await isFileStorageUser(user.id, fssId); + + const siteStorageService = new SiteFileStorageSerivce(fileStorageService, user.id); + const client = await siteStorageService.createClient(); + const results = await client.getFile(fileId); + + if (isEmpty(results)) { + return res.notFound(); + } + + return res.send(results); + }, + + async listDirectoryFiles(req, res) { + const { params, query, user } = req; + const { + path = '', + limit = 50, + page = 1, + sortKey = 'updatedAt', + sortOrder = 'DESC', + } = query; + const order = [[sortKey, sortOrder]]; + + const fssId = parseInt(params.file_storage_id, 10); + const { fileStorageService } = await isFileStorageUser(user.id, fssId); + + const siteStorageService = new SiteFileStorageSerivce(fileStorageService, user.id); + const client = await siteStorageService.createClient(); + const results = await client.listDirectoryFiles(path, { limit, page, order }); + + return res.send(results); + }, + + async listUserActions(req, res) { + const { params, query, user } = req; + const { limit = 50, page = 1 } = query; + + const fssId = parseInt(params.file_storage_id, 10); + const fileStorageFileId = params.file_id && parseInt(params.file_id, 10); + + const { fileStorageService } = await isFileStorageManager(user.id, fssId); + + const siteStorageService = new SiteFileStorageSerivce(fileStorageService, user.id); + const client = await siteStorageService.createClient(); + const results = await client.listUserActions({ + fileStorageFileId, + limit, + page, + }); + + return res.send(results); + }, + + async uploadFile(req, res) { + const { params, user } = req; + + const fssId = parseInt(params.file_storage_id, 10); + + const { fileStorageService } = await isFileStorageUser(user.id, fssId); + + const [file] = req.files; + + if (!file) { + const err = new Error('No file uploaded.'); + return badRequest(err, { res }); + } + + const { name, parent } = req.body; + + if (!name || !parent) { + const err = new Error('No file name or parent directory defined.'); + return badRequest(err, { res }); + } + + const { buffer: fileBuffer, originalname, encoding, mimetype, size } = file; + + const siteStorageService = new SiteFileStorageSerivce(fileStorageService, user.id); + const client = await siteStorageService.createClient(); + const fss = await client.uploadFile(name, fileBuffer, mimetype, parent, { + encoding, + size, + originalname, + }); + + const data = serializeFileStorageFile(fss); + return res.send(data); + }, +}); diff --git a/api/middlewares/index.js b/api/middlewares/index.js index 695df1d9b..e779b9e4f 100644 --- a/api/middlewares/index.js +++ b/api/middlewares/index.js @@ -5,6 +5,7 @@ const ensureAuthenticated = require('./ensure-authenticated'); const ensureOrigin = require('./ensure-origin'); const errorHandler = require('./error-handler'); const fourOhFourHandler = require('./four-oh-four-handler'); +const multipartForm = require('./mulipart-form'); const parseForm = require('./parse-form'); const parseJson = require('./parse-json'); const sessionAuth = require('./session-auth'); @@ -18,6 +19,7 @@ module.exports = { ensureOrigin, errorHandler, fourOhFourHandler, + multipartForm, parseForm, parseJson, sessionAuth, diff --git a/api/middlewares/mulipart-form.js b/api/middlewares/mulipart-form.js new file mode 100644 index 000000000..e4d833111 --- /dev/null +++ b/api/middlewares/mulipart-form.js @@ -0,0 +1,6 @@ +const multer = require('multer'); + +// eslint-disable-next-line sonarjs/content-length +const multipartForm = multer({ limits: { fileSize: 200000000, files: 1 } }); + +module.exports = multipartForm; diff --git a/api/models/file-storage-domain.js b/api/models/file-storage-domain.js new file mode 100644 index 000000000..659fce05b --- /dev/null +++ b/api/models/file-storage-domain.js @@ -0,0 +1,96 @@ +const { buildEnum } = require('../utils'); +const { isDelimitedFQDN } = require('../utils/validators'); + +const States = buildEnum([ + 'pending', + 'provisioning', + 'failed', + 'provisioned', + 'deprovisioning', +]); + +function associate({ FileStorageDomain, FileStorageService }) { + // Associations + FileStorageDomain.belongsTo(FileStorageService, { + foreignKey: 'fileStorageServiceId', + }); + + // Scopes + FileStorageDomain.addScope('bySerivce', (id) => ({ + include: [ + { + model: FileStorageService, + where: { id }, + }, + ], + })); + + FileStorageDomain.addScope('byState', (state) => ({ + where: { + state, + }, + })); +} + +function define(sequelize, DataTypes) { + const FileStorageDomain = sequelize.define( + 'FileStorageDomain', + { + names: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isDelimitedFQDN, + }, + }, + serviceName: { + type: DataTypes.STRING, + allowNull: true, + }, + serviceId: { + type: DataTypes.STRING, + allowNull: true, + }, + state: { + type: DataTypes.ENUM, + values: States.values, + defaultValue: States.Pending, + allowNull: false, + validate: { + isIn: [States.values], + }, + }, + metadata: { + type: DataTypes.JSON, + }, + }, + { + tableName: 'file_storage_domain', + paranoid: true, + }, + ); + + FileStorageDomain.associate = associate; + FileStorageDomain.siteScope = (serviceId) => ({ + method: ['bySerice', serviceId], + }); + FileStorageDomain.stateScope = (state) => ({ + method: ['byState', state], + }); + FileStorageDomain.States = States; + FileStorageDomain.prototype.isPending = function isPending() { + return this.state === FileStorageDomain.States.Pending; + }; + FileStorageDomain.prototype.isProvisioning = function isProvisioning() { + return this.state === FileStorageDomain.States.Provisioning; + }; + FileStorageDomain.prototype.namesArray = function namesArray() { + return this.names.split(','); + }; + FileStorageDomain.prototype.firstName = function firstName() { + return this.namesArray()[0]; + }; + return FileStorageDomain; +} + +module.exports = define; diff --git a/api/models/file-storage-file.js b/api/models/file-storage-file.js new file mode 100644 index 000000000..fd8ff338a --- /dev/null +++ b/api/models/file-storage-file.js @@ -0,0 +1,57 @@ +function associate({ FileStorageFile, FileStorageService, FileStorageUserAction }) { + FileStorageFile.belongsTo(FileStorageService, { + foreignKey: 'fileStorageServiceId', + }); + + FileStorageFile.hasMany(FileStorageUserAction, { + foreignKey: 'fileStorageFileId', + }); + + FileStorageFile.addScope('getFileActions', (id) => ({ + include: [ + { + model: FileStorageUserAction, + where: { fileStorageFileid: id }, + }, + ], + })); +} + +function define(sequelize, DataTypes) { + const FileStorageFile = sequelize.define( + 'FileStorageFile', + { + key: { + type: DataTypes.STRING, + allowNull: false, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + type: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.STRING, + }, + metadata: { + type: DataTypes.JSON, + }, + }, + { + tableName: 'file_storage_file', + paranoid: true, + }, + ); + + FileStorageFile.associate = associate; + FileStorageFile.getFileActions = (id) => ({ + method: ['getFileActions', id], + }); + + return FileStorageFile; +} + +module.exports = define; diff --git a/api/models/file-storage-service.js b/api/models/file-storage-service.js new file mode 100644 index 000000000..2a6d34085 --- /dev/null +++ b/api/models/file-storage-service.js @@ -0,0 +1,103 @@ +const { toInt } = require('../utils'); + +function associate({ + FileStorageDomain, + FileStorageFile, + FileStorageService, + FileStorageUserAction, + Organization, + Site, +}) { + // Associations + FileStorageService.belongsTo(Organization, { + foreignKey: 'organizationId', + }); + + FileStorageService.belongsTo(Site, { + foreignKey: { name: 'siteId', allowNull: true }, + }); + + FileStorageService.hasMany(FileStorageFile, { + foreignKey: 'fileStorageServiceId', + }); + + FileStorageService.hasOne(FileStorageDomain, { + foreignKey: 'fileStorageServiceId', + }); + + FileStorageService.hasMany(FileStorageUserAction, { + foreignKey: 'fileStorageServiceId', + }); + + // Scopes + FileStorageService.addScope('byId', (search) => { + const id = toInt(search); + + return { + where: { id }, + }; + }); + + FileStorageService.addScope('bySite', (id) => ({ + include: [ + { + model: Site, + required: false, + where: { id }, + }, + ], + })); + + FileStorageService.addScope('byOrg', (id) => ({ + include: [ + { + model: Organization, + required: true, + where: { id }, + }, + ], + })); +} + +function define(sequelize, DataTypes) { + const FileStorageService = sequelize.define( + 'FileStorageService', + { + name: { + type: DataTypes.STRING, + allowNull: false, + }, + serviceId: { + type: DataTypes.STRING, + allowNull: false, + }, + serviceName: { + type: DataTypes.STRING, + allowNull: false, + }, + metadata: { + type: DataTypes.JSON, + }, + }, + { + tableName: 'file_storage_service', + paranoid: true, + }, + ); + + FileStorageService.associate = associate; + + FileStorageService.byId = (id) => ({ + method: ['byId', id], + }); + FileStorageService.siteScope = (siteId) => ({ + method: ['bySite', siteId], + }); + FileStorageService.orgScope = (orgId) => ({ + method: ['byOrg', orgId], + }); + + return FileStorageService; +} + +module.exports = define; diff --git a/api/models/file-storage-user-action.js b/api/models/file-storage-user-action.js new file mode 100644 index 000000000..a1a8b222c --- /dev/null +++ b/api/models/file-storage-user-action.js @@ -0,0 +1,76 @@ +const ACTION_TYPES = [ + 'CREATE_SITE_FILE_STORAGE_SERVICE', + 'CREATE_ORGANIZATION_FILE_STORAGE_SERVICE', + 'CREATE_DIRECTORY', + 'UPLOAD_FILE', + 'RENAME_FILE', + 'MOVE_FILE', + 'DELETE_FILE', +].reduce((acc, cur) => { + return { ...acc, [cur]: cur }; +}, {}); + +const METHODS = ['GET', 'POST', 'PUT', 'DELETE'].reduce((acc, cur) => { + return { ...acc, [cur]: cur }; +}, {}); + +function associate({ + FileStorageFile, + FileStorageService, + FileStorageUserAction, + UAAIdentity, + User, +}) { + FileStorageUserAction.belongsTo(FileStorageService, { + foreignKey: 'fileStorageServiceId', + }); + + FileStorageUserAction.belongsTo(FileStorageFile, { + foreignKey: 'fileStorageFileId', + }); + + FileStorageUserAction.belongsTo(User, { + foreignKey: 'userId', + }); + + FileStorageUserAction.addScope('withUserIdentity', { + include: { + model: User, + required: true, + include: { model: UAAIdentity, required: true }, + }, + }); +} + +function define(sequelize, DataTypes) { + const FileStorageUserAction = sequelize.define( + 'FileStorageUserAction', + { + method: { + type: DataTypes.STRING, + allowNull: false, + }, + description: { + type: DataTypes.STRING, + }, + metadata: { + type: DataTypes.JSON, + }, + }, + { + tableName: 'file_storage_user_action', + paranoid: false, + timestamps: true, + updatedAt: false, + }, + ); + + FileStorageUserAction.associate = associate; + + FileStorageUserAction.ACTION_TYPES = ACTION_TYPES; + FileStorageUserAction.METHODS = METHODS; + + return FileStorageUserAction; +} + +module.exports = define; diff --git a/api/models/index.js b/api/models/index.js index 161117dcb..ad5441964 100644 --- a/api/models/index.js +++ b/api/models/index.js @@ -30,6 +30,10 @@ require('./build')(sequelize, DataTypes); require('./build-log')(sequelize, DataTypes); require('./build-task-type')(sequelize, DataTypes); require('./build-task')(sequelize, DataTypes); +require('./file-storage-domain')(sequelize, DataTypes); +require('./file-storage-file')(sequelize, DataTypes); +require('./file-storage-service')(sequelize, DataTypes); +require('./file-storage-user-action')(sequelize, DataTypes); require('./site')(sequelize, DataTypes); require('./site-branch-config')(sequelize, DataTypes); require('./site-build-task')(sequelize, DataTypes); diff --git a/api/models/organization.js b/api/models/organization.js index d179fcac9..6c2c9d6b3 100644 --- a/api/models/organization.js +++ b/api/models/organization.js @@ -3,13 +3,23 @@ const moment = require('moment'); const { toInt } = require('../utils'); const { sandboxDays } = require('../../config').app; -const associate = ({ Organization, OrganizationRole, Role, Site, User }) => { +const associate = ({ + FileStorageService, + Organization, + OrganizationRole, + Role, + Site, + User, +}) => { // Associations Organization.belongsToMany(User, { through: OrganizationRole, foreignKey: 'organizationId', otherKey: 'userId', }); + Organization.hasMany(FileStorageService, { + foreignKey: 'organizationId', + }); Organization.hasMany(OrganizationRole, { foreignKey: 'organizationId', }); diff --git a/api/models/site.js b/api/models/site.js index 25f0c427e..e4a3aab40 100644 --- a/api/models/site.js +++ b/api/models/site.js @@ -30,6 +30,7 @@ const validationFailed = (site, options, validationError) => { const associate = ({ Build, Domain, + FileStorageService, Organization, OrganizationRole, Site, @@ -46,6 +47,9 @@ const associate = ({ Site.hasMany(Domain, { foreignKey: 'siteId', }); + Site.hasOne(FileStorageService, { + foreignKey: 'siteId', + }); Site.hasMany(SiteBranchConfig, { foreignKey: 'siteId', }); diff --git a/api/models/user.js b/api/models/user.js index 050abf6d3..dd485757d 100644 --- a/api/models/user.js +++ b/api/models/user.js @@ -3,6 +3,7 @@ const { toInt } = require('../utils'); const associate = ({ Build, + FileStorageUserAction, Organization, OrganizationRole, Role, @@ -14,6 +15,9 @@ const associate = ({ User.hasMany(Build, { foreignKey: 'user', }); + User.hasMany(FileStorageUserAction, { + foreignKey: 'userId', + }); User.hasMany(UserAction, { foreignKey: 'userId', as: 'userActions', diff --git a/api/responses/siteErrors.js b/api/responses/siteErrors.js index 5500d93bb..c9e1ca7bc 100644 --- a/api/responses/siteErrors.js +++ b/api/responses/siteErrors.js @@ -7,6 +7,14 @@ module.exports = { NO_ASSOCIATED_ORGANIZATION: 'That user is not associated with this organization.', NO_ASSOCIATED_USER: 'That user is not associated with this site.', ADMIN_ACCESS_REQUIRED: 'You do not have administrative access to this repository', + NOT_FOUND: 'Not found', ORGANIZATION_REQUIRED: 'An organization must be specified when creating a new site', ORGANIZATION_INACTIVE: 'This organization has been deactivated.', + ORGANIZATION_MANAGER_ACCESS: 'You do not have manager access to this organization.', + ORGANIZATION_USER_ACCESS: 'You do not have access to this organization.', + SITE_FILE_STORAGE_EXISTS: + 'The site already has an existing file storage services available.', + SITE_DOES_NOT_EXIST: 'The specified site does not exist', + DIRECTORY_MUST_BE_EMPTIED: + 'This directory cannot be deleted. Please delete file contents first.', }; diff --git a/api/routers/file-storage-service.js b/api/routers/file-storage-service.js new file mode 100644 index 000000000..602e6ee7d --- /dev/null +++ b/api/routers/file-storage-service.js @@ -0,0 +1,38 @@ +const router = require('express').Router(); +const FileStorageServiceController = require('../controllers/file-storage-service'); +const { csrfProtection, multipartForm, sessionAuth } = require('../middlewares'); + +router.use(sessionAuth); +router.use(csrfProtection); + +router.get( + '/file-storage/:file_storage_id/', + FileStorageServiceController.listDirectoryFiles, +); +router.get( + '/file-storage/:file_storage_id/file/:file_id', + FileStorageServiceController.getFile, +); +router.delete( + '/file-storage/:file_storage_id/file/:file_id', + FileStorageServiceController.deleteFile, +); +router.get( + '/file-storage/:file_storage_id/user-actions', + FileStorageServiceController.listUserActions, +); +router.get( + '/file-storage/:file_storage_id/user-actions/:file_id', + FileStorageServiceController.listUserActions, +); +router.post( + '/file-storage/:file_storage_id/directory', + FileStorageServiceController.createDirectory, +); +router.post( + '/file-storage/:file_storage_id/upload', + multipartForm.any(), + FileStorageServiceController.uploadFile, +); + +module.exports = router; diff --git a/api/routers/index.js b/api/routers/index.js index c3be60073..995694bac 100644 --- a/api/routers/index.js +++ b/api/routers/index.js @@ -12,6 +12,7 @@ apiRouter.use(require('./build-log')); apiRouter.use(require('./build-task')); apiRouter.use(require('./build')); apiRouter.use(require('./domain')); +apiRouter.use(require('./file-storage-service')); apiRouter.use(require('./organization')); apiRouter.use(require('./organization-role')); apiRouter.use(require('./published-branch')); diff --git a/api/serializers/file-storage.js b/api/serializers/file-storage.js new file mode 100644 index 000000000..32bbcaffc --- /dev/null +++ b/api/serializers/file-storage.js @@ -0,0 +1,67 @@ +const { pick } = require('../utils'); + +const dateFields = ['createdAt', 'updatedAt']; + +const allowedFileStorageFileFields = [ + 'id', + 'name', + 'description', + 'key', + 'type', + 'metadata', + ...dateFields, +]; + +const serializeFileStorageFile = (serializable) => { + if (!serializable) return {}; + + return pick(allowedFileStorageFileFields, serializable.dataValues); +}; + +const serializeFileStorageFiles = (list) => { + return list.map((i) => serializeFileStorageFile(i)); +}; + +const allowedFileStorageServiceFields = [ + 'id', + 'organizationId', + 'siteId', + 'metadata', + ...dateFields, +]; + +const serializeFileStorageService = (serializable) => { + return pick(allowedFileStorageServiceFields, serializable.dataValues); +}; + +const allowedFileStorageUserActionFields = [ + 'id', + 'fileStorageServiceId', + 'fileStorageFileId', + 'method', + 'description', + 'userId', + 'createdAt', + 'email', +]; + +const serializeFileStorageUserAction = (serializable) => { + const { User, ...rest } = serializable.dataValues; + + const { UAAIdentity } = User.dataValues; + const { email } = UAAIdentity.dataValues; + + return pick(allowedFileStorageUserActionFields, { ...rest, email }); +}; + +const serializeFileStorageUserActions = (list) => { + return list.map((i) => serializeFileStorageUserAction(i)); +}; + +module.exports = { + serializeFileStorageFile, + serializeFileStorageFiles, + serializeFileStorageService, + serializeFileStorageUserAction, + serializeFileStorageUserActions, +}; diff --git a/api/services/S3Helper.js b/api/services/S3Helper.js index 9ece6d54a..f5cf17220 100644 --- a/api/services/S3Helper.js +++ b/api/services/S3Helper.js @@ -5,6 +5,7 @@ const { GetObjectCommand, waitUntilBucketExists, DeleteObjectsCommand, + DeleteObjectCommand, } = require('@aws-sdk/client-s3'); const S3_DEFAULT_MAX_KEYS = 1000; @@ -145,6 +146,16 @@ class S3Client { } } + async deleteObject(key, extras = {}) { + const { bucket, client } = this; + const command = new DeleteObjectCommand({ + Bucket: bucket, + Key: key, + ...extras, + }); + return client.send(command); + } + async putObject(body, key, extras = {}) { const { bucket, client } = this; const command = new PutObjectCommand({ diff --git a/api/services/file-storage/index.js b/api/services/file-storage/index.js new file mode 100644 index 000000000..05a7462a9 --- /dev/null +++ b/api/services/file-storage/index.js @@ -0,0 +1,292 @@ +const path = require('node:path'); +const { Op } = require('sequelize'); +const { + FileStorageService, + FileStorageFile, + FileStorageUserAction, +} = require('../../models'); +const S3Helper = require('../S3Helper'); +const CloudFoundryAPIClient = require('../../utils/cfApiClient'); +const { normalizeDirectoryPath, paginate, slugify } = require('../../utils'); +const { + serializeFileStorageFile, + serializeFileStorageFiles, + serializeFileStorageUserActions, +} = require('../../serializers/file-storage'); +const siteErrors = require('../../responses/siteErrors'); + +const apiClient = new CloudFoundryAPIClient(); + +async function adminCreateSiteFileStorage( + { id: siteId, s3ServiceName, organizationId }, + root = '~assets/', +) { + const serviceInstance = await apiClient.fetchServiceInstance(s3ServiceName); + + const { access_key_id, bucket, region, secret_access_key } = + await apiClient.fetchServiceInstanceCredentials(s3ServiceName); + + const s3Client = new S3Helper.S3Client({ + accessKeyId: access_key_id, + secretAccessKey: secret_access_key, + bucket, + region, + }); + + await s3Client.putObject('', root); + + const fss = await FileStorageService.create({ + siteId, + organizationId, + name: 'site-storage', + serviceId: serviceInstance.guid, + serviceName: serviceInstance.name, + }); + + return fss; +} + +class SiteFileStorageSerivce { + constructor(fileStorageService, userId) { + const { id, organizationId, serviceName, serviceId } = fileStorageService.dataValues; + + this.S3_BASE_PATH = '~assets/'; + this.id = id; + this.organizationId = organizationId; + this.serviceName = serviceName; + this.serviceId = serviceId; + + this.access_key_id = null; + this.bucket = null; + this.region = null; + this.secret_access_key = null; + this.serviceInstance = null; + this.s3Client = null; + this.userId = userId; + + this.initialized = null; + } + + async createClient() { + const serviceInstance = await apiClient.fetchServiceInstance(this.serviceName); + + this.serviceInstance = serviceInstance; + + const { access_key_id, bucket, region, secret_access_key } = + await apiClient.fetchServiceInstanceCredentials(this.serviceName); + + this.access_key_id = access_key_id; + this.bucket = bucket; + this.region = region; + this.secret_access_key = secret_access_key; + + const s3Client = new S3Helper.S3Client({ + accessKeyId: access_key_id, + secretAccessKey: secret_access_key, + bucket, + region, + }); + + this.s3Client = s3Client; + this.initialized = true; + + return this; + } + + async createAssetRoot() { + this.#isInitialized(); + + return this.s3Client.putObject('', this.S3_BASE_PATH); + } + + async createDirectory(parent, name) { + const directoryName = slugify(name); + const directoryPath = this.#buildKeyPath(`${parent}/${directoryName}`); + + await this.s3Client.putObject('', directoryPath); + + const fsf = await FileStorageFile.create({ + name, + key: directoryPath, + type: 'directory', + fileStorageServiceId: this.id, + description: 'directory', + }); + + await FileStorageUserAction.create({ + userId: this.userId, + fileStorageServiceId: this.id, + fileStorageFileId: fsf.id, + method: FileStorageUserAction.METHODS.POST, + description: FileStorageUserAction.ACTION_TYPES.CREATE_DIRECTORY, + }); + + return fsf; + } + + async createFileStorageService() { + this.#isInitialized(); + + await this.createAssetRoot(); + + const fss = await FileStorageService.create({ + siteId: this.id, + organizationId: this.organizationId, + name: 'site-storage', + serviceId: this.serviceInstance.guid, + serviceName: this.serviceInstance.name, + }); + + this.id = fss.id; + + return fss; + } + + async deleteFile(id) { + const record = await FileStorageFile.findOne({ + where: { id, fileStorageServiceId: this.id }, + }); + + if (!record) { + return null; + } + + if (record.type === 'directory') { + const children = await FileStorageFile.count({ + where: { + fileStorageServiceId: this.id, + key: { [Op.like]: `${record.key}%` }, + }, + }); + + if (children > 1) { + return { + message: siteErrors.DIRECTORY_MUST_BE_EMPTIED, + }; + } + } + + await this.s3Client.deleteObject(record.key); + + const result = await record.destroy(); + + await FileStorageUserAction.create({ + userId: this.userId, + fileStorageServiceId: this.id, + fileStorageFileId: id, + method: FileStorageUserAction.METHODS.DELETE, + description: FileStorageUserAction.ACTION_TYPES.DELETE_FILE, + }); + + return result; + } + + async getFile(id) { + const record = await FileStorageFile.findOne({ + where: { id, fileStorageServiceId: this.id }, + }); + + return serializeFileStorageFile(record); + } + + async listUserActions({ fileStorageFileId = null, limit = 50, page = 1 } = {}) { + const order = [['createdAt', 'DESC']]; + + const where = { + fileStorageServiceId: this.id, + ...(fileStorageFileId && { fileStorageFileId }), + }; + + const results = await paginate( + FileStorageUserAction.scope(['withUserIdentity']), + serializeFileStorageUserActions, + { + limit, + page, + }, + { + where, + order, + }, + ); + + return results; + } + + async listDirectoryFiles( + directory, + { limit = 50, page = 1, order = [['name', 'ASC']] } = {}, + ) { + const key = this.#buildKeyPath(directory); + + const results = await paginate( + FileStorageFile, + serializeFileStorageFiles, + { + limit, + page, + }, + { + where: { + fileStorageServiceId: this.id, + [Op.and]: [ + { key: { [Op.like]: `${key}%` } }, + { key: { [Op.notLike]: `${key}%/_%` } }, + { key: { [Op.ne]: key } }, + ], + }, + order, + }, + ); + + return results; + } + + async uploadFile(name, fileBuffer, type, parent, metadata = {}) { + const filename = slugify(name); + const directoryPath = this.#buildKeyPath(parent); + const key = path.join(directoryPath, filename); + + await this.s3Client.putObject(fileBuffer, key); + + const fsf = await FileStorageFile.create({ + name, + key, + type: type, + metadata, + fileStorageServiceId: this.id, + }); + + await FileStorageUserAction.create({ + userId: this.userId, + fileStorageServiceId: this.id, + fileStorageFileId: fsf.id, + method: FileStorageUserAction.METHODS.POST, + description: FileStorageUserAction.ACTION_TYPES.UPLOAD_FILE, + }); + + return fsf; + } + + #buildKeyPath(keyPath) { + const normalized = normalizeDirectoryPath(keyPath); + const root = normalized.split('/').filter((x) => x)[0]; + + if (`${root}/` !== this.S3_BASE_PATH) { + return path.join(this.S3_BASE_PATH, normalized); + } + + return normalized; + } + + #isInitialized() { + if (!this.initialized) { + throw Error('Initialize the class instance with `await instance.createClient()`'); + } + } +} + +module.exports = { + adminCreateSiteFileStorage, + SiteFileStorageSerivce, +}; diff --git a/api/utils/index.js b/api/utils/index.js index 4963e369e..2a91cf31b 100644 --- a/api/utils/index.js +++ b/api/utils/index.js @@ -216,7 +216,6 @@ async function paginate(model, serialize, params, query = {}) { order: [['createdAt', 'DESC']], ...query, }; - const { rows, count } = await model.findAndCountAll(pQuery); const totalPages = Math.trunc(count / limit) + (count % limit === 0 ? 0 : 1); @@ -303,6 +302,60 @@ function appMatch(type) { return type.metadata.appName.replace(/pages-(.*)-task-.*/, '$1'); } +function splitFileExt(str, separator = '.') { + const lastIndex = str.lastIndexOf(separator); + if (lastIndex === -1) { + return [str]; + } + const before = str.slice(0, lastIndex); + const after = str.slice(lastIndex + 1); + return [before, after]; +} + +function slugify(text, len = 200) { + const argType = typeof text; + + if (!['string', 'number'].includes(argType)) { + throw new Error('Text must be a string or number.'); + } + + const str = text.toString(); + + const [base, extension] = splitFileExt(str); + + if (str.length > len) { + throw new Error(`Text must be less than or equal to ${len} characters.`); + } + + const slugifiedBase = base + .normalize('NFD') // Normalize to decompose combined characters + .replace(/[\u0300-\u036f]/g, '') // Remove diacritics + .toLowerCase() // Convert to lowercase + .trim() // Trim whitespace from both ends + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/[^\w-]+/g, '') // Remove all non-word chars + .replace(/--+/g, '-') // Replace multiple hyphens with a single hyphen + .replace(/^-+/, '') // Trim hyphens from the start + // eslint-disable-next-line sonarjs/slow-regex + .replace(/-+$/, ''); // Trim hyphens from the end + + return extension ? `${slugifiedBase}.${extension}` : slugifiedBase; +} + +function normalizeDirectoryPath(dir) { + let normalized = path.normalize(dir); + + if (normalized.startsWith('/') && normalized.endsWith('/')) { + normalized = normalized.slice(1); + } + + if (!normalized.endsWith('/')) { + normalized += '/'; + } + + return normalized; +} + module.exports = { appMatch, buildEnum, @@ -315,11 +368,13 @@ module.exports = { loadDevelopmentManifest, loadProductionManifest, mapValues, + normalizeDirectoryPath, omitBy, omit, paginate, pick, shouldIncludeTracking, + slugify, toInt, toSubdomainPart, truncateString, diff --git a/migrations/20250116164506-add-file-storage-model-columns.js b/migrations/20250116164506-add-file-storage-model-columns.js new file mode 100644 index 000000000..bef26fa59 --- /dev/null +++ b/migrations/20250116164506-add-file-storage-model-columns.js @@ -0,0 +1,48 @@ +const TABLE_NAME = 'file_storage_service'; +const SERVICE_NAME_COLUMN_NAME = 'serviceName'; +const SERVICE_ID_COLUMN_NAME = 'serviceId'; +const COLUMN_TYPE = { + type: 'string', + allowNull: true, +}; + +const SITE_ID_COLUMN_NAME = 'siteId'; +const SITE_ID_UNIQUE_INDEX_NAME = `${TABLE_NAME}_unique_site_id_idx`; + +const USER_ACTION_TABLE_NAME = 'file_storage_user_action'; +const USER_ACTION_DESCRIPTION_COLUMN_NAME = 'description'; +const USER_ACTION_DESCRIPTION_OLD_COLUMN_NAME = 'desciption'; + +const FILE_TABLE_NAME = 'file_storage_file'; +const FILE_TABLE_NAME_TYPE_COLUMN = 'type'; +const FILE_TABLE_KEY_COLUMN = 'key'; +const FILE_TABLE_NAME_KEY_INDEX = 'file_storage_file_key_idx'; + +exports.up = async (db) => { + await db.addColumn(TABLE_NAME, SERVICE_NAME_COLUMN_NAME, COLUMN_TYPE); + await db.addColumn(TABLE_NAME, SERVICE_ID_COLUMN_NAME, COLUMN_TYPE); + await db.addIndex(TABLE_NAME, SITE_ID_UNIQUE_INDEX_NAME, SITE_ID_COLUMN_NAME, true); + await db.renameColumn( + USER_ACTION_TABLE_NAME, + USER_ACTION_DESCRIPTION_OLD_COLUMN_NAME, + USER_ACTION_DESCRIPTION_COLUMN_NAME, + ); + await db.addColumn(FILE_TABLE_NAME, FILE_TABLE_NAME_TYPE_COLUMN, { + type: 'string', + notNull: true, + }); + await db.addIndex(FILE_TABLE_NAME, FILE_TABLE_NAME_KEY_INDEX, FILE_TABLE_KEY_COLUMN); +}; + +exports.down = async (db) => { + await db.removeIndex(FILE_TABLE_NAME, FILE_TABLE_NAME_KEY_INDEX); + await db.removeColumn(FILE_TABLE_NAME, FILE_TABLE_NAME_TYPE_COLUMN); + await db.renameColumn( + USER_ACTION_TABLE_NAME, + USER_ACTION_DESCRIPTION_COLUMN_NAME, + USER_ACTION_DESCRIPTION_OLD_COLUMN_NAME, + ); + await db.removeIndex(TABLE_NAME, SITE_ID_UNIQUE_INDEX_NAME); + await db.removeColumn(TABLE_NAME, SERVICE_NAME_COLUMN_NAME); + await db.removeColumn(TABLE_NAME, SERVICE_ID_COLUMN_NAME); +}; diff --git a/package.json b/package.json index fc3ff2089..3f8a162f9 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "jsonwebtoken": "^9.0.2", "lodash.merge": "^4.6.2", "moment": "^2.29.2", + "multer": "^1.4.5-lts.1", "nunjucks": "^3.2.4", "passport": "^0.7.0", "passport-github": "^1.1.0", @@ -100,7 +101,9 @@ "migrate:create": "node migrate.js create", "migrate:up": "node migrate.js up || true", "migrate:down": "node migrate.js down", - "migrate:test": "node migrate.js up --dry-run", + "migrate:test": "NODE_ENV=test node migrate.js up --dry-run", + "migrate:test:up": "NODE_ENV=test node migrate.js up", + "migrate:test:down": "NODE_ENV=test node migrate.js down", "migrate:reset": "node migrate.js reset", "export:sites": "node ./scripts/exportSitesAsCsv.js", "serve-coverage": "serve -n -l 8080 ./coverage", diff --git a/public/swagger/FileStorageFile.json b/public/swagger/FileStorageFile.json new file mode 100644 index 000000000..1476a4193 --- /dev/null +++ b/public/swagger/FileStorageFile.json @@ -0,0 +1,32 @@ +{ + "type": "object", + "required": ["id", "metadata", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "description": { + "oneOf": [{ "type": ["null"] }, { "type": ["string"] }] + }, + "metadata": { + "type": "json" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/public/swagger/FileStorageList.json b/public/swagger/FileStorageList.json new file mode 100644 index 000000000..93bb514ff --- /dev/null +++ b/public/swagger/FileStorageList.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "required": ["currentPage", "totalPages", "totalItems", "data"], + "properties": { + "currentPage": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + }, + "totalItems": { + "type": "integer" + }, + "data": { + "type": "array", + "items": { + "$ref": "FileStorageFile.json" + } + } + } +} diff --git a/public/swagger/FileStorageService.json b/public/swagger/FileStorageService.json new file mode 100644 index 000000000..aa061bde2 --- /dev/null +++ b/public/swagger/FileStorageService.json @@ -0,0 +1,20 @@ +{ + "type": "object", + "required": ["id", "metadata", "createdAt", "updatedAt"], + "properties": { + "id": { + "type": "integer" + }, + "metadata": { + "type": "json" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/public/swagger/FileStorageUserAction.json b/public/swagger/FileStorageUserAction.json new file mode 100644 index 000000000..2ee691d84 --- /dev/null +++ b/public/swagger/FileStorageUserAction.json @@ -0,0 +1,43 @@ +{ + "type": "object", + "required": [ + "id", + "fileStorageServiceId", + "fileStorageFileId", + "userId", + "email", + "method", + "description", + "createdAt" + ], + "properties": { + "id": { + "type": "integer" + }, + "fileStorageServiceId": { + "type": "integer" + }, + "fileStorageFileId": { + "type": "integer" + }, + "userId": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "type": { + "type": "string" + }, + "method": { + "type": "string" + }, + "description": { + "oneOf": [{ "type": ["null"] }, { "type": ["string"] }] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/public/swagger/FileStorageUserActionList.json b/public/swagger/FileStorageUserActionList.json new file mode 100644 index 000000000..630c311ba --- /dev/null +++ b/public/swagger/FileStorageUserActionList.json @@ -0,0 +1,21 @@ +{ + "type": "object", + "required": ["currentPage", "totalPages", "totalItems", "data"], + "properties": { + "currentPage": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + }, + "totalItems": { + "type": "integer" + }, + "data": { + "type": "array", + "items": { + "$ref": "FileStorageUserAction.json" + } + } + } +} diff --git a/public/swagger/index.yml b/public/swagger/index.yml index ca11a40fa..01bfd325b 100644 --- a/public/swagger/index.yml +++ b/public/swagger/index.yml @@ -279,6 +279,191 @@ paths: description: Not found schema: $ref: 'Error.json' + /file-storage/{file_storage_id}: + parameters: + - name: file_storage_id + in: path + description: The id of the file storage service + type: integer + required: true + get: + summary: Returns a list of files in a file storage service path + responses: + 200: + description: A list of files + schema: + $ref: 'FileStorageList.json' + 400: + description: Bad request + schema: + $ref: 'Error.json' + 403: + description: Not authorized + schema: + $ref: 'Error.json' + 404: + description: Not found + schema: + $ref: 'Error.json' + /file-storage/{file_storage_id}/file/{file_id}: + parameters: + - name: file_storage_id + in: path + description: The id of the file storage service + type: integer + required: true + - name: file_id + in: path + description: The id of the file storage file + type: integer + required: true + get: + summary: Returns a file storage file + responses: + 200: + description: A list of files + schema: + $ref: 'FileStorageFile.json' + 400: + description: Bad request + schema: + $ref: 'Error.json' + 403: + description: Not authorized + schema: + $ref: 'Error.json' + 404: + description: Not found + schema: + $ref: 'Error.json' + delete: + summary: Deletes a file storage file + responses: + 200: + description: An empty object + schema: + type: object + 400: + description: Bad request + schema: + $ref: 'Error.json' + 403: + description: Not authorized + schema: + $ref: 'Error.json' + 404: + description: Not found + schema: + $ref: 'Error.json' + /file-storage/{file_storage_id}/user-actions: + parameters: + - name: file_storage_id + in: path + description: The id of the file storage service + type: integer + required: true + get: + summary: Returns a list of user actions in a file storage service path + responses: + 200: + description: Lists the user actions for a file storage service + schema: + $ref: 'FileStorageUserActionList.json' + 400: + description: Bad request + schema: + $ref: 'Error.json' + 403: + description: Not authorized + schema: + $ref: 'Error.json' + 404: + description: Not found + schema: + $ref: 'Error.json' + /file-storage/{file_storage_id}/user-actions/{file_id}: + parameters: + - name: file_storage_id + in: path + description: The id of the file storage service + type: integer + required: true + - name: file_id + in: path + description: The id of the file storage file + type: integer + required: true + get: + summary: Returns a list of user actions in a file storage file + responses: + 200: + description: Lists the user actions for a file storage file + schema: + $ref: 'FileStorageUserActionList.json' + 400: + description: Bad request + schema: + $ref: 'Error.json' + 403: + description: Not authorized + schema: + $ref: 'Error.json' + 404: + description: Not found + schema: + $ref: 'Error.json' + /file-storage/{file_storage_id}/directory: + parameters: + - name: file_storage_id + in: path + description: The id of the file storage service + type: integer + required: true + post: + summary: Creates a directory given a name and directory path + responses: + 200: + description: Acknowledgement that the directory was created + schema: + $ref: 'FileStorageFile.json' + 400: + description: Bad request + schema: + $ref: 'Error.json' + 403: + description: Not authorized + schema: + $ref: 'Error.json' + 404: + description: Not found + schema: + $ref: 'Error.json' + /file-storage/{file_storage_id}/upload: + parameters: + - name: file_storage_id + in: path + description: The id of the file storage service + type: integer + required: true + post: + summary: Uploads a file given a name, directory path, and file + responses: + 200: + description: Acknowledgement that the file was uploaded + schema: + $ref: 'FileStorageFile.json' + 400: + description: Bad request + schema: + $ref: 'Error.json' + 403: + description: Not authorized + schema: + $ref: 'Error.json' + 404: + description: Not found + schema: + $ref: 'Error.json' /organization: get: summary: Fetch all of the current user's organizations @@ -775,6 +960,31 @@ paths: description: Not found schema: $ref: 'Error.json' + /site/{site_id}/file-storage-service: + post: + summary: Create a site file storage service if the current user has permissions + parameters: + - name: site_id + in: path + description: the id of the site + type: integer + responses: + 200: + description: The create site file storage service + schema: + $ref: 'FileStorageService.json' + 400: + description: Bad request + schema: + $ref: 'Error.json' + 403: + description: Not authorized + schema: + $ref: 'Error.json' + 404: + description: Not found + schema: + $ref: 'Error.json' /site/{site_id}/published-branch: parameters: - name: site_id diff --git a/test/api/admin/requests/file-storage.test.js b/test/api/admin/requests/file-storage.test.js new file mode 100644 index 000000000..a711731d6 --- /dev/null +++ b/test/api/admin/requests/file-storage.test.js @@ -0,0 +1,84 @@ +const { expect } = require('chai'); +const request = require('supertest'); +const sinon = require('sinon'); +const app = require('../../../../api/admin'); +const { authenticatedAdminOrSupportSession } = require('../../support/session'); +const sessionConfig = require('../../../../api/admin/sessionConfig'); +const factory = require('../../support/factory'); +const csrfToken = require('../../support/csrfToken'); +const config = require('../../../../config'); +const EventCreator = require('../../../../api/services/EventCreator'); +const siteErrors = require('../../../../api/responses/siteErrors'); +const { + createFileStorageServiceClient, + stubSiteS3, +} = require('../../support/file-storage-service'); + +describe('Admin File-Storage API', () => { + beforeEach(async () => { + sinon.stub(EventCreator, 'error').resolves(); + await factory.organization.truncate(); + }); + + afterEach(async () => { + sinon.restore(); + await factory.organization.truncate(); + }); + + describe('Unauthorized domain route requests', async () => { + const routes = [ + { + method: 'post', + path: '/site/:id/file-storage', + }, + ]; + + it('should block all unauthenticated actions', async () => { + await Promise.all( + routes.map(async (route) => { + const response = await request(app)[route.method](route.path).expect(401); + expect(response.body.message).to.equal('Unauthorized'); + }), + ); + }); + }); + + describe('POST /admin/site/:site_id/file-storage', () => { + it('should require admin authentication', async () => { + const response = await request(app)['get']('/sites/:id/file-storage').expect(401); + expect(response.body.message).to.equal('Unauthorized'); + }); + + it('returns a 200 with successfull site file storage service creation', async () => { + const user = await factory.user(); + const cookie = await authenticatedAdminOrSupportSession(user, sessionConfig); + const { site } = await stubSiteS3(); + + const { body } = await request(app) + .post(`/sites/${site.id}/file-storage`) + .set('Cookie', cookie) + .set('Origin', config.app.adminHostname) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.siteId).to.be.eq(site.id); + }); + + it('returns a 403 with an existing site file storage service', async () => { + const user = await factory.user(); + const cookie = await authenticatedAdminOrSupportSession(user, sessionConfig); + const { site } = await createFileStorageServiceClient(); + + const { body } = await request(app) + .post(`/sites/${site.id}/file-storage`) + .set('Cookie', cookie) + .set('Origin', config.app.adminHostname) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(403); + + expect(body.message).to.be.eq(siteErrors.SITE_FILE_STORAGE_EXISTS); + }); + }); +}); diff --git a/test/api/requests/file-storage-service.test.js b/test/api/requests/file-storage-service.test.js new file mode 100644 index 000000000..30a40aa13 --- /dev/null +++ b/test/api/requests/file-storage-service.test.js @@ -0,0 +1,906 @@ +const path = require('node:path'); +const { expect } = require('chai'); +const request = require('supertest'); +const sinon = require('sinon'); +const factory = require('../support/factory'); +const csrfToken = require('../support/csrfToken'); +const { authenticatedSession } = require('../support/session'); +const validateAgainstJSONSchema = require('../support/validateAgainstJSONSchema'); +const app = require('../../../app'); +const EventCreator = require('../../../api/services/EventCreator'); +const { stubSiteS3 } = require('../support/file-storage-service'); +const S3Helper = require('../../../api/services/S3Helper'); + +describe('File Storgage API', () => { + beforeEach(async () => { + sinon.stub(EventCreator, 'error').resolves(); + await factory.organization.truncate(); + }); + + afterEach(async () => { + sinon.restore(); + await factory.organization.truncate(); + }); + + describe('GET /v0/file-storage/:file_storage_id', () => { + const endpoint = '/file-storage/{file_storage_id}'; + + it('returns a 403', async () => { + const fssId = 1; + + const { body } = await request(app) + .get(`/v0/file-storage/${fssId}`) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + it('must be a org user', async () => { + const nonOrgUser = await factory.user(); + const { site, org } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(nonOrgUser); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + describe('when a user lists directory', () => { + it('returns a 200 when empty', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + + it('returns a list of items in the directory path', async () => { + const { site, org, user } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const dir = '~assets/b/c/'; + const subdir = `${dir}/d/`; + const expectedList = await factory.fileStorageFile.createBulk(fss.id, dir, { + files: 10, + directories: 2, + }); + const expectedCount = expectedList.files.length + expectedList.directories.length; + await factory.fileStorageFile.createBulk(fss.id, subdir, { + files: 2, + directories: 1, + }); + + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}?path=${dir}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.currentPage).to.be.eq(1); + expect(body.totalPages).to.be.eq(1); + expect(body.data.length).to.be.eq(expectedCount); + expect(body.totalItems).to.be.eq(expectedCount); + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + + it('returns a list of items in the default root directory', async () => { + const { site, org, user } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const dir = '~assets/'; + const subdir = `${dir}/d/`; + const expectedList = await factory.fileStorageFile.createBulk(fss.id, dir, { + files: 10, + directories: 2, + }); + const expectedCount = expectedList.files.length + expectedList.directories.length; + await factory.fileStorageFile.createBulk(fss.id, subdir, { + files: 2, + directories: 1, + }); + + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.currentPage).to.be.eq(1); + expect(body.totalPages).to.be.eq(1); + expect(body.data.length).to.be.eq(expectedCount); + expect(body.totalItems).to.be.eq(expectedCount); + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + }); + + it('returns page 2 with a page size of 2 from the list', async () => { + const { site, org, user } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const dir = '~assets/a/'; + const subdir = `${dir}/b/`; + const page = 2; + const limit = 2; + const expectedList = await factory.fileStorageFile.createBulk(fss.id, dir, { + files: 10, + directories: 2, + }); + const expectedCount = expectedList.files.length + expectedList.directories.length; + await factory.fileStorageFile.createBulk(fss.id, subdir, { + files: 2, + directories: 1, + }); + + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}?path=${dir}&limit=${limit}&page=${page}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.currentPage).to.be.eq(page); + expect(body.totalPages).to.be.eq(expectedCount / limit); + expect(body.data.length).to.be.eq(limit); + expect(body.totalItems).to.be.eq(expectedCount); + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + }); + + describe('GET /v0/file-storage/:file_storage_id/file/:file_id', () => { + const endpoint = '/file-storage/{file_storage_id}/file/{file_id}'; + + it('returns a 403', async () => { + const fssId = 1; + + const { body } = await request(app) + .get(`/v0/file-storage/${fssId}/file/123`) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + it('must be a org user', async () => { + const nonOrgUser = await factory.user(); + const { site, org } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(nonOrgUser); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/file/123`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + describe('when a user gets a file', () => { + it('returns a 404 when no file exists', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/file/123`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(404); + + validateAgainstJSONSchema('GET', endpoint, 404, body); + }); + + it('returns a 404 when file exists in differnt file service', async () => { + const { site, org, user } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const otherFss = await factory.fileStorageService.create(); + const file = await factory.fileStorageFile.create({ + fileStorageServiceId: otherFss.id, + }); + + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/file/${file.id}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(404); + + validateAgainstJSONSchema('GET', endpoint, 404, body); + }); + + it('returns 200 with a file', async () => { + const { site, org, user } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const file = await factory.fileStorageFile.create({ + fileStorageServiceId: fss.id, + }); + + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/file/${file.id}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.id).to.be.eq(file.id); + expect(body.name).to.be.eq(file.name); + expect(body.description).to.be.eq(file.description); + expect(body.key).to.be.eq(file.key); + expect(body.type).to.be.eq(file.type); + expect(body.metadata).to.be.eq(file.metadata); + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + }); + }); + + describe('DELETE /v0/file-storage/:file_storage_id/file/:file_id', () => { + const endpoint = '/file-storage/{file_storage_id}/file/{file_id}'; + + it('returns a 403', async () => { + const fssId = 1; + + const { body } = await request(app) + .delete(`/v0/file-storage/${fssId}/file/123`) + .type('json') + .expect(403); + + validateAgainstJSONSchema('DELETE', endpoint, 403, body); + }); + + it('must be a org user', async () => { + const nonOrgUser = await factory.user(); + const { site, org } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(nonOrgUser); + + const { body } = await request(app) + .delete(`/v0/file-storage/${fss.id}/file/123`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(403); + + validateAgainstJSONSchema('DELETE', endpoint, 403, body); + }); + + describe('when a user deletes a file', () => { + it('returns a 404 when no file exists', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .delete(`/v0/file-storage/${fss.id}/file/123`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(404); + + validateAgainstJSONSchema('DELETE', endpoint, 404, body); + }); + + it('returns a 404 when file exists in differnt file service', async () => { + const { site, org, user } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const otherFss = await factory.fileStorageService.create(); + const file = await factory.fileStorageFile.create({ + fileStorageServiceId: otherFss.id, + }); + + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .delete(`/v0/file-storage/${fss.id}/file/${file.id}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(404); + + validateAgainstJSONSchema('DELETE', endpoint, 404, body); + }); + + it('returns 200 with a file', async () => { + const { site, org, user } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const file = await factory.fileStorageFile.create({ + fileStorageServiceId: fss.id, + }); + + const cookie = await authenticatedSession(user); + + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'deleteObject').resolves(); + + const { body } = await request(app) + .delete(`/v0/file-storage/${fss.id}/file/${file.id}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(s3stub.calledOnceWith(body.key)).to.be.eq(true); + expect(body.id).to.be.eq(file.id); + expect(body.name).to.be.eq(file.name); + expect(body.description).to.be.eq(file.description); + expect(body.key).to.be.eq(file.key); + expect(body.type).to.be.eq(file.type); + expect(body.metadata).to.be.eq(file.metadata); + validateAgainstJSONSchema('DELETE', endpoint, 200, body); + }); + }); + }); + + describe('GET /v0/file-storage/:file_storage_id/user-actions', () => { + const endpoint = '/file-storage/{file_storage_id}/user-actions'; + + it('returns a 403', async () => { + const fssId = 1; + + const { body } = await request(app) + .get(`/v0/file-storage/${fssId}/user-actions`) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + it('must be user in the org', async () => { + const nonOrgUser = await factory.user(); + const { site, org } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(nonOrgUser); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + it('must be an org manager', async () => { + const { site, org, user } = await stubSiteS3({ roleName: 'user' }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + describe('when a user lists directory', () => { + it('returns a 200 when empty', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + + it('returns a list of items in the directory path', async () => { + const { site, org, user } = await stubSiteS3({ roleName: 'manager' }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const fileActions1 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const fileActions2 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const expectedCount = fileActions1.length + fileActions2.length; + + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.currentPage).to.be.eq(1); + expect(body.totalPages).to.be.eq(1); + expect(body.data.length).to.be.eq(expectedCount); + expect(body.totalItems).to.be.eq(expectedCount); + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + + it('returns page 2 with a page size of 2 from the list', async () => { + const limit = 2; + const page = 2; + const { site, org, user } = await stubSiteS3({ roleName: 'manager' }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const fileActions1 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const fileActions2 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const expectedCount = fileActions1.length + fileActions2.length; + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions?limit=${limit}&page=${page}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.currentPage).to.be.eq(page); + expect(body.totalPages).to.be.eq(expectedCount / limit); + expect(body.data.length).to.be.eq(limit); + expect(body.totalItems).to.be.eq(expectedCount); + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + }); + }); + + describe('GET /v0/file-storage/:file_storage_id/user-actions/:file_id', () => { + const endpoint = '/file-storage/{file_storage_id}/user-actions/{file_id}'; + + it('returns a 403', async () => { + const fssId = 1; + + const { body } = await request(app) + .get(`/v0/file-storage/${fssId}/user-actions/123`) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + it('must be user in the org', async () => { + const nonOrgUser = await factory.user(); + const { site, org } = await stubSiteS3(); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(nonOrgUser); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions/123`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + it('must be an org manager', async () => { + const { site, org, user } = await stubSiteS3({ roleName: 'user' }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions/123`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(403); + + validateAgainstJSONSchema('GET', endpoint, 403, body); + }); + + describe('when a user lists directory', () => { + it('returns a 200 when empty', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions/123`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + + it('returns a list of items in the directory path', async () => { + const { site, org, user } = await stubSiteS3({ roleName: 'manager' }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const fileActions1 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const fileId = fileActions1[0].fileStorageFileId; + await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const expectedCount = fileActions1.length; + + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions/${fileId}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.currentPage).to.be.eq(1); + expect(body.totalPages).to.be.eq(1); + expect(body.data.length).to.be.eq(expectedCount); + expect(body.totalItems).to.be.eq(expectedCount); + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + + it('returns page 2 with a page size of 2 from the list', async () => { + const limit = 2; + const page = 2; + const { site, org, user } = await stubSiteS3({ roleName: 'manager' }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + + const fileActions1 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const fileId = fileActions1[0].fileStorageFileId; + await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const expectedCount = fileActions1.length; + const cookie = await authenticatedSession(user); + + const query = `limit=${limit}&page=${page}`; + const { body } = await request(app) + .get(`/v0/file-storage/${fss.id}/user-actions/${fileId}?${query}`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .expect(200); + + expect(body.currentPage).to.be.eq(page); + expect(body.totalPages).to.be.eq(expectedCount / limit); + expect(body.data.length).to.be.eq(limit); + expect(body.totalItems).to.be.eq(expectedCount); + validateAgainstJSONSchema('GET', endpoint, 200, body); + }); + }); + }); + + describe('POST /v0/file-storage/:file_storage_id/directory', () => { + const endpoint = '/file-storage/{file_storage_id}/directory'; + + describe('when the user is not authenticated', () => { + it('returns a 403', async () => { + const fssId = 1; + + const { body } = await request(app) + .post(`/v0/file-storage/${fssId}/directory`) + .type('json') + .expect(403); + + validateAgainstJSONSchema('POST', endpoint, 403, body); + }); + }); + + describe('when there is no csrf token', () => { + it('returns a 403', async () => { + const siteId = 1; + const user = await factory.user(); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .post(`/v0/file-storage/${siteId}/directory`) + .set('Cookie', cookie) + .type('json') + .expect(403); + + validateAgainstJSONSchema('POST', endpoint, 403, body); + }); + }); + + describe('when a user creates a valid directory', () => { + it('returns a 200', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + const payload = { parent: 'cool', name: 'runnings' }; + + const { body } = await request(app) + .post(`/v0/file-storage/${fss.id}/directory`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .type('json') + .send(payload) + .expect(200); + + validateAgainstJSONSchema('POST', endpoint, 200, body); + }); + }); + }); + + describe('POST /v0/file-storage/:file_storage_id/upload', () => { + const endpoint = '/file-storage/{file_storage_id}/upload'; + + describe('when the user is not authenticated', () => { + it('returns a 403', async () => { + const fssId = 1; + + const { body } = await request(app) + .post(`/v0/file-storage/${fssId}/upload`) + .expect(403); + + validateAgainstJSONSchema('POST', endpoint, 403, body); + }); + }); + + describe('when there is no csrf token', () => { + it('returns a 403', async () => { + const siteId = 1; + const user = await factory.user(); + const cookie = await authenticatedSession(user); + + const { body } = await request(app) + .post(`/v0/file-storage/${siteId}/upload`) + .set('Cookie', cookie) + .expect(403); + + validateAgainstJSONSchema('POST', endpoint, 403, body); + }); + }); + + describe('when a user uploads a valid file', () => { + it('returns a 200', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + const name = 'test.txt'; + const parent = 'parent/'; + + const { body } = await request(app) + .post(`/v0/file-storage/${fss.id}/upload`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .field('name', name) + .field('parent', parent) + .attach('file', path.join(__dirname, '../support/fixtures/lorem.txt')) + .expect(200); + + validateAgainstJSONSchema('POST', endpoint, 200, body); + }); + }); + + describe('when a user uploads a file without name field', () => { + it('returns a 400', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + const parent = 'parent/'; + + const { body } = await request(app) + .post(`/v0/file-storage/${fss.id}/upload`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .field('parent', parent) + .attach('file', path.join(__dirname, '../support/fixtures/lorem.txt')) + .expect(400); + + validateAgainstJSONSchema('POST', endpoint, 400, body); + }); + }); + + describe('when a user uploads a file without parent field', () => { + it('returns a 400', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + const name = 'test.txt'; + + const { body } = await request(app) + .post(`/v0/file-storage/${fss.id}/upload`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .field('name', name) + .attach('file', path.join(__dirname, '../support/fixtures/lorem.txt')) + .expect(400); + + validateAgainstJSONSchema('POST', endpoint, 400, body); + }); + }); + + describe('when a user does not upload a file', () => { + it('returns a 400', async () => { + const { site, org, user } = await stubSiteS3({ + roleName: 'manager', + }); + const fss = await factory.fileStorageService.create({ + siteId: site.id, + serviceName: site.s3ServiceName, + org, + }); + const cookie = await authenticatedSession(user); + const name = 'test.txt'; + const parent = 'parent/'; + + const { body } = await request(app) + .post(`/v0/file-storage/${fss.id}/upload`) + .set('Cookie', cookie) + .set('x-csrf-token', csrfToken.getToken()) + .field('name', name) + .field('parent', parent) + .expect(400); + + validateAgainstJSONSchema('POST', endpoint, 400, body); + }); + }); + }); +}); diff --git a/test/api/requests/published-branch.test.js b/test/api/requests/published-branch.test.js index e773d69ec..5ddb07723 100644 --- a/test/api/requests/published-branch.test.js +++ b/test/api/requests/published-branch.test.js @@ -155,7 +155,7 @@ describe('Published Branches API', () => { expect(branchNames).to.deep.equal([site.defaultBranch, site.demoBranch, 'abc']); }); - it('should 403 if the user is not associated with the site', (done) => { + it('should 404 if the user is not associated with the site', (done) => { const user = factory.user(); const site = factory.site(); const cookie = authenticatedSession(user); @@ -169,7 +169,7 @@ describe('Published Branches API', () => { request(app) .get(`/v0/site/${promisedValues.site.id}/published-branch`) .set('Cookie', promisedValues.cookie) - .expect(403), + .expect(404), ) .then((response) => { validateAgainstJSONSchema( @@ -258,7 +258,7 @@ describe('Published Branches API', () => { expect(response.body.name).to.equal('main'); }); - it('should 403 if the user is not associated with the site', (done) => { + it('should 404 if the user is not associated with the site', (done) => { const user = factory.user(); const site = factory.site({ defaultBranch: 'main', @@ -274,7 +274,7 @@ describe('Published Branches API', () => { request(app) .get(`/v0/site/${promisedValues.site.id}/published-branch/main`) .set('Cookie', promisedValues.cookie) - .expect(403), + .expect(404), ) .then((response) => { validateAgainstJSONSchema( diff --git a/test/api/requests/published-file.test.js b/test/api/requests/published-file.test.js index be40222ab..f25774584 100644 --- a/test/api/requests/published-file.test.js +++ b/test/api/requests/published-file.test.js @@ -155,7 +155,7 @@ describe('Published Files API', () => { ); }); - it('should 403 if the user is not associated with the site', (done) => { + it('should 404 if the user is not associated with the site', (done) => { const user = factory.user(); const site = factory.site({ defaultBranch: 'main', @@ -173,7 +173,7 @@ describe('Published Files API', () => { `/v0/site/${promisedValues.site.id}/published-branch/main/published-file`, ) .set('Cookie', promisedValues.cookie) - .expect(403), + .expect(404), ) .then((response) => { validateAgainstJSONSchema( diff --git a/test/api/requests/user-action.test.js b/test/api/requests/user-action.test.js index 02bc86719..801a633ef 100644 --- a/test/api/requests/user-action.test.js +++ b/test/api/requests/user-action.test.js @@ -13,7 +13,7 @@ const route = 'user-action'; const notAuthenticatedError = 'You are not permitted to perform this action. Are you sure you are logged in?'; -const notAuthorizedError = 'You are not authorized to perform that action'; +const notFound = 'Not found'; const path = (id) => `${version}/${resource}/${id}/${route}`; @@ -54,17 +54,17 @@ describe('UserAction API', () => { .catch(done); }); - it('returns a 403 if the site is not associated with the user', (done) => { + it('returns a 404 i if the site is not associated with the user', (done) => { buildAuthenticatedSession() - .then((cookie) => makeGetRequest(403, { id: 0, cookie })) + .then((cookie) => makeGetRequest(404, { id: 0, cookie })) .then((response) => { validateAgainstJSONSchema( 'GET', '/site/{site_id}/user-action', - 403, + 404, response.body, ); - expect(response.body.message).to.equal(notAuthorizedError); + expect(response.body.message).to.equal(notFound); done(); }) .catch(done); diff --git a/test/api/support/factory/file-storage-file.js b/test/api/support/factory/file-storage-file.js new file mode 100644 index 000000000..6d620c222 --- /dev/null +++ b/test/api/support/factory/file-storage-file.js @@ -0,0 +1,107 @@ +const path = require('node:path'); +const fileStorageService = require('./file-storage-service'); +const { FileStorageFile } = require('../../../../api/models'); + +const counters = {}; + +function increment(key) { + counters[key] = (counters[key] || 0) + 1; + return `${key}-${counters[key]}`; +} + +async function build(params = {}) { + let { + name, + key, + type, + fileStorageServiceId = null, + description = null, + metadata = null, + } = params; + + if (!name) { + name = increment('file-storage-service'); + } + + if (!key) { + key = increment('/key/path/'); + } + + if (!type) { + type = 'file/plain'; + } + + if (!fileStorageServiceId) { + const fss = await fileStorageService.create(); + fileStorageServiceId = fss.id; + } + + return FileStorageFile.create({ + name, + key, + type, + fileStorageServiceId, + description, + metadata, + }); +} + +function create(params) { + return build(params); +} + +function truncate() { + return FileStorageFile.truncate({ + force: true, + cascade: true, + }); +} + +async function createBulk( + fileStorageServiceId, + directoryPath, + { files = 0, directories = 0 } = {}, +) { + let totalFiles = []; + let totalDiectories = []; + + if (files > 0) { + const fileList = new Array(files).fill(0); + + await Promise.all( + fileList.map(async (_, idx) => { + const fileName = `file-${idx}.txt`; + const key = path.join(directoryPath, fileName); + const row = await create({ fileStorageServiceId, key }); + + totalFiles.push(row); + }), + ); + } + + if (directories > 0) { + const fileList = new Array(directories).fill(0); + + await Promise.all( + fileList.map(async (_, idx) => { + const directoryName = `dir-${idx}/`; + const key = path.join(directoryPath, directoryName); + const row = await create({ fileStorageServiceId, key, type: 'directory' }); + + totalDiectories.push(row); + }), + ); + } + + return { + files: totalFiles, + directories: totalDiectories, + }; +} + +module.exports = { + build, + create, + truncate, + createBulk, +}; diff --git a/test/api/support/factory/file-storage-service.js b/test/api/support/factory/file-storage-service.js new file mode 100644 index 000000000..87538e280 --- /dev/null +++ b/test/api/support/factory/file-storage-service.js @@ -0,0 +1,55 @@ +const organization = require('./organization'); +const { FileStorageService } = require('../../../../api/models'); + +const counters = {}; + +function increment(key) { + counters[key] = (counters[key] || 0) + 1; + return `${key}-${counters[key]}`; +} + +async function build(params = {}) { + let { name, org, siteId, metadata, serviceId, serviceName } = params; + + if (!name) { + name = increment('file-storage-service'); + } + + if (!serviceId) { + serviceId = increment('service-id-'); + } + + if (!serviceName) { + serviceName = increment('service-name-'); + } + + if (!org) { + org = await organization.create(); + } + + return FileStorageService.create({ + name, + organizationId: org.id, + siteId, + serviceId, + serviceName, + metadata, + }); +} + +function create(params) { + return build(params); +} + +function truncate() { + return FileStorageService.truncate({ + force: true, + cascade: true, + }); +} + +module.exports = { + build, + create, + truncate, +}; diff --git a/test/api/support/factory/file-storage-user-action.js b/test/api/support/factory/file-storage-user-action.js new file mode 100644 index 000000000..84a211f0c --- /dev/null +++ b/test/api/support/factory/file-storage-user-action.js @@ -0,0 +1,97 @@ +const fileStorageFile = require('./file-storage-file'); +const fileStorageService = require('./file-storage-service'); +const user = require('./user'); +const uaaIdentity = require('./uaa-identity'); +const { FileStorageUserAction } = require('../../../../api/models'); + +function getRandItem(kv) { + const list = Object.keys(kv); + const randomIndex = Math.floor(Math.random() * list.length); + const key = list[randomIndex]; + + return kv[key]; +} + +async function build(params = {}) { + let { + method = null, + description = null, + fileStorageServiceId = null, + fileStorageFileId = null, + userId = null, + } = params; + + if (!userId) { + const u = await user(); + userId = u.id; + } + + if (!fileStorageFileId) { + const fss = await fileStorageFile.create(); + fileStorageFileId = fss.id; + } + + if (!fileStorageServiceId) { + const fss = await fileStorageService.create(); + fileStorageServiceId = fss.id; + } + + if (!method) { + method = getRandItem(FileStorageUserAction.METHODS); + } + + if (!description) { + description = getRandItem(FileStorageUserAction.ACTION_TYPES); + } + + return FileStorageUserAction.create({ + fileStorageServiceId, + fileStorageFileId, + userId, + method, + description, + }); +} + +function create(params) { + return build(params); +} + +function truncate() { + return FileStorageUserAction.truncate({ + force: true, + cascade: true, + }); +} + +async function createBulk( + { fileStorageServiceId, fileStorageFileId, userId }, + actions = 1, +) { + const actionslist = new Array(actions).fill(0); + + return Promise.all( + actionslist.map(async () => { + return create({ fileStorageServiceId, fileStorageFileId, userId }); + }), + ); +} + +async function createBulkRandom({ fileStorageServiceId }, actions = 1) { + const fsf = await fileStorageFile.create({ fileStorageServiceId }); + const u = await user(); + await uaaIdentity.createUAAIdentity({ userId: u.id }); + + return createBulk( + { fileStorageServiceId, fileStorageFileId: fsf.id, userId: u.id }, + actions, + ); +} + +module.exports = { + build, + create, + truncate, + createBulk, + createBulkRandom, +}; diff --git a/test/api/support/factory/index.js b/test/api/support/factory/index.js index 705fcce54..101590692 100644 --- a/test/api/support/factory/index.js +++ b/test/api/support/factory/index.js @@ -6,6 +6,9 @@ const build = require('./build'); const { createCFAPIResource, createCFAPIResourceList } = require('./cf-api-response'); const domain = require('./domain'); const event = require('./event'); +const fileStorageFile = require('./file-storage-file'); +const fileStorageService = require('./file-storage-service'); +const fileStorageUserActions = require('./file-storage-user-action'); const organization = require('./organization'); const responses = require('./responses'); const role = require('./role'); @@ -27,6 +30,9 @@ module.exports = { createCFAPIResourceList, domain, event, + fileStorageFile, + fileStorageService, + fileStorageUserActions, organization, responses, role, diff --git a/test/api/support/file-storage-service.js b/test/api/support/file-storage-service.js new file mode 100644 index 000000000..b00d2ce89 --- /dev/null +++ b/test/api/support/file-storage-service.js @@ -0,0 +1,206 @@ +const path = require('node:path'); +const sinon = require('sinon'); +const factory = require('./factory'); +const { createSiteUserOrg } = require('./site-user'); +const CloudFoundryAPIClient = require('../../../api/utils/cfApiClient'); +const S3Helper = require('../../../api/services/S3Helper'); +const { + adminCreateSiteFileStorage, + SiteFileStorageSerivce, +} = require('../../../api/services/file-storage'); + +async function createSiteConfig({ roleName = 'user' } = {}) { + const { site, user, org } = await createSiteUserOrg({ roleName }); + const access_key_id = 'access-key-1'; + const bucket = 'bucke-1'; + const region = 'region-1'; + const secret_access_key = 'secret-key-1'; + const instance1 = await factory.createCFAPIResource({ + name: site.s3ServiceName, + }); + + return { + org, + site, + access_key_id, + bucket, + region, + secret_access_key, + instance1, + user, + }; +} + +async function stubFileStorageClient({ + fetchServiceInstanceResolves = true, + fetchServiceInstanceRejects = null, + fetchCredentialsResolves = true, + fetchCredentialsRejects = null, + roleName = 'user', +} = {}) { + const { org, site, user, access_key_id, bucket, region, secret_access_key, instance1 } = + await createSiteConfig({ roleName }); + + const fss = await factory.fileStorageService.create({ + org, + siteId: site.id, + serviceName: site.s3ServiceName, + }); + + if (fetchServiceInstanceResolves && !fetchServiceInstanceRejects) { + sinon + .stub(CloudFoundryAPIClient.prototype, 'fetchServiceInstance') + .withArgs(site.s3ServiceName) + .resolves(instance1); + } + + if (fetchServiceInstanceRejects) { + sinon + .stub(CloudFoundryAPIClient.prototype, 'fetchServiceInstance') + .withArgs(site.s3ServiceName) + .rejects(fetchServiceInstanceRejects); + } + + if (fetchCredentialsResolves && !fetchCredentialsRejects) { + sinon + .stub(CloudFoundryAPIClient.prototype, 'fetchServiceInstanceCredentials') + .withArgs(site.s3ServiceName) + .resolves({ + access_key_id, + bucket, + region, + secret_access_key, + }); + } + + if (fetchCredentialsRejects) { + sinon + .stub(CloudFoundryAPIClient.prototype, 'fetchServiceInstanceCredentials') + .withArgs(site.s3ServiceName) + .rejects(fetchCredentialsRejects); + } + + return { + fss, + org, + site, + access_key_id, + bucket, + region, + secret_access_key, + instance1, + user, + }; +} + +async function stubSiteS3({ + putObjectResolves = true, + putObjectRejects = null, + fetchServiceInstanceResolves = true, + fetchServiceInstanceRejects = null, + fetchCredentialsResolves = true, + fetchCredentialsRejects = null, + roleName = 'user', +} = {}) { + const { org, site, user, access_key_id, bucket, region, secret_access_key, instance1 } = + await createSiteConfig({ roleName }); + + if (fetchServiceInstanceResolves && !fetchServiceInstanceRejects) { + sinon + .stub(CloudFoundryAPIClient.prototype, 'fetchServiceInstance') + .withArgs(site.s3ServiceName) + .resolves(instance1); + } + + if (fetchServiceInstanceRejects) { + sinon + .stub(CloudFoundryAPIClient.prototype, 'fetchServiceInstance') + .withArgs(site.s3ServiceName) + .rejects(fetchServiceInstanceRejects); + } + + if (fetchCredentialsResolves && !fetchCredentialsRejects) { + sinon + .stub(CloudFoundryAPIClient.prototype, 'fetchServiceInstanceCredentials') + .withArgs(site.s3ServiceName) + .resolves({ + access_key_id, + bucket, + region, + secret_access_key, + }); + } + + if (fetchCredentialsRejects) { + sinon + .stub(CloudFoundryAPIClient.prototype, 'fetchServiceInstanceCredentials') + .withArgs(site.s3ServiceName) + .rejects(fetchCredentialsRejects); + } + + if (putObjectResolves && !putObjectRejects) { + sinon + .stub(S3Helper.S3Client.prototype, 'putObject') + .withArgs('', '~assets/') + .resolves(putObjectResolves); + } + + if (putObjectRejects) { + sinon + .stub(S3Helper.S3Client.prototype, 'putObject') + .withArgs('', '~assets/') + .rejects(putObjectRejects); + } + + return { + org, + site, + access_key_id, + bucket, + region, + secret_access_key, + instance1, + user, + }; +} + +async function createFileStorageServiceClient() { + const { site, user, ...props } = await stubSiteS3(); + const fss = await adminCreateSiteFileStorage(site); + const siteStorageService = new SiteFileStorageSerivce(fss, user.id); + const client = await siteStorageService.createClient(); + + sinon.restore(); + + return { + ...props, + client, + fss, + site, + user, + }; +} + +function stubCreateDirectory( + { parent, name, base = `~assets/` } = {}, + { resolves = true, rejects = false } = {}, +) { + const dirPath = path.join(base, parent, name, '/'); + const stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').withArgs('', dirPath); + + if (resolves && !rejects) { + stub.resolves(resolves); + } + + if (rejects) { + stub.rejects(rejects); + } +} + +module.exports = { + createSiteConfig, + createFileStorageServiceClient, + stubCreateDirectory, + stubFileStorageClient, + stubSiteS3, +}; diff --git a/test/api/support/fixtures/logo.svg b/test/api/support/fixtures/logo.svg new file mode 100755 index 000000000..8611b85fd --- /dev/null +++ b/test/api/support/fixtures/logo.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/test/api/support/fixtures/lorem.txt b/test/api/support/fixtures/lorem.txt new file mode 100644 index 000000000..6b46baaf9 --- /dev/null +++ b/test/api/support/fixtures/lorem.txt @@ -0,0 +1,39 @@ +Lorem ipsum odor amet, consectetuer adipiscing elit. Est id nullam egestas quis consequat tellus nisi eu. Tellus mollis sem quam aptent at facilisis habitasse facilisis. Arcu volutpat accumsan cras iaculis magnis rhoncus curabitur aenean nunc. Vivamus curae vulputate dis accumsan porttitor nulla. Semper odio cubilia vestibulum; elit malesuada curabitur curabitur eros. + +Maximus est sodales sed vitae varius maecenas efficitur tincidunt nulla. Tempus dolor efficitur varius; sit accumsan fames? Turpis a conubia interdum ad varius rhoncus purus eget erat. Ante molestie scelerisque vitae himenaeos egestas molestie. Integer scelerisque torquent penatibus sollicitudin bibendum sed pretium duis. Leo nisl mus facilisis per sed euismod volutpat. + +Purus consequat vulputate cubilia ad sagittis pellentesque pellentesque diam mollis. Dis nisl eu eu purus id feugiat vel interdum. In non ad ultrices viverra per tristique. Lobortis rutrum inceptos leo ante sagittis scelerisque penatibus lacinia. Ornare vestibulum pretium tincidunt placerat nascetur nibh primis conubia. Augue vehicula lacus rutrum felis taciti volutpat! Montes sociosqu fringilla lacinia eleifend finibus elit id commodo. Erat proin malesuada duis dolor phasellus ultrices. + +Afermentum taciti eleifend id faucibus nostra potenti. Nam finibus donec natoque nisi nec netus. Auctor mattis eleifend viverra condimentum tristique fusce malesuada sem. Litora habitant auctor litora auctor at nulla ac. Blandit scelerisque dui dignissim iaculis consectetur sed, aliquet placerat bibendum? Senectus tristique sollicitudin nascetur risus elementum parturient sagittis quisque. Ut nostra lobortis ullamcorper class etiam convallis nulla mollis. + +Lacus netus sollicitudin nisi magnis erat porttitor augue metus. Conubia lectus neque diam sem at nostra dignissim. Quam nec maximus augue phasellus eros; fringilla ut ultrices ridiculus. Gravida aliquet egestas ex curabitur class montes enim parturient. Etiam aenean fames pellentesque nostra curabitur nisl ornare elit. Venenatis maximus urna ante potenti mauris taciti. Taciti lacinia odio amet; elementum porta pulvinar. + +Pharetra sagittis molestie, consectetur velit morbi elementum. Varius potenti feugiat cras, vivamus class porta. Ipsum ullamcorper morbi eros euismod vehicula. Senectus sed quisque a tempus habitasse. At ipsum nisi semper integer rutrum dictum tortor. Purus euismod nisi tristique purus facilisi sapien sit. Varius porta semper penatibus sed vivamus ultrices maecenas nam. Viverra elit commodo sociosqu praesent adipiscing aliquam nisl mauris dapibus. Himenaeos libero curae quisque, mi congue rhoncus. + +Taciti nullam luctus posuere et eu sapien bibendum in. In tellus mollis fusce convallis nibh et. Ultricies dui in eros nostra; nullam aptent consectetur imperdiet faucibus. Fusce scelerisque nisl ante id ullamcorper suspendisse nullam. Donec cras interdum velit enim nascetur urna velit efficitur. Aptent eros condimentum mauris nisl, montes consequat condimentum platea dictumst. Nibh amet accumsan feugiat imperdiet vulputate morbi pellentesque senectus. + +Justo aptent quisque consequat cubilia lobortis id potenti amet. Nascetur semper venenatis viverra a luctus per ante conubia risus. Nunc dapibus mattis duis magna potenti, tortor sit ligula. Vel bibendum torquent consectetur vel placerat euismod litora congue. Rutrum placerat sit lobortis feugiat nascetur. Mi convallis quam semper vitae dignissim cras sed. Dignissim laoreet non et dis per maecenas iaculis etiam. + +Class turpis mollis; eu dui rhoncus tempor hendrerit. Dictum bibendum conubia cras morbi gravida etiam. Tristique dapibus litora dapibus dignissim at mollis maximus semper congue. Habitasse vestibulum maximus pulvinar arcu risus sapien tempus vestibulum mauris? Magna natoque senectus euismod mauris penatibus; laoreet imperdiet. Varius imperdiet ut sit pharetra duis at lacinia iaculis. Laoreet consequat nam elementum phasellus metus blandit. + +Enim nisl libero litora tortor vulputate proin. Laoreet curae nunc, suscipit risus malesuada potenti potenti volutpat. Varius eget convallis nostra nibh adipiscing. Inceptos felis sapien suscipit non magna. Facilisi hac auctor morbi dapibus placerat potenti taciti. Sapien volutpat phasellus finibus sed curae habitasse dolor lacus sociosqu. Urna quisque torquent erat himenaeos ante hac quis. Molestie dui molestie platea neque commodo ligula suscipit parturient lectus. + +Fusce eros sit tempor vivamus tempus nascetur malesuada. Platea augue senectus aenean et; aenean pharetra quis ultrices molestie. Blandit eu vestibulum quisque ultrices mollis tortor. Dapibus luctus morbi eros ridiculus faucibus, faucibus sed nibh? Senectus lacus ridiculus pulvinar elementum, eu ligula ipsum. Ut habitasse turpis porttitor integer, condimentum facilisis sem primis! Risus lobortis velit pellentesque tristique justo magna morbi a. + +Risus ultricies vehicula gravida aenean sapien dapibus eleifend porta rutrum. Primis at facilisis massa diam dapibus. Facilisi maecenas tincidunt torquent lobortis porta vel. Ridiculus massa ex rhoncus tellus ex fermentum phasellus. In class dolor laoreet ridiculus iaculis facilisis diam velit velit. Ut quis purus gravida dolor nisl. Tincidunt hac nam montes dolor donec tristique. Habitasse torquent risus lacinia nam suspendisse vestibulum purus. Id enim mollis scelerisque tempor eleifend eget. + +Ante et facilisi consectetur finibus penatibus pretium consequat aliquet blandit. Finibus sociosqu tempor nunc curabitur dis rhoncus. Blandit maecenas venenatis finibus penatibus fringilla dapibus platea cras. Placerat molestie cubilia efficitur placerat sapien habitasse. Primis ullamcorper torquent mollis potenti duis. Adipiscing est viverra suscipit ridiculus augue nisl posuere dui. Quam tincidunt urna mus auctor erat nullam. Maximus sagittis bibendum nisi id; proin pulvinar conubia. + +Arcu venenatis ac lobortis eu ridiculus ultrices. Sollicitudin rhoncus tempus pretium est natoque metus id. Eu litora elit est varius tortor augue. Taciti conubia dignissim class lacus cursus; sollicitudin ultricies mus interdum. Laoreet habitant orci penatibus penatibus ullamcorper diam. Libero ut turpis senectus; cubilia interdum montes. + +Efficitur adipiscing scelerisque inceptos suspendisse litora integer aptent porta. Parturient justo potenti condimentum odio montes sagittis phasellus facilisis. Diam porttitor porta imperdiet fames rutrum ex magnis. Posuere metus purus metus convallis porttitor pulvinar elementum magnis. Velit maximus duis nisi mus nullam dictum curabitur varius laoreet. Curae adipiscing eu sollicitudin potenti sed convallis phasellus ipsum. Id porttitor ultricies nunc senectus duis curabitur. Mattis blandit arcu eget montes tempus quisque nulla iaculis. + +Penatibus nisi habitasse at tellus vitae et imperdiet gravida? Curabitur aenean nunc pellentesque aliquet tortor ullamcorper, natoque urna. Lectus etiam tortor sit facilisis nullam, mi amet phasellus. Parturient nam tristique mattis nullam taciti enim turpis. Netus laoreet vel velit nam, blandit suspendisse. Turpis quis maximus imperdiet justo placerat volutpat. + +Risus neque ullamcorper ultrices ornare hendrerit maximus. Facilisis aliquet lobortis taciti semper fringilla. Habitasse non massa dis fringilla augue ligula iaculis. Tortor vel ipsum eget convallis aliquam molestie hendrerit mollis. Donec commodo ante, erat lacus nisi tincidunt. Faucibus eleifend magnis suscipit consequat suscipit libero bibendum torquent. Litora risus eros venenatis lacinia magna blandit maecenas duis metus. Parturient integer etiam euismod nam morbi natoque in malesuada. + +Hendrerit mus nec turpis tempus eget nisi a per. Dis maximus parturient vestibulum quisque mollis vehicula. Massa conubia aenean mattis facilisi tortor cras. Leo morbi pellentesque mus molestie montes venenatis ultricies congue. Laoreet est rutrum accumsan risus torquent massa vel suscipit! Cras velit mus feugiat natoque mi sodales. Ultricies egestas dui mollis aenean amet cras dignissim. Malesuada risus pretium rhoncus dis volutpat. Euismod litora amet eget maximus efficitur nisl orci arcu. + +Neque tristique pharetra accumsan nulla consectetur. Eros enim mollis sociosqu pretium etiam. Viverra aenean egestas non inceptos conubia conubia porttitor commodo lorem. Montes tristique duis est imperdiet nec phasellus blandit. Potenti mollis turpis porta montes consectetur amet habitasse. Hendrerit eleifend mauris dolor congue posuere porta. Habitasse lectus accumsan blandit ultrices accumsan fermentum nibh. + +Nascetur consequat proin platea porta, torquent litora. Sapien per leo odio non aliquet. Lobortis sagittis vitae sit ut elit praesent mattis. Mauris eget tempus facilisi nunc orci arcu. Augue ex arcu suspendisse a erat ac eget montes interdum. Fermentum eleifend cras; facilisi duis taciti consequat phasellus bibendum dignissim. Maximus quisque diam mattis posuere vel laoreet dignissim eros sociosqu. Fringilla pulvinar natoque semper sem lacus vivamus. diff --git a/test/api/support/site-user.js b/test/api/support/site-user.js index dcac554e5..a480819a6 100644 --- a/test/api/support/site-user.js +++ b/test/api/support/site-user.js @@ -6,7 +6,12 @@ const factory = require('./factory'); // operators can provide existing user/org/site models as needed and the user will // still be added to the org, and the site will have it's organization set to the // organization -async function createSiteUserOrg({ user = null, org = null, site = null } = {}) { +async function createSiteUserOrg({ + user = null, + org = null, + site = null, + roleName = 'user', +} = {}) { if (!user) { // eslint-disable-next-line no-param-reassign user = await factory.user(); @@ -17,7 +22,7 @@ async function createSiteUserOrg({ user = null, org = null, site = null } = {}) org = await factory.organization.create(); } - await org.addRoleUser(user); + await org.addRoleUser(user, roleName); if (!site) { // eslint-disable-next-line no-param-reassign diff --git a/test/api/unit/authorizers/file-storage.test.js b/test/api/unit/authorizers/file-storage.test.js new file mode 100644 index 000000000..663b9ced3 --- /dev/null +++ b/test/api/unit/authorizers/file-storage.test.js @@ -0,0 +1,165 @@ +const { expect } = require('chai'); +const factory = require('../../support/factory'); +const authorizer = require('../../../../api/authorizers/file-storage'); +const siteErrors = require('../../../../api/responses/siteErrors'); +const { createSiteUserOrg } = require('../../support/site-user'); + +describe('file-storage authorizer', () => { + beforeEach(() => factory.organization.truncate()); + afterEach(() => factory.organization.truncate()); + + describe('.canAdminCreateSiteFileStorage(siteId)', () => { + it('should pass with a valid site with no file storage service', async () => { + const { site } = await createSiteUserOrg(); + + const { site: expected } = await authorizer.canAdminCreateSiteFileStorage(site.id); + expect(expected.id).to.be.eq(site.id); + expect(expected.s3ServiceName).to.be.eq(site.s3ServiceName); + expect(expected.organizationId).to.be.eq(site.organizationId); + }); + + it('should fail with invalid site', async () => { + const error = await authorizer + .canAdminCreateSiteFileStorage(9999999999) + .catch((e) => e); + expect(error).to.be.throw; + expect(error.message).to.be.eq(siteErrors.SITE_DOES_NOT_EXIST); + }); + + it('should fail if site file storage exists', async () => { + const { site, org } = await createSiteUserOrg(); + await factory.fileStorageService.create({ + organizationId: org.id, + siteId: site.id, + }); + + const error = await authorizer + .canAdminCreateSiteFileStorage(site.id) + .catch((e) => e); + expect(error).to.be.throw; + expect(error.message).to.be.eq(siteErrors.SITE_FILE_STORAGE_EXISTS); + }); + }); + + describe('.canCreateSiteStorage(userId, siteId)', () => { + it('should pass with an org manager and no existing site storage', async () => { + const { user, site, org } = await createSiteUserOrg({ + roleName: 'manager', + }); + + const expected = await authorizer.canCreateSiteStorage(user.id, site.id); + expect(expected.site.id).to.equal(site.id); + expect(expected.organization.id).to.equal(org.id); + expect(expected).to.have.all.keys('site', 'organization'); + }); + + it('should pass with org manager, no site storage but org storage', async () => { + const { user, site, org } = await createSiteUserOrg({ roleName: 'manager' }); + await factory.fileStorageService.create({ + organizationId: org.id, + }); + + const expected = await authorizer.canCreateSiteStorage(user.id, site.id); + expect(expected.site.id).to.equal(site.id); + expect(expected.organization.id).to.equal(org.id); + expect(expected).to.have.all.keys('site', 'organization'); + }); + + it('should fail with an org manager and an existing site storage', async () => { + const { user, site, org } = await createSiteUserOrg({ roleName: 'manager' }); + await factory.fileStorageService.create({ + organizationId: org.id, + siteId: site.id, + }); + + const error = await authorizer + .canCreateSiteStorage(user.id, site.id) + .catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.be.equal(403); + expect(error.message).to.be.equal(siteErrors.SITE_FILE_STORAGE_EXISTS); + }); + }); + + describe('.isFileStorageManager(userId, fssId)', () => { + it('should pass with an org manager an existing file storage', async () => { + const { user, site, org } = await createSiteUserOrg({ roleName: 'manager' }); + const fss = await factory.fileStorageService.create({ + org, + siteId: site.id, + }); + + const expected = await authorizer.isFileStorageManager(user.id, fss.id); + expect(expected.organization.id).to.equal(org.id); + expect(expected.fileStorageService.id).to.equal(fss.id); + expect(expected).to.have.all.keys('organization', 'fileStorageService'); + }); + + it('should fail with an org manager and no site storage', async () => { + const { user } = await createSiteUserOrg({ roleName: 'manager' }); + + const error = await authorizer.isFileStorageManager(user.id, 123).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.be.equal(404); + expect(error.message).to.be.equal(siteErrors.NOT_FOUND); + }); + + it('should fail with an org user and site storage', async () => { + const { user, site, org } = await createSiteUserOrg({ roleName: 'user' }); + const fss = await factory.fileStorageService.create({ + organizationId: org.id, + siteId: site.id, + }); + + const error = await authorizer + .isFileStorageManager(user.id, fss.id) + .catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.be.equal(403); + expect(error.message).to.be.equal(siteErrors.ORGANIZATION_MANAGER_ACCESS); + }); + }); + + describe('.isFileStorageUser(userId, fssId)', () => { + it('should pass with an org manager an existing file storage', async () => { + const { user, site, org } = await createSiteUserOrg({ roleName: 'manager' }); + const fss = await factory.fileStorageService.create({ + org, + siteId: site.id, + }); + + const expected = await authorizer.isFileStorageUser(user.id, fss.id); + expect(expected.organization.id).to.equal(org.id); + expect(expected.fileStorageService.id).to.equal(fss.id); + expect(expected).to.have.all.keys('organization', 'fileStorageService'); + }); + + it('should pass with an org user and site storage', async () => { + const { user, org, site } = await createSiteUserOrg({ roleName: 'user' }); + const fss = await factory.fileStorageService.create({ + org, + siteId: site.id, + }); + + const expected = await authorizer.isFileStorageUser(user.id, fss.id); + expect(expected.organization.id).to.equal(org.id); + expect(expected.fileStorageService.id).to.equal(fss.id); + expect(expected).to.have.all.keys('organization', 'fileStorageService'); + }); + + it('should fail with an org user and site storage', async () => { + const { user, site, org } = await createSiteUserOrg({ roleName: 'user' }); + const fss = await factory.fileStorageService.create({ + organizationId: org.id, + siteId: site.id, + }); + + const error = await authorizer + .isFileStorageManager(user.id, fss.id) + .catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.be.equal(403); + expect(error.message).to.be.equal(siteErrors.ORGANIZATION_MANAGER_ACCESS); + }); + }); +}); diff --git a/test/api/unit/authorizers/site.test.js b/test/api/unit/authorizers/site.test.js index bae370564..8b7575210 100644 --- a/test/api/unit/authorizers/site.test.js +++ b/test/api/unit/authorizers/site.test.js @@ -114,7 +114,8 @@ describe('Site authorizer', () => { const error = await authorizer.findOne(user, site).catch((err) => err); expect(error).to.be.throw; - return expect(error).to.equal(403); + expect(error.status).to.equal(404); + expect(error.message).to.equal(siteErrors.NOT_FOUND); }); context('site that belongs to an inactive organization', () => { it(`should resolve if the site is associated @@ -132,7 +133,8 @@ describe('Site authorizer', () => { const error = await authorizer.findOne(user, site).catch((err) => err); expect(error).to.be.throw; - return expect(error).to.equal(403); + expect(error.status).to.equal(403); + expect(error.message).to.equal(siteErrors.ORGANIZATION_INACTIVE); }); }); context('site is inactive', () => { @@ -149,7 +151,8 @@ describe('Site authorizer', () => { const error = await authorizer.findOne(user, site).catch((err) => err); expect(error).to.be.throw; - return expect(error).to.equal(403); + expect(error.status).to.equal(403); + expect(error.message).to.equal(siteErrors.ORGANIZATION_INACTIVE); }); }); }); @@ -160,7 +163,8 @@ describe('Site authorizer', () => { const error = await authorizer.update(user, site).catch((err) => err); expect(error).to.be.throw; - return expect(error).to.equal(403); + expect(error.status).to.equal(404); + expect(error.message).to.equal(siteErrors.NOT_FOUND); }); context('site that belongs to an inactive organization', () => { it(`should resolve if the site is associated @@ -179,7 +183,8 @@ describe('Site authorizer', () => { const error = await authorizer.update(user, site).catch((err) => err); expect(error).to.be.throw; - return expect(error).to.equal(403); + expect(error.status).to.equal(403); + expect(error.message).to.equal(siteErrors.ORGANIZATION_INACTIVE); }); }); context('site is active', () => { @@ -197,7 +202,8 @@ describe('Site authorizer', () => { const error = await authorizer.update(user, site).catch((err) => err); expect(error).to.be.throw; - return expect(error).to.equal(403); + expect(error.status).to.equal(403); + expect(error.message).to.equal(siteErrors.ORGANIZATION_INACTIVE); }); }); }); @@ -236,7 +242,8 @@ describe('Site authorizer', () => { const error = await authorizer.destroy(user, site).catch((err) => err); expect(error).to.be.throw; - return expect(error).to.equal(403); + expect(error.status).to.equal(404); + expect(error.message).to.equal(siteErrors.NOT_FOUND); }); it(`should reject if the user is associated diff --git a/test/api/unit/authorizers/utils.test.js b/test/api/unit/authorizers/utils.test.js new file mode 100644 index 000000000..7104c0a5b --- /dev/null +++ b/test/api/unit/authorizers/utils.test.js @@ -0,0 +1,149 @@ +const { expect } = require('chai'); +const factory = require('../../support/factory'); +const authorizer = require('../../../../api/authorizers/utils'); +const siteErrors = require('../../../../api/responses/siteErrors'); +const { createSiteUserOrg } = require('../../support/site-user'); + +describe('Utils authorizer', () => { + describe('.authorize(userId, siteId)', () => { + beforeEach(() => factory.organization.truncate()); + afterEach(() => factory.organization.truncate()); + + it('should resolve when user is apart of site org', async () => { + const { user, site } = await createSiteUserOrg(); + + const expected = await authorizer.authorize(user.id, site.id); + return expect(expected.id).to.equal(site.id); + }); + + it('should throw when site does not exist', async () => { + const user = await factory.user(); + + const error = await authorizer.authorize(user.id, 8675309).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.equal(404); + expect(error.message).to.equal(siteErrors.NOT_FOUND); + }); + + it('should throw when user is not apart of site org', async () => { + const { site } = await createSiteUserOrg(); + const user = await factory.user(); + + const error = await authorizer.authorize(user.id, site.id).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.equal(404); + expect(error.message).to.equal(siteErrors.NOT_FOUND); + }); + + it('should throw when site is inactive', async () => { + const { user, site } = await createSiteUserOrg(); + + await site.update({ isActive: false }); + + const error = await authorizer.authorize(user.id, site.id).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.equal(403); + expect(error.message).to.equal(siteErrors.ORGANIZATION_INACTIVE); + }); + + it('should throw when org is inactive', async () => { + const { user, site, org } = await createSiteUserOrg(); + + await org.update({ isActive: false }); + + const error = await authorizer.authorize(user.id, site.id).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.equal(403); + expect(error.message).to.equal(siteErrors.ORGANIZATION_INACTIVE); + }); + }); + + describe('.isOrgManager(userId, orgId)', () => { + beforeEach(() => factory.organization.truncate()); + afterEach(() => factory.organization.truncate()); + + it('should resolve when manager of org', async () => { + const { user, org } = await createSiteUserOrg({ roleName: 'manager' }); + + const expected = await authorizer.isOrgManager(user.id, org.id); + + expect(expected.organization.id).to.equal(org.id); + expect(expected).to.have.all.keys('organization'); + }); + + it('should throw when user of org', async () => { + const { user, org } = await createSiteUserOrg({ roleName: 'user' }); + + const error = await authorizer.isOrgManager(user.id, org.id).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.be.equal(403); + expect(error.message).to.be.equal(siteErrors.ORGANIZATION_MANAGER_ACCESS); + }); + + it('should throw when user not in org', async () => { + const { org } = await createSiteUserOrg({ roleName: 'user' }); + const { user: notOrgUser } = await createSiteUserOrg({ roleName: 'user' }); + + const error = await authorizer.isOrgManager(notOrgUser.id, org.id).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.be.equal(403); + expect(error.message).to.be.equal(siteErrors.ORGANIZATION_MANAGER_ACCESS); + }); + }); + + describe('.isOrgUser(userId, orgId)', () => { + beforeEach(() => factory.organization.truncate()); + afterEach(() => factory.organization.truncate()); + + it('should resolve when manager of org', async () => { + const { user, org } = await createSiteUserOrg({ roleName: 'manager' }); + + const expected = await authorizer.isOrgUser(user.id, org.id); + + expect(expected.organization.id).to.equal(org.id); + expect(expected).to.have.all.keys('organization'); + }); + + it('should resolve when user of org', async () => { + const { user, org } = await createSiteUserOrg({ roleName: 'user' }); + + const expected = await authorizer.isOrgUser(user.id, org.id); + + expect(expected.organization.id).to.equal(org.id); + expect(expected).to.have.all.keys('organization'); + }); + + it('should throw when user not in org', async () => { + const { org } = await createSiteUserOrg({ roleName: 'user' }); + const { user: notOrgUser } = await createSiteUserOrg({ roleName: 'user' }); + + const error = await authorizer.isOrgUser(notOrgUser.id, org.id).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.be.equal(403); + expect(error.message).to.be.equal(siteErrors.ORGANIZATION_USER_ACCESS); + }); + }); + + describe('.isSiteOrgManager(userId, siteId)', () => { + beforeEach(() => factory.organization.truncate()); + afterEach(() => factory.organization.truncate()); + + it('should resolve when user is manager of site org', async () => { + const { user, site, org } = await createSiteUserOrg({ roleName: 'manager' }); + + const expected = await authorizer.isSiteOrgManager(user.id, site.id); + expect(expected.site.id).to.equal(site.id); + expect(expected.organization.id).to.equal(org.id); + expect(expected).to.have.all.keys('site', 'organization'); + }); + + it('should throw when user is user of site org', async () => { + const { user, site } = await createSiteUserOrg({ roleName: 'user' }); + + const error = await authorizer.isSiteOrgManager(user.id, site.id).catch((e) => e); + expect(error).to.be.throw; + expect(error.status).to.be.equal(403); + expect(error.message).to.be.equal(siteErrors.ORGANIZATION_MANAGER_ACCESS); + }); + }); +}); diff --git a/test/api/unit/models/file-storage-domain.test.js b/test/api/unit/models/file-storage-domain.test.js new file mode 100644 index 000000000..a2a9c6ac8 --- /dev/null +++ b/test/api/unit/models/file-storage-domain.test.js @@ -0,0 +1,149 @@ +const { expect } = require('chai'); +const factory = require('../../support/factory'); +const { FileStorageDomain } = require('../../../../api/models'); + +describe('FileStorageDomain model', () => { + afterEach(() => + Promise.all([ + factory.fileStorageService.truncate(), + factory.organization.truncate(), + FileStorageDomain.truncate(), + ]), + ); + + it('requires `names` and `fileStorageServiceId` foreign key', async () => { + const names = 'test.gov'; + const defaultState = 'pending'; + const fss = await factory.fileStorageService.create(); + + const fsd = await FileStorageDomain.create({ + names, + fileStorageServiceId: fss.id, + }); + + expect(fsd.names).to.equal(names); + expect(fsd.state).to.equal(defaultState); + expect(fsd.fileStorageServiceId).to.equal(fss.id); + expect(fsd.metadata).to.equal(null); + expect(fsd.serviceName).to.equal(null); + expect(fsd.serviceId).to.equal(null); + expect(fsd.createdAt).to.be.instanceOf(Date); + expect(fsd.updatedAt).to.be.instanceOf(Date); + expect(fsd.deletedAt).to.equal(null); + }); + + it('allows updates of `serviceName` and `serviceId`', async () => { + const names = 'test.gov'; + const defaultState = 'pending'; + const serviceName = 'service-name'; + const serviceId = '123-abc'; + const fss = await factory.fileStorageService.create(); + + const fsd = await FileStorageDomain.create({ + names, + fileStorageServiceId: fss.id, + }); + + await fsd.update({ serviceName, serviceId }); + + expect(fsd.names).to.equal(names); + expect(fsd.state).to.equal(defaultState); + expect(fsd.fileStorageServiceId).to.equal(fss.id); + expect(fsd.metadata).to.equal(null); + expect(fsd.serviceName).to.equal(serviceName); + expect(fsd.serviceId).to.equal(serviceId); + expect(fsd.createdAt).to.be.instanceOf(Date); + expect(fsd.updatedAt).to.be.instanceOf(Date); + expect(fsd.deletedAt).to.equal(null); + }); + + it('allows only comma delimited, fully qualifed domain names', async () => { + const namesList = ['test.gov', 'one.test.gov,two.test.gov']; + const defaultState = 'pending'; + + namesList.map(async (names) => { + const fss = await factory.fileStorageService.create(); + + const fsd = await FileStorageDomain.create({ + names, + fileStorageServiceId: fss.id, + }); + + expect(fsd.names).to.equal(names); + + expect(fsd.state).to.equal(defaultState); + expect(fsd.fileStorageServiceId).to.equal(fss.id); + expect(fsd.metadata).to.equal(null); + expect(fsd.serviceName).to.equal(null); + expect(fsd.serviceId).to.equal(null); + expect(fsd.createdAt).to.be.instanceOf(Date); + expect(fsd.updatedAt).to.be.instanceOf(Date); + expect(fsd.deletedAt).to.equal(null); + }); + }); + + it('allows only proper `state` enum values', async () => { + const names = 'test.gov'; + + FileStorageDomain.States.values.map(async (state) => { + const fss = await factory.fileStorageService.create(); + + const fsd = await FileStorageDomain.create({ + names, + fileStorageServiceId: fss.id, + state, + }); + + expect(fsd.names).to.equal(names); + expect(fsd.state).to.equal(state); + expect(fsd.fileStorageServiceId).to.equal(fss.id); + expect(fsd.metadata).to.equal(null); + expect(fsd.serviceName).to.equal(null); + expect(fsd.serviceId).to.equal(null); + expect(fsd.createdAt).to.be.instanceOf(Date); + expect(fsd.updatedAt).to.be.instanceOf(Date); + expect(fsd.deletedAt).to.equal(null); + }); + }); + + it('should error with invalid fully qualifed domain names', async () => { + const names = 'notadomaingov'; + const fss = await factory.fileStorageService.create(); + + const error = await FileStorageDomain.create({ + names, + fileStorageServiceId: fss.id, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeValidationError'); + expect(error.errors[0].path).to.equal('names'); + }); + + it('should error without `fileStorageServiceId` foreign key', async () => { + const names = 'test.gov'; + + const error = await FileStorageDomain.create({ + names, + fileStorageServiceId: '987654321', + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeForeignKeyConstraintError'); + }); + + it('should error invalid state value', async () => { + const names = 'test.gov'; + const fss = await factory.fileStorageService.create(); + + const error = await FileStorageDomain.create({ + names, + fileStorageServiceId: fss.id, + state: 'not a state', + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeValidationError'); + expect(error.errors[0].path).to.equal('state'); + }); +}); diff --git a/test/api/unit/models/file-storage-file.test.js b/test/api/unit/models/file-storage-file.test.js new file mode 100644 index 000000000..1961674c3 --- /dev/null +++ b/test/api/unit/models/file-storage-file.test.js @@ -0,0 +1,127 @@ +const { expect } = require('chai'); +const factory = require('../../support/factory'); +const { FileStorageFile } = require('../../../../api/models'); + +describe('FileStorageFile model', () => { + afterEach(() => + Promise.all([ + factory.fileStorageService.truncate(), + factory.organization.truncate(), + factory.fileStorageFile.truncate(), + ]), + ); + + it('`name`, `key`, `fileStorageServiceId`, `type` is required', async () => { + const name = 'test.txt'; + const key = `/the/storage/key/${name}`; + const type = 'text/plain'; + const fss = await factory.fileStorageService.create(); + + const fsf = await FileStorageFile.create({ + name, + key, + type, + fileStorageServiceId: fss.id, + }); + + expect(fsf.name).to.equal(name); + expect(fsf.key).to.equal(key); + expect(fsf.type).to.equal(type); + expect(fsf.fileStorageServiceId).to.equal(fss.id); + expect(fsf.metadata).to.equal(null); + expect(fsf.description).to.equal(null); + expect(fsf.createdAt).to.be.instanceOf(Date); + expect(fsf.updatedAt).to.be.instanceOf(Date); + expect(fsf.deletedAt).to.equal(null); + }); + + it('saves additional `description` text and `metadata` json', async () => { + const name = 'test.txt'; + const key = `/the/storage/key/${name}`; + const fss = await factory.fileStorageService.create(); + const description = 'this is a test'; + const metadata = { type: 'plain/text', size: 1234 }; + + const fsf = await FileStorageFile.create({ + name, + key, + type: metadata.type, + fileStorageServiceId: fss.id, + description, + metadata, + }); + + expect(fsf.name).to.equal(name); + expect(fsf.key).to.equal(key); + expect(fsf.type).to.equal(metadata.type); + expect(fsf.fileStorageServiceId).to.equal(fss.id); + expect(fsf.metadata).to.deep.equal(metadata); + expect(fsf.description).to.equal(description); + expect(fsf.createdAt).to.be.instanceOf(Date); + expect(fsf.updatedAt).to.be.instanceOf(Date); + expect(fsf.deletedAt).to.equal(null); + }); + + it('should error with invalid fileStorageServiceId foreign key', async () => { + const name = 'test.txt'; + const key = `/the/storage/key/${name}`; + const type = 'text/plain'; + + const error = await FileStorageFile.create({ + name, + key, + type, + fileStorageServiceId: 8675309, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeForeignKeyConstraintError'); + }); + + it('should error without name`', async () => { + const name = null; + const key = `/the/storage/key/${name}`; + const fss = await factory.fileStorageService.create(); + + const error = await FileStorageFile.create({ + name, + key, + fileStorageServiceId: fss.id, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeValidationError'); + }); + + it('should error without a key`', async () => { + const name = 'test.txt'; + const key = null; + const type = 'text/plain'; + const fss = await factory.fileStorageService.create(); + + const error = await FileStorageFile.create({ + name, + key, + type, + fileStorageServiceId: fss.id, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeValidationError'); + }); + + it('should error without a type`', async () => { + const name = 'test.txt'; + const key = 'a/b/c'; + const fss = await factory.fileStorageService.create(); + + const error = await FileStorageFile.create({ + name, + key, + fileStorageServiceId: fss.id, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeValidationError'); + }); +}); diff --git a/test/api/unit/models/file-storage-service.test.js b/test/api/unit/models/file-storage-service.test.js new file mode 100644 index 000000000..b8dde923a --- /dev/null +++ b/test/api/unit/models/file-storage-service.test.js @@ -0,0 +1,172 @@ +const { expect } = require('chai'); +const factory = require('../../support/factory'); +const { FileStorageService, Site } = require('../../../../api/models'); + +describe('FileStorageService model', () => { + afterEach(() => + Promise.all([ + FileStorageService.truncate(), + factory.organization.truncate(), + Site.truncate(), + ]), + ); + + it('requires name, serviceName, serviceId, and organizationId', async () => { + const name = 'test'; + const org = await factory.organization.create(); + const serviceId = 'abc-123'; + const serviceName = 'services1'; + + const fss = await FileStorageService.create({ + name, + organizationId: org.id, + serviceId, + serviceName, + }); + + expect(fss.name).to.equal(name); + expect(fss.organizationId).to.equal(org.id); + expect(fss.serviceId).to.equal(serviceId); + expect(fss.serviceName).to.equal(serviceName); + expect(fss.metadata).to.equal(null); + expect(fss.siteId).to.equal(null); + expect(fss.createdAt).to.be.instanceOf(Date); + expect(fss.updatedAt).to.be.instanceOf(Date); + expect(fss.deletedAt).to.equal(null); + }); + + it('allow multiple services with null siteId', async () => { + const names = ['one', 'two', 'three', 'for']; + const org = await factory.organization.create(); + + await Promise.all( + names.map(async (name, idx) => { + const serviceId = `${name}-${idx}`; + const serviceName = `${name}-service`; + const fss = await FileStorageService.create({ + name, + organizationId: org.id, + serviceId, + serviceName, + }); + + expect(fss.name).to.equal(name); + expect(fss.organizationId).to.equal(org.id); + expect(fss.metadata).to.equal(null); + expect(fss.serviceId).to.equal(serviceId); + expect(fss.serviceName).to.equal(serviceName); + expect(fss.createdAt).to.be.instanceOf(Date); + expect(fss.updatedAt).to.be.instanceOf(Date); + expect(fss.deletedAt).to.equal(null); + }), + ); + }); + + it('should take a valid, optional siteId foreign key', async () => { + const name = 'test'; + const org = await factory.organization.create(); + const site = await factory.site(); + const serviceId = 'service-123'; + const serviceName = 'service-name'; + + const fss = await FileStorageService.create({ + name, + organizationId: org.id, + siteId: site.id, + serviceId, + serviceName, + }); + + expect(fss.name).to.equal(name); + expect(fss.organizationId).to.equal(org.id); + expect(fss.metadata).to.equal(null); + expect(fss.serviceId).to.equal(serviceId); + expect(fss.serviceName).to.equal(serviceName); + expect(fss.siteId).to.equal(site.id); + expect(fss.createdAt).to.be.instanceOf(Date); + expect(fss.updatedAt).to.be.instanceOf(Date); + expect(fss.deletedAt).to.equal(null); + }); + + it('should error with non-unique siteId foreign key', async () => { + const name = 'test'; + const org = await factory.organization.create(); + const site = await factory.site(); + const serviceId = 'service-123'; + const serviceName = 'service-name'; + + const fss = await FileStorageService.create({ + name, + organizationId: org.id, + siteId: site.id, + serviceId, + serviceName, + }); + + expect(fss.name).to.equal(name); + expect(fss.organizationId).to.equal(org.id); + expect(fss.metadata).to.equal(null); + expect(fss.serviceId).to.equal(serviceId); + expect(fss.serviceName).to.equal(serviceName); + expect(fss.siteId).to.equal(site.id); + expect(fss.createdAt).to.be.instanceOf(Date); + expect(fss.updatedAt).to.be.instanceOf(Date); + expect(fss.deletedAt).to.equal(null); + + const error = await FileStorageService.create({ + name, + organizationId: org.id, + siteId: site.id, + serviceId: 'service-456', + serviceName: 'service-two', + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeUniqueConstraintError'); + }); + + it('should error with invalid siteId foreign key', async () => { + const name = 'test'; + const org = await factory.organization.create(); + const serviceId = 'service-123'; + const serviceName = 'service-name'; + + const error = await FileStorageService.create({ + name, + organizationId: org.id, + siteId: '90210', + serviceId, + serviceName, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeForeignKeyConstraintError'); + }); + + it('should error without organizationId', async () => { + const name = 'test'; + const serviceId = 'service-123'; + const serviceName = 'service-name'; + + const error = await FileStorageService.create({ + name, + organizationId: '123', + serviceId, + serviceName, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeForeignKeyConstraintError'); + }); + + it('should error without `name`', async () => { + const org = await factory.organization.create(); + + const error = await FileStorageService.create({ + organizationId: org.id, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeValidationError'); + }); +}); diff --git a/test/api/unit/models/file-storage-user-action.test.js b/test/api/unit/models/file-storage-user-action.test.js new file mode 100644 index 000000000..91fe6488e --- /dev/null +++ b/test/api/unit/models/file-storage-user-action.test.js @@ -0,0 +1,109 @@ +const { expect } = require('chai'); +const factory = require('../../support/factory'); +const { FileStorageUserAction } = require('../../../../api/models'); + +describe('FileStorageUserAction model', () => { + afterEach(() => + Promise.all([ + factory.fileStorageService.truncate(), + factory.organization.truncate(), + factory.fileStorageFile.truncate(), + ]), + ); + + it( + 'requires `fileStorageServiceId`, `fileStorageFileId`,' + ' `userId`, and `method`', + async () => { + const method = 'POST'; + const fsf = await factory.fileStorageFile.create(); + const user = await factory.user(); + + const result = await FileStorageUserAction.create({ + method, + fileStorageFileId: fsf.id, + fileStorageServiceId: fsf.fileStorageServiceId, + userId: user.id, + }); + + expect(result.method).to.equal(method); + expect(result.fileStorageFileId).to.equal(fsf.id); + expect(result.fileStorageServiceId).to.equal(fsf.fileStorageServiceId); + expect(result.userId).to.equal(user.id); + expect(result.description).to.equal(null); + expect(result.createdAt).to.be.instanceOf(Date); + expect(result.updatedAt).to.equal(undefined); + expect(result.deletedAt).to.equal(undefined); + }, + ); + + it('should have an optional description', async () => { + const method = 'POST'; + const description = 'Created file'; + const fsf = await factory.fileStorageFile.create(); + const user = await factory.user(); + + const result = await FileStorageUserAction.create({ + method, + fileStorageFileId: fsf.id, + fileStorageServiceId: fsf.fileStorageServiceId, + userId: user.id, + description, + }); + + expect(result.method).to.equal(method); + expect(result.fileStorageFileId).to.equal(fsf.id); + expect(result.fileStorageServiceId).to.equal(fsf.fileStorageServiceId); + expect(result.userId).to.equal(user.id); + expect(result.description).to.equal(description); + expect(result.createdAt).to.be.instanceOf(Date); + expect(result.updatedAt).to.equal(undefined); + expect(result.deletedAt).to.equal(undefined); + }); + + it('should error with invalid fileStorageFileId foreign key', async () => { + const method = 'POST'; + const fsf = await factory.fileStorageFile.create(); + const user = await factory.user(); + + const error = await FileStorageUserAction.create({ + method, + fileStorageFileId: '999999', + fileStorageServiceId: fsf.fileStorageServiceId, + userId: user.id, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeForeignKeyConstraintError'); + }); + + it('should error with invalid fileStorageServiceId foreign key', async () => { + const method = 'POST'; + const fsf = await factory.fileStorageFile.create(); + const user = await factory.user(); + + const error = await FileStorageUserAction.create({ + method, + fileStorageFileId: fsf.id, + fileStorageServiceId: '999999', + userId: user.id, + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeForeignKeyConstraintError'); + }); + + it('should error with invalid userId foreign key', async () => { + const method = 'POST'; + const fsf = await factory.fileStorageFile.create(); + + const error = await FileStorageUserAction.create({ + method, + fileStorageFileId: fsf.id, + fileStorageServiceId: fsf.fileStorageServiceId, + userId: '99999999', + }).catch((e) => e); + + expect(error).to.be.an('error'); + expect(error.name).to.eq('SequelizeForeignKeyConstraintError'); + }); +}); diff --git a/test/api/unit/services/FileStorage.test.js b/test/api/unit/services/FileStorage.test.js new file mode 100644 index 000000000..b1d00230a --- /dev/null +++ b/test/api/unit/services/FileStorage.test.js @@ -0,0 +1,739 @@ +const path = require('node:path'); +const { randomBytes } = require('node:crypto'); +const { expect } = require('chai'); +const sinon = require('sinon'); +const factory = require('../../support/factory'); +const { + stubFileStorageClient, + createFileStorageServiceClient, +} = require('../../support/file-storage-service'); +const EventCreator = require('../../../../api/services/EventCreator'); +const S3Helper = require('../../../../api/services/S3Helper'); +const { FileStorageFile, FileStorageUserAction } = require('../../../../api/models'); +const { SiteFileStorageSerivce } = require('../../../../api/services/file-storage'); +const siteErrors = require('../../../../api/responses/siteErrors'); + +function testUserActionResults(results, fss) { + return results.data.map((result) => { + expect(typeof result.id).to.be.eq('number'); + expect(result.fileStorageServiceId).to.be.eq(fss.id); + expect(typeof result.fileStorageFileId).to.be.eq('number'); + expect(typeof result.method).to.be.eq('string'); + expect(typeof result.description).to.be.eq('string'); + expect(typeof result.userId).to.be.eq('number'); + expect(typeof result.email).to.be.eq('string'); + }); +} + +describe('FileStorage services', () => { + beforeEach(async () => + Promise.all([ + sinon.stub(EventCreator, 'error').resolves(), + await factory.organization.truncate(), + ]), + ); + + afterEach(async () => + Promise.all([sinon.restore(), await factory.organization.truncate()]), + ); + + describe('SiteFileStorageSerivce', () => { + it('should initialize with proper s3 creds', async () => { + const { + fss, + org, + site, + access_key_id, + bucket, + region, + secret_access_key, + instance1, + user, + } = await stubFileStorageClient(); + + const siteStorageService = new SiteFileStorageSerivce(fss, user.id); + const client = await siteStorageService.createClient(); + + expect(client.access_key_id).to.be.eq(access_key_id); + expect(client.bucket).to.be.eq(bucket); + expect(client.region).to.be.eq(region); + expect(client.secret_access_key).to.be.eq(secret_access_key); + expect(client.serviceName).to.be.eq(site.s3ServiceName); + expect(client.id).to.be.eq(fss.id); + expect(client.organizationId).to.be.eq(org.id); + expect(client.serviceInstance).to.be.eq(instance1); + expect(client.s3Client).to.be.instanceOf(S3Helper.S3Client); + }); + + it('should throw with invalid s3 service', async () => { + const message = 'Error occured'; + const expected = Error(message); + const { fss } = await stubFileStorageClient({ + fetchServiceInstanceRejects: expected, + }); + + const siteStorageService = new SiteFileStorageSerivce(fss); + const error = await siteStorageService.createClient().catch((e) => e); + + expect(error).to.be.throw; + expect(error.message).to.be.eq(message); + }); + + it('should throw with invalid s3 credentials', async () => { + const message = 'Error occured'; + const expected = Error(message); + const { fss } = await stubFileStorageClient({ + fetchCredentialsRejects: expected, + }); + + const siteStorageService = new SiteFileStorageSerivce(fss); + const error = await siteStorageService.createClient().catch((e) => e); + + expect(error).to.be.throw; + expect(error.message).to.be.eq(message); + }); + }); + + describe('createDirectory', () => { + it('should create a directory with a name and path', async () => { + const { client, user } = await createFileStorageServiceClient(); + const basepath = '/a/b/c'; + const name = 'another-directory/'; + const key = path.join(client.S3_BASE_PATH, basepath, name); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').resolves(); + + const results = await client.createDirectory(basepath, name); + + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: results.id, + fileStorageServiceId: client.id, + userId: user.id, + }, + }); + + expect(s3stub.calledOnceWith('', `${key}`)).to.be.eq(true); + expect(fsua.method).to.be.eq(FileStorageUserAction.METHODS.POST); + expect(fsua.description).to.be.eq( + FileStorageUserAction.ACTION_TYPES.CREATE_DIRECTORY, + ); + }); + + it('should prepend the root directory if not provided', async () => { + const { client, user } = await createFileStorageServiceClient(); + const basepath = '/a/b/c'; + const name = 'another-directory'; + const key = path.join(client.S3_BASE_PATH, basepath, name); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').resolves(); + + const results = await client.createDirectory(basepath, name); + + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: results.id, + fileStorageServiceId: client.id, + userId: user.id, + }, + }); + + expect(s3stub.calledOnceWith('', `${key}/`)).to.be.eq(true); + expect(fsua.method).to.be.eq(FileStorageUserAction.METHODS.POST); + expect(fsua.description).to.be.eq( + FileStorageUserAction.ACTION_TYPES.CREATE_DIRECTORY, + ); + }); + + it('should append trailing slash if not provided', async () => { + const { client, user } = await createFileStorageServiceClient(); + const basepath = '/a/b/c'; + const name = 'another-directory'; + const key = path.join(client.S3_BASE_PATH, basepath, name); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').resolves(); + + const results = await client.createDirectory(basepath, name); + + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: results.id, + fileStorageServiceId: client.id, + userId: user.id, + }, + }); + + expect(s3stub.calledOnceWith('', `${key}/`)).to.be.eq(true); + expect(fsua.method).to.be.eq(FileStorageUserAction.METHODS.POST); + expect(fsua.description).to.be.eq( + FileStorageUserAction.ACTION_TYPES.CREATE_DIRECTORY, + ); + }); + + it('should normalize the path', async () => { + const { client, user } = await createFileStorageServiceClient(); + const basepath = '/a/b///c'; + const name = 'another-directory///'; + const key = path.join(client.S3_BASE_PATH, basepath, name); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').resolves(); + + const results = await client.createDirectory(basepath, name); + + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: results.id, + fileStorageServiceId: client.id, + userId: user.id, + }, + }); + + expect(s3stub.calledOnceWith('', `${key}`)).to.be.eq(true); + expect(fsua.method).to.be.eq(FileStorageUserAction.METHODS.POST); + expect(fsua.description).to.be.eq( + FileStorageUserAction.ACTION_TYPES.CREATE_DIRECTORY, + ); + }); + + it('should slugify the directory name', async () => { + const { client, user } = await createFileStorageServiceClient(); + const basepath = '/a/b///c'; + const name = 'another directory'; + const slugified = 'another-directory'; + const key = path.join(client.S3_BASE_PATH, basepath, slugified, '/'); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').resolves(); + + const results = await client.createDirectory(basepath, name); + + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: results.id, + fileStorageServiceId: client.id, + userId: user.id, + }, + }); + + expect(s3stub.calledOnceWith('', `${key}`)).to.be.eq(true); + expect(fsua.method).to.be.eq(FileStorageUserAction.METHODS.POST); + expect(fsua.description).to.be.eq( + FileStorageUserAction.ACTION_TYPES.CREATE_DIRECTORY, + ); + }); + + it('error when the directory name is too long', async () => { + const { client } = await createFileStorageServiceClient(); + const basepath = '/a/b///c'; + const name = randomBytes(101).toString('hex').slice(0, 201); + const errorMessage = 'Text must be less than or equal to 200 characters.'; + + const error = await client.createDirectory(basepath, name).catch((e) => e); + + expect(error).to.be.throw; + expect(error.message).to.be.eq(errorMessage); + }); + + it('error when the directory name is not string or number', async () => { + const { client } = await createFileStorageServiceClient(); + const basepath = '/a/b///c'; + const name = { hello: 'world' }; + const errorMessage = 'Text must be a string or number.'; + + const error = await client.createDirectory(basepath, name).catch((e) => e); + + expect(error).to.be.throw; + expect(error.message).to.be.eq(errorMessage); + }); + + it('should not create a file or user action on s3 error', async () => { + const { client, user } = await createFileStorageServiceClient(); + const basepath = '/a/b/c'; + const name = 'another-directory/'; + const key = path.join(client.S3_BASE_PATH, basepath, name); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').rejects(); + + const error = await client.createDirectory(basepath, name).catch((e) => e); + + expect(error).to.be.throw; + + const fsua = await FileStorageUserAction.findAll({ + where: { + fileStorageServiceId: client.id, + userId: user.id, + }, + }); + + expect(s3stub.calledOnceWith('', `${key}`)).to.be.eq(true); + expect(fsua).to.be.empty; + }); + }); + + describe('.deleteFile', () => { + it('should delete a file by id', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const file = await factory.fileStorageFile.create({ fileStorageServiceId: fss.id }); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'deleteObject').resolves(); + + const results = await client.deleteFile(file.id); + + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: file.id, + method: FileStorageUserAction.METHODS.DELETE, + description: FileStorageUserAction.ACTION_TYPES.DELETE_FILE, + }, + }); + + expect(fsua).to.not.be.empty; + expect(s3stub.calledOnceWith(file.key)).to.be.eq(true); + expect(results.id).to.be.eq(file.id); + expect(results.name).to.be.eq(file.name); + expect(results.description).to.be.eq(file.description); + expect(results.key).to.be.eq(file.key); + expect(results.type).to.be.eq(file.type); + expect(results.metadata).to.be.eq(file.metadata); + expect(results.deletedAt).to.be.not.null; + }); + + it('should return message if file is directory is not empty', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const baseDir = 'a/'; + const childDir = `${baseDir}b/`; + const file = await factory.fileStorageFile.create({ + fileStorageServiceId: fss.id, + type: 'directory', + key: childDir, + }); + + // Other files/directories on the same level + await factory.fileStorageFile.createBulk(fss.id, baseDir, { + files: 10, + directories: 2, + }); + + // Children files/directories + await factory.fileStorageFile.createBulk(fss.id, childDir, { + files: 10, + directories: 2, + }); + + const results = await client.deleteFile(file.id); + + expect(results.message).to.be.eq(siteErrors.DIRECTORY_MUST_BE_EMPTIED); + }); + + it('should delete directory if empty', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const baseDir = 'a/'; + const childDir = `${baseDir}b/`; + const file = await factory.fileStorageFile.create({ + fileStorageServiceId: fss.id, + type: 'directory', + key: childDir, + }); + + // Other files/directories on the same level + await factory.fileStorageFile.createBulk(fss.id, baseDir, { + files: 10, + directories: 2, + }); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'deleteObject').resolves(); + const results = await client.deleteFile(file.id); + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: file.id, + method: FileStorageUserAction.METHODS.DELETE, + description: FileStorageUserAction.ACTION_TYPES.DELETE_FILE, + }, + }); + + expect(fsua).to.not.be.empty; + expect(s3stub.calledOnceWith(file.key)).to.be.eq(true); + expect(results.id).to.be.eq(file.id); + expect(results.name).to.be.eq(file.name); + expect(results.description).to.be.eq(file.description); + expect(results.key).to.be.eq(file.key); + expect(results.type).to.be.eq(file.type); + expect(results.metadata).to.be.eq(file.metadata); + expect(results.deletedAt).to.be.not.null; + }); + + it('should throw no file exists', async () => { + const { client } = await createFileStorageServiceClient(); + const result = await client.deleteFile(123).catch((e) => e); + + expect(result).to.be.null; + }); + + it('should throw if file exist in other file storage service', async () => { + const { client } = await createFileStorageServiceClient(); + const otherFss = await factory.fileStorageService.create(); + const otherFile = await factory.fileStorageFile.create({ fssId: otherFss.id }); + + const result = await client.deleteFile(otherFile.id).catch((e) => e); + + expect(result).to.be.null; + }); + }); + + describe('.getFile', () => { + it('should return a file by id', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const file = await factory.fileStorageFile.create({ fileStorageServiceId: fss.id }); + + const results = await client.getFile(file.id); + + expect(results.id).to.be.eq(file.id); + expect(results.name).to.be.eq(file.name); + expect(results.description).to.be.eq(file.description); + expect(results.key).to.be.eq(file.key); + expect(results.type).to.be.eq(file.type); + expect(results.metadata).to.be.eq(file.metadata); + }); + + it('should return empty if no file exists', async () => { + const { client } = await createFileStorageServiceClient(); + const results = await client.getFile(123); + expect(results).to.be.empty; + }); + + it('should return empty if file exist in other file storage service', async () => { + const { client } = await createFileStorageServiceClient(); + const otherFss = await factory.fileStorageService.create(); + const otherFile = await factory.fileStorageFile.create({ fssId: otherFss.id }); + const results = await client.getFile(otherFile.id); + + expect(results).to.be.empty; + }); + }); + + describe('.listUserActions', () => { + it('should list user actions for a file storage service', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const fileActions1 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const fileActions2 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const totalActionCount = fileActions1.length + fileActions2.length; + + const results = await client.listUserActions(); + + testUserActionResults(results, fss); + expect(results.data.length).to.be.eq(totalActionCount); + expect(results.totalItems).to.be.eq(totalActionCount); + expect(results.currentPage).to.be.eq(1); + expect(results.totalPages).to.be.eq(1); + }); + + it('should list user actions for a file storage file', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const fileActions1 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const fileStorageFileId = fileActions1[0].fileStorageFileId; + await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 5, + ); + const totalActionCount = fileActions1.length; + + const results = await client.listUserActions({ fileStorageFileId }); + + testUserActionResults(results, fss); + expect(results.data.length).to.be.eq(totalActionCount); + expect(results.totalItems).to.be.eq(totalActionCount); + expect(results.currentPage).to.be.eq(1); + expect(results.totalPages).to.be.eq(1); + }); + + it('should list user actions with limit 2 on page 2', async () => { + const limit = 2; + const page = 2; + + const { client, fss } = await createFileStorageServiceClient(); + const fileActions1 = await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 10, + ); + const fileStorageFileId = fileActions1[0].fileStorageFileId; + await factory.fileStorageUserActions.createBulkRandom( + { fileStorageServiceId: fss.id }, + 5, + ); + const totalActionCount = fileActions1.length; + + const results = await client.listUserActions({ + fileStorageFileId, + limit, + page, + }); + + testUserActionResults(results, fss); + expect(results.data.length).to.be.eq(limit); + expect(results.totalItems).to.be.eq(totalActionCount); + expect(results.currentPage).to.be.eq(page); + expect(results.totalPages).to.be.eq(totalActionCount / limit); + }); + }); + + describe('.listDirectoryFiles', () => { + it('should list files in a directory', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const dir = path.join(client.S3_BASE_PATH, 'a/b/c/'); + const subdir = `${dir}/d/`; + const expectedList = await factory.fileStorageFile.createBulk(fss.id, dir, { + files: 10, + directories: 2, + }); + const expectedCount = expectedList.files.length + expectedList.directories.length; + const unexpectedList = await factory.fileStorageFile.createBulk(fss.id, subdir, { + files: 2, + directories: 1, + }); + const unexpectedCount = + unexpectedList.files.length + unexpectedList.directories.length; + const allFileCount = expectedCount + unexpectedCount; + const results = await client.listDirectoryFiles(dir, { limit: 100 }); + + const files = await FileStorageFile.findAll({ + where: { fileStorageServiceId: fss.id }, + }); + + expect(results.currentPage).to.be.eq(1); + expect(results.totalPages).to.be.eq(1); + expect(results.data.length).to.be.eq(expectedCount); + expect(results.totalItems).to.be.eq(expectedCount); + expect(files.length).to.be.eq(allFileCount); + }); + + it('should list files in and prepend root directory', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const dir = 'a/b/c/'; + const subdir = `${dir}/d/`; + const expectedList = await factory.fileStorageFile.createBulk( + fss.id, + // The prepended root directory + `${client.S3_BASE_PATH}${dir}`, + { + files: 10, + directories: 2, + }, + ); + const expectedCount = expectedList.files.length + expectedList.directories.length; + const unexpectedList = await factory.fileStorageFile.createBulk( + fss.id, + // The prepended root directory + `${client.S3_BASE_PATH}${subdir}`, + { + files: 2, + directories: 1, + }, + ); + const unexpectedCount = + unexpectedList.files.length + unexpectedList.directories.length; + const allFileCount = expectedCount + unexpectedCount; + const results = await client.listDirectoryFiles(dir, { limit: 100 }); + + const files = await FileStorageFile.findAll({ + where: { fileStorageServiceId: fss.id }, + }); + + expect(results.currentPage).to.be.eq(1); + expect(results.totalPages).to.be.eq(1); + expect(results.data.length).to.be.eq(expectedCount); + expect(results.totalItems).to.be.eq(expectedCount); + expect(files.length).to.be.eq(allFileCount); + }); + + it('should list files in a directory and not the parent directory', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const dir = path.join(client.S3_BASE_PATH, 'a/b/c/'); + const subdir = `${dir}/d/`; + await factory.fileStorageFile.create({ + fileStorageServiceId: fss.id, + type: 'directory', + key: dir, + }); + const expectedList = await factory.fileStorageFile.createBulk(fss.id, dir, { + files: 10, + directories: 2, + }); + const expectedCount = expectedList.files.length + expectedList.directories.length; + const unexpectedList = await factory.fileStorageFile.createBulk(fss.id, subdir, { + files: 2, + directories: 1, + }); + const unexpectedCount = + unexpectedList.files.length + unexpectedList.directories.length; + const allFileCount = expectedCount + unexpectedCount + 1; + const results = await client.listDirectoryFiles(dir, { limit: 100 }); + + const files = await FileStorageFile.findAll({ + where: { fileStorageServiceId: fss.id }, + }); + + expect(results.currentPage).to.be.eq(1); + expect(results.totalPages).to.be.eq(1); + expect(results.data.length).to.be.eq(expectedCount); + expect(results.totalItems).to.be.eq(expectedCount); + expect(files.length).to.be.eq(allFileCount); + }); + + it('should list files for directory on multiple pages', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const dir = path.join(client.S3_BASE_PATH, 'a/b/c/'); + const subdir = `${dir}/d/`; + const limit = 2; + const expectedList = await factory.fileStorageFile.createBulk(fss.id, dir, { + files: 10, + directories: 2, + }); + const expectedCount = expectedList.files.length + expectedList.directories.length; + const unexpectedList = await factory.fileStorageFile.createBulk(fss.id, subdir, { + files: 2, + directories: 1, + }); + const unexpectedCount = + unexpectedList.files.length + unexpectedList.directories.length; + const allFileCount = expectedCount + unexpectedCount; + const results = await client.listDirectoryFiles(dir, { limit }); + + const files = await FileStorageFile.findAll({ + where: { fileStorageServiceId: fss.id }, + }); + + const totalPages = expectedCount / limit; + + expect(results.currentPage).to.be.eq(1); + expect(results.totalPages).to.be.eq(totalPages); + expect(results.data.length).to.be.eq(limit); + expect(results.totalItems).to.be.eq(expectedCount); + expect(files.length).to.be.eq(allFileCount); + }); + + it('should list files for directory from second page', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const dir = path.join(client.S3_BASE_PATH, 'a/b/c/'); + const subdir = `${dir}/d/`; + const limit = 2; + const page = 2; + const expectedList = await factory.fileStorageFile.createBulk(fss.id, dir, { + files: 10, + directories: 2, + }); + const expectedCount = expectedList.files.length + expectedList.directories.length; + const unexpectedList = await factory.fileStorageFile.createBulk(fss.id, subdir, { + files: 2, + directories: 1, + }); + const unexpectedCount = + unexpectedList.files.length + unexpectedList.directories.length; + const allFileCount = expectedCount + unexpectedCount; + const results = await client.listDirectoryFiles(dir, { limit, page }); + + const files = await FileStorageFile.findAll({ + where: { fileStorageServiceId: fss.id }, + }); + + const totalPages = expectedCount / limit; + + expect(results.currentPage).to.be.eq(page); + expect(results.totalPages).to.be.eq(totalPages); + expect(results.data.length).to.be.eq(limit); + expect(results.totalItems).to.be.eq(expectedCount); + expect(files.length).to.be.eq(allFileCount); + }); + + it('should sort by name desc', async () => { + const { client, fss } = await createFileStorageServiceClient(); + const dir = path.join(client.S3_BASE_PATH, 'a/b/c/'); + const subdir = `${dir}/d/`; + const order = [['name', 'desc']]; + const expectedList = await factory.fileStorageFile.createBulk(fss.id, dir, { + files: 10, + directories: 2, + }); + const expectedCount = expectedList.files.length + expectedList.directories.length; + const unexpectedList = await factory.fileStorageFile.createBulk(fss.id, subdir, { + files: 2, + directories: 1, + }); + const unexpectedCount = + unexpectedList.files.length + unexpectedList.directories.length; + const allFileCount = expectedCount + unexpectedCount; + const results = await client.listDirectoryFiles(dir, { order }); + + const files = await FileStorageFile.findAll({ + where: { fileStorageServiceId: fss.id }, + }); + + const firstRecordIncrement = parseInt( + results.data[0].name.split('-').slice(-1)[0], + 10, + ); + const secondRecordIncrement = parseInt( + results.data[1].name.split('-').slice(-1)[0], + 10, + ); + + expect(firstRecordIncrement).to.be.gt(secondRecordIncrement); + expect(results.currentPage).to.be.eq(1); + expect(results.totalPages).to.be.eq(1); + expect(results.data.length).to.be.eq(expectedCount); + expect(results.totalItems).to.be.eq(expectedCount); + expect(files.length).to.be.eq(allFileCount); + }); + }); + + describe('uploadFile', () => { + it('should create a directory with a name and path', async () => { + const { client, user } = await createFileStorageServiceClient(); + const parent = '/a/b/c'; + const name = 'test.txt'; + const fileBuffer = Buffer.from('file content'); + const type = 'plain/txt'; + const metadata = { size: 123 }; + const expectedKey = path.join(client.S3_BASE_PATH, parent, name); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').resolves(); + + const results = await client.uploadFile(name, fileBuffer, type, parent, metadata); + + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: results.id, + fileStorageServiceId: client.id, + userId: user.id, + }, + }); + + expect(s3stub.calledOnceWith(fileBuffer, `${expectedKey}`)).to.be.eq(true); + expect(fsua.method).to.be.eq(FileStorageUserAction.METHODS.POST); + expect(fsua.description).to.be.eq(FileStorageUserAction.ACTION_TYPES.UPLOAD_FILE); + }); + + it('should create a directory appended to the ~assets root', async () => { + const { client, user } = await createFileStorageServiceClient(); + const parent = '/a/b/c'; + const name = 'test.txt'; + const fileBuffer = Buffer.from('file content'); + const type = 'plain/txt'; + const metadata = { size: 123 }; + const expectedKey = path.join(client.S3_BASE_PATH, parent, name); + const s3stub = sinon.stub(S3Helper.S3Client.prototype, 'putObject').resolves(); + + const results = await client.uploadFile(name, fileBuffer, type, parent, metadata); + + const fsua = await FileStorageUserAction.findOne({ + where: { + fileStorageFileId: results.id, + fileStorageServiceId: client.id, + userId: user.id, + }, + }); + + expect(s3stub.calledOnceWith(fileBuffer, `${expectedKey}`)).to.be.eq(true); + expect(fsua.method).to.be.eq(FileStorageUserAction.METHODS.POST); + expect(fsua.description).to.be.eq(FileStorageUserAction.ACTION_TYPES.UPLOAD_FILE); + }); + }); +}); diff --git a/test/api/unit/utils/utils.test.js b/test/api/unit/utils/utils.test.js index 34520a47a..07fd8f0c2 100644 --- a/test/api/unit/utils/utils.test.js +++ b/test/api/unit/utils/utils.test.js @@ -1,6 +1,7 @@ const { expect } = require('chai'); const fs = require('node:fs'); const path = require('node:path'); +const { randomBytes } = require('node:crypto'); const sinon = require('sinon'); const moment = require('moment'); const fsMock = require('mock-fs'); @@ -389,4 +390,114 @@ describe('utils', () => { expect(end - start).to.within(time - 10, time + 20); }); }); + + describe('.slugify', () => { + it('should slugify a string', () => { + const input = 'Hello World'; + const expected = 'hello-world'; + + const result = utils.slugify(input); + expect(result).to.be.eq(expected); + }); + + it('should preserve file extension', () => { + const input = 'test.txt'; + const expected = 'test.txt'; + + const result = utils.slugify(input); + expect(result).to.be.eq(expected); + }); + + it('should preserve file extension when addition periods in name', () => { + const input = 'test.one two.txt'; + const expected = 'testone-two.txt'; + + const result = utils.slugify(input); + expect(result).to.be.eq(expected); + }); + + it('should slugify a number', () => { + const input = 25624; + const expected = '25624'; + + const result = utils.slugify(input); + expect(result).to.be.eq(expected); + }); + + it('should remove accents and punctuation from a string', () => { + const input = 'Cazá, Cazá'; + const expected = 'caza-caza'; + + const result = utils.slugify(input); + expect(result).to.be.eq(expected); + }); + + it('should remove slashes from a string', () => { + const input = 'hello\\world'; + const input2 = 'hello/world'; + const expected = 'helloworld'; + + const result = utils.slugify(input); + const result2 = utils.slugify(input2); + expect(result).to.be.eq(expected); + expect(result2).to.be.eq(expected); + }); + + it('throws if not a string or number', () => { + const input = { test: 1 }; + const message = 'Text must be a string or number.'; + + try { + utils.slugify(input); + } catch (error) { + expect(error).to.be.throw; + expect(error.message).to.be.eq(message); + } + }); + + it('throws if string is 201+ characters', () => { + const input = randomBytes(101).toString('hex').slice(0, 201); + const message = 'Text must be less than or equal to 200 characters.'; + + try { + utils.slugify(input); + } catch (error) { + expect(error).to.be.throw; + expect(error.message).to.be.eq(message); + } + }); + }); + + describe('.normalizeDirectoryPath', () => { + it('should return a good dir path', () => { + const dir = 'asdf/asdf/'; + const result = utils.normalizeDirectoryPath(dir); + + expect(result).to.be.eq(dir); + }); + + it('should remove a leading slash', () => { + const expected = 'asdf/asdf/'; + const dir = `/${expected}`; + const result = utils.normalizeDirectoryPath(dir); + + expect(result).to.be.eq(expected); + }); + + it('should remove add a trailing slash', () => { + const expected = 'asdf/asdf/'; + const dir = `asdf/asdf`; + const result = utils.normalizeDirectoryPath(dir); + + expect(result).to.be.eq(expected); + }); + + it('should normalize slashes', () => { + const expected = 'asdf/asdf/'; + const dir = `/${expected}/`; + const result = utils.normalizeDirectoryPath(dir); + + expect(result).to.be.eq(expected); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index df40c8459..a30792608 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3634,6 +3634,11 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + append-transform@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-2.0.0.tgz#99d9d29c7b38391e6f428d28ce136551f0b77e12" @@ -4149,6 +4154,13 @@ bullmq@^5.7.0: tslib "^2.0.0" uuid "^9.0.0" +busboy@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -4553,6 +4565,16 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + concurrently@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.0.tgz#8da6d609f4321752912dab9be8710232ac496aa0" @@ -4666,6 +4688,11 @@ core-js@^2.6.5: resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cors@^2.8.5, cors@~2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" @@ -6756,7 +6783,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -7143,6 +7170,11 @@ isarray@^2.0.5: resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -8277,7 +8309,7 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -mkdirp@~0.5.0: +mkdirp@^0.5.4, mkdirp@~0.5.0: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -8383,6 +8415,19 @@ msgpackr@^1.11.2: optionalDependencies: msgpackr-extract "^3.0.2" +multer@^1.4.5-lts.1: + version "1.4.5-lts.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac" + integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ== + dependencies: + append-field "^1.0.0" + busboy "^1.0.0" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + mute-stream@~0.0.4: version "0.0.8" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" @@ -9227,6 +9272,11 @@ pretty-format@^29.7.0: ansi-styles "^5.0.0" react-is "^18.0.0" +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + process-on-spawn@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/process-on-spawn/-/process-on-spawn-1.1.0.tgz#9d5999ba87b3bf0a8acb05322d69f2f5aa4fb763" @@ -9505,6 +9555,19 @@ read@1.0.x: dependencies: mute-stream "~0.0.4" +readable-stream@^2.2.2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^3.4.0, readable-stream@^3.6.2: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" @@ -9885,7 +9948,7 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@5.1.2: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -10385,6 +10448,11 @@ statuses@2.0.1, statuses@^2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + string-length@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" @@ -10481,6 +10549,13 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" @@ -10821,6 +10896,14 @@ type-fest@^2.13.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== +type-is@^1.6.4, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + type-is@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.0.tgz#7d249c2e2af716665cc149575dadb8b3858653af" @@ -10830,14 +10913,6 @@ type-is@^2.0.0: media-typer "^1.1.0" mime-types "^3.0.0" -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" @@ -10901,6 +10976,11 @@ typedarray.prototype.slice@^1.0.3: typed-array-buffer "^1.0.2" typed-array-byte-offset "^1.0.2" +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + typedescriptor@3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/typedescriptor/-/typedescriptor-3.0.2.tgz#9ad1715bc2be1cf063d5acbc4cd4bfc96d644225" @@ -11037,7 +11117,7 @@ urlsafe-base64@^1.0.0: resolved "https://registry.yarnpkg.com/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz#23f89069a6c62f46cf3a1d3b00169cefb90be0c6" integrity sha512-RtuPeMy7c1UrHwproMZN9gN6kiZ0SvJwRaEzwZY0j9MypEkFqyBaKv176jvlPtg58Zh36bOkS0NFABXMHvvGCA== -util-deprecate@^1.0.1, util-deprecate@^1.0.2: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==