diff --git a/sites/backend/scripts/import-sizing-table.mjs b/sites/backend/scripts/import-sizing-table.mjs new file mode 100644 index 00000000000..2b74f53a4e5 --- /dev/null +++ b/sites/backend/scripts/import-sizing-table.mjs @@ -0,0 +1,177 @@ +import path from 'path' +import fs from 'fs' +import { PrismaClient } from '@prisma/client' +import { hash, encryption } from '../src/utils/crypto.mjs' +import { clean } from '../src/utils/index.mjs' +import { verifyConfig } from '../src/config.mjs' +import { + cisFemaleAdult, + cisMaleAdult, + cisFemaleDoll, + cisMaleDoll, + cisFemaleGiant, + cisMaleGiant, +} from '../../../packages/models/src/index.mjs' +const prisma = new PrismaClient() +const config = verifyConfig() +const { encrypt, decrypt } = encryption(config.encryption.key) + +/* + * Note: This will import the FreeSewing (v2) sizing table into the + * FreeSewing curator account. + * + * The curator account is just a regular user account, but one that + * we use to store the 'official' measurement sets. + * + * Unless you are a very involved contributor, there is probably + * no reason for you to run this script. + */ + +// Holds the sets to create +const sets = [] + +// Helper method to create the set data +const createSetData = ({ name, measies, imperial }) => ({ + imperial, + name, + measies, + userId: config.curator.id, + public: true, +}) + +// CIS Female Adult +sets.push( + ...Object.keys(cisFemaleAdult).map((size) => + createSetData({ + name: `Metric Cis Female Adult - Size ${size} (EU)`, + measies: cisFemaleAdult[size], + imperial: false, + }) + ) +) +sets.push( + ...Object.keys(cisFemaleAdult).map((size) => + createSetData({ + name: `Imperial Cis Female Adult - Size ${size} (EU)`, + measies: cisFemaleAdult[size], + imperial: true, + }) + ) +) + +// CIS Male Adult +sets.push( + ...Object.keys(cisMaleAdult).map((size) => + createSetData({ + name: `Metric Cis Male Adult - Size ${size} (EU)`, + measies: cisMaleAdult[size], + imperial: false, + }) + ) +) +sets.push( + ...Object.keys(cisMaleAdult).map((size) => + createSetData({ + name: `Imperial Cis Male Adult - Size ${size} (EU)`, + measies: cisMaleAdult[size], + imperial: true, + }) + ) +) + +// CIS Female Doll +sets.push( + ...Object.keys(cisFemaleDoll).map((size) => + createSetData({ + name: `Metric Cis Female Doll - ${size}%`, + measies: cisFemaleDoll[size], + imperial: false, + }) + ) +) +sets.push( + ...Object.keys(cisFemaleDoll).map((size) => + createSetData({ + name: `Imperial Cis Female Doll - ${size}%`, + measies: cisFemaleDoll[size], + imperial: true, + }) + ) +) + +// CIS Male Doll +sets.push( + ...Object.keys(cisMaleDoll).map((size) => + createSetData({ + name: `Metric Cis Male Doll - ${size}%`, + measies: cisMaleDoll[size], + imperial: false, + }) + ) +) +sets.push( + ...Object.keys(cisMaleDoll).map((size) => + createSetData({ + name: `Imperial Cis Male Doll - ${size}%`, + measies: cisMaleDoll[size], + imperial: true, + }) + ) +) + +// CIS Female Giant +sets.push( + ...Object.keys(cisFemaleGiant).map((size) => + createSetData({ + name: `Metric Cis Female Giant - Size ${size}%`, + measies: cisFemaleGiant[size], + imperial: false, + }) + ) +) +sets.push( + ...Object.keys(cisFemaleGiant).map((size) => + createSetData({ + name: `Imperial Cis Female Giant - Size ${size}%`, + measies: cisFemaleGiant[size], + imperial: true, + }) + ) +) + +// CIS Male Giant +sets.push( + ...Object.keys(cisMaleGiant).map((size) => + createSetData({ + name: `Metric Cis Male Giant - Size ${size}%`, + measies: cisMaleGiant[size], + imperial: false, + }) + ) +) +sets.push( + ...Object.keys(cisMaleGiant).map((size) => + createSetData({ + name: `Imperial Cis Male Giant - Size ${size}%`, + measies: cisMaleGiant[size], + imperial: true, + }) + ) +) + +importSets(sets) + +async function createSet(set) { + try { + record = await prisma.user.create({ data: set }) + } catch (err) { + console.log(err) + } +} + +async function importSets(sets) { + for (const set of sets) { + console.log(`Importing ${set.name}`) + await createSet(set) + } +} diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index 41da446764b..73bb983e1e8 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -27,6 +27,8 @@ const envToBool = (input = 'no') => { // Construct config object const baseConfig = { + // Environment + env: process.env.NODE_ENV || 'development', // Feature flags use: { github: envToBool(process.env.BACKEND_ENABLE_GITHUB), @@ -87,6 +89,7 @@ const baseConfig = { }, tests: { domain: process.env.BACKEND_TEST_DOMAIN || 'freesewing.dev', + production: envToBool(process.env.BACKEND_ALLOW_TESTS_IN_PRODUCTION), }, website: { domain: process.env.BACKEND_WEBSITE_DOMAIN || 'freesewing.org', @@ -202,6 +205,7 @@ const vars = { BACKEND_ENABLE_OAUTH_GITHUB: 'optional', BACKEND_ENABLE_OAUTH_GOOGLE: 'optional', BACKEND_ENABLE_TESTS: 'optional', + BACKEND_ALLOW_TESTS_IN_PRODUCTION: 'optional', BACKEND_ENABLE_DUMP_CONFIG_AT_STARTUP: 'optional', } @@ -241,7 +245,7 @@ if (envToBool(process.env.BACKEND_ENABLE_OAUTH_GOOGLE)) { vars.BACKEND_OAUTH_GOOGLE_CLIENT_ID = 'required' vars.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET = 'requiredSecret' } -// Vars for unit tests +// Vars for (unit) tests if (envToBool(process.env.BACKEND_ENABLE_TESTS)) { vars.BACKEND_TEST_DOMAIN = 'optional' vars.BACKEND_ENABLE_TESTS_EMAIL = 'optional' diff --git a/sites/backend/src/controllers/curated-sets.mjs b/sites/backend/src/controllers/curated-sets.mjs new file mode 100644 index 00000000000..06566cd27e8 --- /dev/null +++ b/sites/backend/src/controllers/curated-sets.mjs @@ -0,0 +1,79 @@ +import { CuratedSetModel } from '../models/curated-set.mjs' +import { SetModel } from '../models/set.mjs' + +export function CuratedSetsController() {} + +/* + * Create a curated measurements set + * See: https://freesewing.dev/reference/backend/api + */ +CuratedSetsController.prototype.create = async (req, res, tools) => { + const CuratedSet = new CuratedSetModel(tools) + await CuratedSet.guardedCreate(req) + + return CuratedSet.sendResponse(res) +} + +/* + * Read a curated measurements set + * See: https://freesewing.dev/reference/backend/api + */ +CuratedSetsController.prototype.read = async (req, res, tools, format = false) => { + const CuratedSet = new CuratedSetModel(tools) + await CuratedSet.guardedRead(req, format) + + return format === 'yaml' ? CuratedSet.sendYamlResponse(res) : CuratedSet.sendResponse(res) +} + +/* + * Get a list of curated measurements sets + * See: https://freesewing.dev/reference/backend/api + */ +CuratedSetsController.prototype.list = async (req, res, tools, format = false) => { + const CuratedSet = new CuratedSetModel(tools) + const curatedSets = await CuratedSet.allCuratedSets() + + if (curatedSets) { + if (!format) CuratedSet.setResponse(200, 'success', { curatedSets }) + else CuratedSet.setResponse(200, 'success', curatedSets, true) + } else CuratedSet.setResponse(404, 'notFound') + + return format === 'yaml' && curatedSets + ? CuratedSet.sendYamlResponse(res) + : CuratedSet.sendResponse(res) +} + +/* + * Update a curated measurements set + * See: https://freesewing.dev/reference/backend/api + */ +CuratedSetsController.prototype.update = async (req, res, tools) => { + const CuratedSet = new CuratedSetModel(tools) + await CuratedSet.guardedUpdate(req) + + return CuratedSet.sendResponse(res) +} + +/* + * Remove a curated measurements set + * See: https://freesewing.dev/reference/backend/api + */ +CuratedSetsController.prototype.delete = async (req, res, tools) => { + const CuratedSet = new CuratedSetModel(tools) + await CuratedSet.guardedDelete(req) + + return Set.sendResponse(res) +} + +/* + * Clone a curated measurements set + * See: https://freesewing.dev/reference/backend/api + */ +CuratedSetsController.prototype.clone = async (req, res, tools) => { + const CuratedSet = new CuratedSetModel(tools) + const Set = new SetModel(tools) + await CuratedSet.guardedClone(req, Set) + + // Note: Sending the set back + return Set.sendResponse(res) +} diff --git a/sites/backend/src/models/curated-set.mjs b/sites/backend/src/models/curated-set.mjs new file mode 100644 index 00000000000..48429f7da55 --- /dev/null +++ b/sites/backend/src/models/curated-set.mjs @@ -0,0 +1,322 @@ +import { capitalize } from '../utils/index.mjs' +import { log } from '../utils/log.mjs' +import { setSetAvatar } from '../utils/sanity.mjs' +import yaml from 'js-yaml' + +export function CuratedSetModel(tools) { + this.config = tools.config + this.prisma = tools.prisma + this.rbac = tools.rbac + + return this +} + +CuratedSetModel.prototype.guardedCreate = async function ({ body, user }) { + if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel') + if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing') + if (!body.nameEn || typeof body.nameEn !== 'string') return this.setResponse(400, 'nameEnMissing') + + // Prepare data + const data = {} + for (const lang of this.config.languages) { + for (const field of ['name', 'notes']) { + const key = field + capitalize(lang) + if (body[key] && typeof body[key] === 'string') data[key] = body[key] + } + } + if (body.measies) data.measies = this.sanitizeMeasurements(body.measies) + else data.measies = {} + // Set this one initially as we need the ID to create a custom img via Sanity + data.img = this.config.avatars.set + + // Create record + await this.unguardedCreate(data) + + // Update img? (now that we have the ID) + const img = + this.config.use.sanity && + typeof body.img === 'string' && + (!body.test || (body.test && this.config.use.tests?.sanity)) + ? await setSetAvatar(this.record.id, body.img) + : false + + if (img) await this.unguardedUpdate({ img: img.url }) + else await this.read({ id: this.record.id }) + + return this.setResponse(201, 'created', { curatedSet: this.asCuratedSet() }) +} + +CuratedSetModel.prototype.unguardedCreate = async function (data) { + // FIXME: See https://github.com/prisma/prisma/issues/3786 + if (data.measies && typeof data.measies === 'object') data.measies = JSON.stringify(data.measies) + try { + this.record = await this.prisma.curatedSet.create({ data }) + } catch (err) { + log.warn(err, 'Could not create set') + return this.setResponse(500, 'createSetFailed') + } + + return this +} + +/* + * Loads a measurements set from the database based on the where clause you pass it + * + * Stores result in this.record + */ +CuratedSetModel.prototype.read = async function (where) { + try { + this.record = await this.prisma.curatedSet.findUnique({ where }) + } catch (err) { + log.warn({ err, where }, 'Could not read measurements set') + } + + // FIXME: Convert JSON to object. See https://github.com/prisma/prisma/issues/3786 + this.record.measies = JSON.parse(this.record.measies) + + return this.curatedSetExists() +} + +/* + * Loads a measurements set from the database based on the where clause you pass it + * In addition prepares it for returning the set data + * + * Stores result in this.record + */ +CuratedSetModel.prototype.guardedRead = async function ({ params }, format = false) { + await this.read({ id: parseInt(params.id) }) + + if (!format) + return this.setResponse(200, false, { + result: 'success', + curatedSet: this.asCuratedSet(), + }) + + return this.setResponse(200, false, this.asData(), true) +} + +/* + * Returns a list of all curated sets + */ +CuratedSetModel.prototype.allCuratedSets = async function () { + let curatedSets + try { + curatedSets = await this.prisma.curatedSet.findMany() + } catch (err) { + log.warn(`Failed to search curated sets: ${err}`) + } + const list = [] + for (const curatedSet of curatedSets) list.push(curatedSet) + + return list +} + +/* + * Clones a curated measurements set (into a regular set) + * In addition prepares it for returning the set data + * + * Stores result in this.record + */ +CuratedSetModel.prototype.guardedClone = async function ({ params, user, body }, Set) { + if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + if (!body.language || !this.config.languages.includes(body.language)) + return this.setResponse(403, 'languageMissing') + + await this.read({ id: parseInt(params.id) }) + + // Clone curated set + const data = {} + const lang = capitalize(body.language.toLowerCase()) + data.name = this.record[`name${lang}`] + data.notes = this.record[`notes${lang}`] + data.measies = this.record.measies + + await Set.guardedCreate({ params, user, body: data }) + + return +} + +/* + * Checks this.record and sets a boolean to indicate whether + * the curated set exists or not + * + * Stores result in this.exists + */ +CuratedSetModel.prototype.curatedSetExists = function () { + this.exists = this.record ? true : false + + return this +} + +/* + * Updates the set data - Used when we create the data ourselves + * so we know it's safe + */ +CuratedSetModel.prototype.unguardedUpdate = async function (data) { + // FIXME: Convert object to JSON. See https://github.com/prisma/prisma/issues/3786 + if (data.measies && typeof data.measies === 'object') data.measies = JSON.stringify(data.measies) + + try { + this.record = await this.prisma.curatedSet.update({ + where: { id: this.record.id }, + data, + }) + // FIXME: Convert JSON to object. See https://github.com/prisma/prisma/issues/3786 + this.record.measies = JSON.parse(this.record.measies) + } catch (err) { + log.warn(err, 'Could not update set record') + process.exit() + return this.setResponse(500, 'updateSetFailed') + } + + return this.setResponse(200) +} + +/* + * Updates the set data - Used when we pass through user-provided data + * so we can't be certain it's safe + */ +CuratedSetModel.prototype.guardedUpdate = async function ({ params, body, user }) { + if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + await this.read({ id: parseInt(params.id) }) + + // Prepare data + const data = {} + for (const lang of this.config.languages) { + for (const field of ['name', 'notes']) { + const key = field + capitalize(lang) + if (body[key] && typeof body[key] === 'string') data[key] = body[key] + } + } + // Measurements + const measies = {} + if (typeof body.measies === 'object') { + const remove = [] + for (const [key, val] of Object.entries(body.measies)) { + if (this.config.measies.includes(key)) { + if (val === null) remove.push(key) + else if (typeof val == 'number' && val > 0) measies[key] = val + } + } + data.measies = { ...this.record.measies, ...measies } + for (const key of remove) delete data.measies[key] + } + + // Image (img) + if (typeof body.img === 'string') { + const img = await setSetAvatar(params.id, body.img) + data.img = img.url + } + + // Now update the record + await this.unguardedUpdate(data) + + return this.setResponse(200, false, { set: this.asCuratedSet() }) +} + +/* + * Removes the set - No questions asked + */ +CuratedSetModel.prototype.unguardedDelete = async function () { + await this.prisma.curatedSet.delete({ where: { id: this.record.id } }) + this.record = null + this.clear = null + + return this.curatedSetExists() +} + +/* + * Removes the set - Checks permissions + */ +CuratedSetModel.prototype.guardedDelete = async function ({ params, user }) { + if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + await this.read({ id: parseInt(params.id) }) + if (this.record.userId !== user.uid && user.level < 8) { + return this.setResponse(403, 'insufficientAccessLevel') + } + + await this.unguardedDelete() + + return this.setResponse(204, false) +} + +/* + * Returns record data fit for public publishing + */ +CuratedSetModel.prototype.asCuratedSet = function () { + return { ...this.record } +} + +/* + * Returns record data fit for public publishing + */ +CuratedSetModel.prototype.asData = function () { + const data = { + author: 'FreeSewing.org', + type: 'curatedMeasurementsSet', + about: 'Contains measurements in mm as well as metadata', + ...this.asCuratedSet(), + } + data.measurements = data.measies + delete data.measies + + return data +} + +/* + * Helper method to set the response code, result, and body + * + * Will be used by this.sendResponse() + */ +CuratedSetModel.prototype.setResponse = function ( + status = 200, + error = false, + data = {}, + rawData = false +) { + this.response = { + status, + body: rawData + ? data + : { + result: 'success', + ...data, + }, + } + if (status > 201) { + this.response.body.error = error + this.response.body.result = 'error' + this.error = true + } else this.error = false + + return this.curatedSetExists() +} + +/* + * Helper method to send response (as JSON) + */ +CuratedSetModel.prototype.sendResponse = async function (res) { + return res.status(this.response.status).send(this.response.body) +} + +/* + * Helper method to send response as YAML + */ +CuratedSetModel.prototype.sendYamlResponse = async function (res) { + return res.status(this.response.status).type('yaml').send(yaml.dump(this.response.body)) +} + +/* Helper method to parse user-supplied measurements */ +CuratedSetModel.prototype.sanitizeMeasurements = function (input) { + const measies = {} + if (typeof input !== 'object') return measies + for (const [m, val] of Object.entries(input)) { + if (this.config.measies.includes(m) && typeof val === 'number' && val > 0) measies[m] = val + } + + return measies +} diff --git a/sites/backend/src/models/pattern.mjs b/sites/backend/src/models/pattern.mjs index 78a0163b1b3..5e954e1ca59 100644 --- a/sites/backend/src/models/pattern.mjs +++ b/sites/backend/src/models/pattern.mjs @@ -51,7 +51,7 @@ PatternModel.prototype.guardedCreate = async function ({ body, user }) { const img = this.config.use.sanity && typeof body.img === 'string' && - (!body.unittest || (body.unittest && this.config.use.tests?.sanity)) + (!body.test || (body.test && this.config.use.tests?.sanity)) ? await setPatternAvatar(this.record.id, body.img) : false diff --git a/sites/backend/src/models/set.mjs b/sites/backend/src/models/set.mjs index 2fd0ec9c6f0..211e8bf0256 100644 --- a/sites/backend/src/models/set.mjs +++ b/sites/backend/src/models/set.mjs @@ -42,7 +42,7 @@ SetModel.prototype.guardedCreate = async function ({ body, user }) { const img = this.config.use.sanity && typeof body.img === 'string' && - (!body.unittest || (body.unittest && this.config.use.tests?.sanity)) + (!body.test || (body.test && this.config.use.tests?.sanity)) ? await setSetAvatar(this.record.id, body.img) : false @@ -393,10 +393,10 @@ SetModel.prototype.sendYamlResponse = async function (res) { /* * Update method to determine whether this request is - * part of a unit test + * part of a test */ -//UserModel.prototype.isUnitTest = function (body) { -// if (!body.unittest) return false +//UserModel.prototype.isTest = function (body) { +// if (!body.test) return false // if (!this.clear.email.split('@').pop() === this.config.tests.domain) return false // if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false // diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index a821279aed8..0e3b8730f68 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -150,6 +150,10 @@ UserModel.prototype.guardedCreate = async function ({ body }) { const ehash = hash(clean(body.email)) const check = randomString() await this.read({ ehash }) + + // Check for unit tests only once + const isTest = this.isTest(body) + if (this.exists) { /* * User already exists. However, if we return an error, then baddies can @@ -183,20 +187,21 @@ UserModel.prototype.guardedCreate = async function ({ body }) { userId: this.record.id, }) } - // Always send email - await this.mailer.send({ - template: type, - language: body.language, - to: this.clear.email, - replacements: { - actionUrl: - type === 'signup-aed' - ? false // No actionUrl for disabled accounts - : i18nUrl(body.language, `/confirm/${type}/${this.Confirmation.record.id}/${check}`), - whyUrl: i18nUrl(body.language, `/docs/faq/email/why-${type}`), - supportUrl: i18nUrl(body.language, `/patrons/join`), - }, - }) + // Send email unless it's a test and we don't want to send test emails + if (!isTest || this.config.tests.sendEmail) + await this.mailer.send({ + template: type, + language: body.language, + to: this.clear.email, + replacements: { + actionUrl: + type === 'signup-aed' + ? false // No actionUrl for disabled accounts + : i18nUrl(body.language, `/confirm/${type}/${this.Confirmation.record.id}/${check}`), + whyUrl: i18nUrl(body.language, `/docs/faq/email/why-${type}`), + supportUrl: i18nUrl(body.language, `/patrons/join`), + }, + }) // Now return as if everything is fine return this.setResponse(201, false, { email: this.clear.email }) @@ -225,6 +230,8 @@ UserModel.prototype.guardedCreate = async function ({ body }) { // Set this one initially as we need the ID to create a custom img via Sanity img: this.encrypt(this.config.avatars.user), } + // During tests, users can set their own permission level so you can test admin stuff + if (isTest && body.role) data.role = body.role this.record = await this.prisma.user.create({ data }) } catch (err) { log.warn(err, 'Could not create user record') @@ -256,7 +263,7 @@ UserModel.prototype.guardedCreate = async function ({ body }) { }) // Send signup email - if (!this.isUnitTest(body) || this.config.tests.sendEmail) + if (!this.isTest(body) || this.config.tests.sendEmail) await this.mailer.send({ template: 'signup', language: this.language, @@ -271,7 +278,7 @@ UserModel.prototype.guardedCreate = async function ({ body }) { }, }) - return this.isUnitTest(body) + return this.isTest(body) ? this.setResponse(201, false, { email: this.clear.email, confirmation: this.confirmation.record.id, @@ -390,8 +397,8 @@ UserModel.prototype.sendSigninlink = async function (req) { }, userId: this.record.id, }) - const isUnitTest = this.isUnitTest(req.body) - if (!isUnitTest) { + const isTest = this.isTest(req.body) + if (!isTest) { // Send sign-in link email await this.mailer.send({ template: 'signinlink', @@ -522,7 +529,7 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) { // Now update the record await this.unguardedUpdate(this.cloak(data)) - const isUnitTest = this.isUnitTest(body) + const isTest = this.isTest(body) if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) { // Email change (requires confirmation) const check = randomString() @@ -538,7 +545,7 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) { }, userId: this.record.id, }) - if (!isUnitTest || this.config.tests.sendEmail) { + if (!isTest || this.config.tests.sendEmail) { // Send confirmation email await this.mailer.send({ template: 'emailchange', @@ -590,8 +597,7 @@ UserModel.prototype.guardedUpdate = async function ({ body, user }) { result: 'success', account: this.asAccount(), } - if (isUnitTest && this.Confirmation.record?.id) - returnData.confirmation = this.Confirmation.record.id + if (isTest && this.Confirmation.record?.id) returnData.confirmation = this.Confirmation.record.id return this.setResponse(200, false, returnData) } @@ -755,11 +761,14 @@ UserModel.prototype.sendResponse = async function (res) { /* * Update method to determine whether this request is - * part of a unit test + * part of a (unit) test */ -UserModel.prototype.isUnitTest = function (body) { - if (!body.unittest) return false - if (!this.clear.email.split('@').pop() === this.config.tests.domain) return false +UserModel.prototype.isTest = function (body) { + // Disalowing tests in prodution is hard-coded to protect people from + if (this.config.env === 'production' && !this.config.tests.production) return false + if (!body.test) return false + if (this.clear?.email && !this.clear.email.split('@').pop() === this.config.tests.domain) + return false if (body.email && !body.email.split('@').pop() === this.config.tests.domain) return false return true diff --git a/sites/backend/src/routes/curated-sets.mjs b/sites/backend/src/routes/curated-sets.mjs new file mode 100644 index 00000000000..563c5d5446e --- /dev/null +++ b/sites/backend/src/routes/curated-sets.mjs @@ -0,0 +1,51 @@ +import { CuratedSetsController } from '../controllers/curated-sets.mjs' + +const CuratedSets = new CuratedSetsController() +const jwt = ['jwt', { session: false }] +const bsc = ['basic', { session: false }] + +export function curatedSetsRoutes(tools) { + const { app, passport } = tools + + // Read a curated measurements set (no need to authenticate for this) + app.get('/curated-sets/:id.json', (req, res) => CuratedSets.read(req, res, tools, 'json')) + app.get('/curated-sets/:id.yaml', (req, res) => CuratedSets.read(req, res, tools, 'yaml')) + app.get('/curated-sets/:id', (req, res) => CuratedSets.read(req, res, tools)) + + // Get a list of all curated measurments sets (no need to authenticate for this) + app.get('/curated-sets.json', (req, res) => CuratedSets.list(req, res, tools, 'json')) + app.get('/curated-sets.yaml', (req, res) => CuratedSets.list(req, res, tools, 'yaml')) + app.get('/curated-sets', (req, res) => CuratedSets.list(req, res, tools)) + + // Create a curated measurements set + app.post('/curated-sets/jwt', passport.authenticate(...jwt), (req, res) => + CuratedSets.create(req, res, tools) + ) + app.post('/curated-sets/key', passport.authenticate(...bsc), (req, res) => + CuratedSets.create(req, res, tools) + ) + + // Clone a curated measurements set + app.post('/curated-sets/:id/clone/jwt', passport.authenticate(...jwt), (req, res) => + CuratedSets.clone(req, res, tools) + ) + app.post('/curated-sets/:id/clone/key', passport.authenticate(...bsc), (req, res) => + CuratedSets.clone(req, res, tools) + ) + + // Update a curated measurements set + app.patch('/curated-sets/:id/jwt', passport.authenticate(...jwt), (req, res) => + CuratedSets.update(req, res, tools) + ) + app.patch('/curated-sets/:id/key', passport.authenticate(...bsc), (req, res) => + CuratedSets.update(req, res, tools) + ) + + // Delete a curated measurements set + app.delete('/curated-sets/:id/jwt', passport.authenticate(...jwt), (req, res) => + CuratedSets.delete(req, res, tools) + ) + app.delete('/curated-sets/:id/key', passport.authenticate(...bsc), (req, res) => + CuratedSets.delete(req, res, tools) + ) +} diff --git a/sites/backend/src/routes/index.mjs b/sites/backend/src/routes/index.mjs index c2a4a52d7a5..8f6a5aa9eae 100644 --- a/sites/backend/src/routes/index.mjs +++ b/sites/backend/src/routes/index.mjs @@ -3,7 +3,7 @@ import { usersRoutes } from './users.mjs' import { setsRoutes } from './sets.mjs' import { patternsRoutes } from './patterns.mjs' import { confirmationsRoutes } from './confirmations.mjs' -//import { curatedSetsRoutes } from './curated-sets.mjs' +import { curatedSetsRoutes } from './curated-sets.mjs' export const routes = { apikeysRoutes, @@ -11,5 +11,5 @@ export const routes = { setsRoutes, patternsRoutes, confirmationsRoutes, - //curatedSetsRoutes, + curatedSetsRoutes, } diff --git a/sites/backend/src/utils/index.mjs b/sites/backend/src/utils/index.mjs index 7c8d7abacc1..4c72ea9451a 100644 --- a/sites/backend/src/utils/index.mjs +++ b/sites/backend/src/utils/index.mjs @@ -1,5 +1,10 @@ import { website } from '../config.mjs' +/* + * Capitalizes a string + */ +export const capitalize = (string) => string.charAt(0).toUpperCase() + string.slice(1) + /* * Cleans a string (typically email) for hashing */ diff --git a/sites/backend/tests/account.mjs b/sites/backend/tests/account.mjs index 1455c004e2b..6300e8f0588 100644 --- a/sites/backend/tests/account.mjs +++ b/sites/backend/tests/account.mjs @@ -232,7 +232,7 @@ export const accountTests = async (chai, config, expect, store) => { ) .send({ email: `updating_${store.randomString()}@${store.config.tests.domain}`, - unittest: true, + test: true, }) .end((err, res) => { expect(err === null).to.equal(true) @@ -287,7 +287,7 @@ export const accountTests = async (chai, config, expect, store) => { ) .send({ email: store.account.email, - unittest: true, + test: true, }) .end((err, res) => { expect(err === null).to.equal(true) diff --git a/sites/backend/tests/curated-set.mjs b/sites/backend/tests/curated-set.mjs new file mode 100644 index 00000000000..da9fb041d6b --- /dev/null +++ b/sites/backend/tests/curated-set.mjs @@ -0,0 +1,309 @@ +import { cat } from './cat.mjs' +import { capitalize } from '../src/utils/index.mjs' + +export const curatedSetTests = async (chai, config, expect, store) => { + const data = { + jwt: { + nameDe: 'Beispielmessungen A', + nameEn: 'Example measurements A', + nameEs: 'Medidas de ejemplo A', + nameFr: 'Mesures exemple A', + nameNl: 'Voorbeel maten A', + notesDe: 'Das sind die Notizen A', + notesEn: 'These are the notes A', + notesEs: 'Estas son las notas A', + notesFr: 'Ce sont les notes A', + notesNl: 'Dit zijn de notities A', + measies: { + chest: 1000, + neck: 420, + }, + }, + key: { + nameDe: 'Beispielmessungen B', + nameEn: 'Example measurements B', + nameEs: 'Medidas de ejemplo B', + nameFr: 'Mesures exemple B', + nameNl: 'Voorbeel maten B', + notesDe: 'Das sind die Notizen B', + notesEn: 'These are the notes B', + notesEs: 'Estas son las notas B', + notesFr: 'Ce sont les notes B', + notesNl: 'Dit zijn de notities B', + measies: { + chest: 930, + neck: 360, + }, + img: cat, + }, + } + store.curatedSet = { + jwt: {}, + key: {}, + } + store.altset = { + jwt: {}, + key: {}, + } + + for (const auth of ['jwt', 'key']) { + describe(`${store.icon('set', auth)} Curated set tests (${auth})`, () => { + it(`${store.icon('set', auth)} Should create a new curated set (${auth})`, (done) => { + chai + .request(config.api) + .post(`/curated-sets/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .send(data[auth]) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(201) + expect(res.body.result).to.equal(`success`) + for (const [key, val] of Object.entries(data[auth])) { + if (!['measies', 'img'].includes(key)) expect(res.body.curatedSet[key]).to.equal(val) + } + store.curatedSet[auth] = res.body.curatedSet + done() + }) + }).timeout(5000) + + for (const field of ['name', 'notes']) { + for (const lang of config.languages) { + const langField = field + capitalize(lang) + it(`${store.icon( + 'set', + auth + )} Should update the ${langField} field (${auth})`, (done) => { + const data = {} + const val = store.curatedSet[auth][langField] + '_updated' + data[langField] = val + chai + .request(config.api) + .patch(`/curated-sets/${store.curatedSet[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer( + `${store.account.apikey.key}:${store.account.apikey.secret}` + ).toString('base64') + ) + .send(data) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.set[langField]).to.equal(val) + done() + }) + }) + } + } + + for (const field of ['chest', 'neck', 'ankle']) { + it(`${store.icon( + 'set', + auth + )} Should update the ${field} measurement (${auth})`, (done) => { + const data = { measies: {} } + const val = Math.ceil(Math.random() * 1000) + data.measies[field] = val + chai + .request(config.api) + .patch(`/curated-sets/${store.curatedSet[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer( + `${store.account.apikey.key}:${store.account.apikey.secret}` + ).toString('base64') + ) + .send(data) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.set.measies[field]).to.equal(val) + done() + }) + }) + } + + it(`${store.icon( + 'set', + auth + )} Should not set an non-existing measurement (${auth})`, (done) => { + chai + .request(config.api) + .patch(`/curated-sets/${store.curatedSet[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .send({ + measies: { + ankle: 320, + potatoe: 12, + }, + }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(res.body.set.measies.ankle).to.equal(320) + expect(typeof res.body.set.measies.potatoe).to.equal('undefined') + done() + }) + }) + + it(`${store.icon('set', auth)} Should clear a measurement (${auth})`, (done) => { + chai + .request(config.api) + .patch(`/curated-sets/${store.curatedSet[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .send({ + measies: { + chest: null, + }, + }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(typeof res.body.set.measies.chest).to.equal('undefined') + done() + }) + }) + + it(`${store.icon('set', auth)} Should clone a set (${auth})`, (done) => { + chai + .request(config.api) + .post(`/curated-sets/${store.curatedSet[auth].id}/clone/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .send({ language: 'nl' }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(201) + expect(res.body.result).to.equal(`success`) + expect(typeof res.body.error).to.equal(`undefined`) + expect(typeof res.body.set.id).to.equal(`number`) + expect(res.body.set.name).to.equal(store.curatedSet[auth].nameNl + '_updated') + expect(res.body.set.notes).to.equal(store.curatedSet[auth].notesNl + '_updated') + done() + }) + }) + }) + } + + // Unauthenticated tests + describe(`${store.icon('set')} Curated set tests (unauthenticated)`, () => { + for (const auth of ['jwt', 'key']) { + it(`${store.icon('set')} Should read a curated set created with ${auth}`, (done) => { + chai + .request(config.api) + .get(`/curated-sets/${store.curatedSet[auth].id}`) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal(`success`) + expect(typeof res.body.curatedSet.measies).to.equal('object') + done() + }) + }) + + it(`${store.icon('set')} Should read a curated set created with ${auth} as JSON`, (done) => { + chai + .request(config.api) + .get(`/curated-sets/${store.curatedSet[auth].id}.json`) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(typeof res.body.measurements).to.equal('object') + done() + }) + }) + + it(`${store.icon('set')} Should read a curated set created with ${auth} as YAML`, (done) => { + chai + .request(config.api) + .get(`/curated-sets/${store.curatedSet[auth].id}.yaml`) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.text).to.include('FreeSewing') + done() + }) + }) + + it(`${store.icon('set')} Should retrieve a list of curated sets`, (done) => { + chai + .request(config.api) + .get(`/curated-sets`) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + expect(res.body.result).to.equal('success') + done() + }) + }) + + it(`${store.icon('set')} Should retrieve a list of curated sets as JSON`, (done) => { + chai + .request(config.api) + .get(`/curated-sets.json`) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + done() + }) + }) + + it(`${store.icon('set')} Should retrieve a list of curated sets as YAML`, (done) => { + chai + .request(config.api) + .get(`/curated-sets.yaml`) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(200) + done() + }) + }) + } + }) + //console.log(`/curated-sets/${store.curatedSet[auth].id}/${auth}`) + //console.log({body: res.body, status: res.status}) + + // TODO: + // - Delete Curated set +} diff --git a/sites/backend/tests/index.mjs b/sites/backend/tests/index.mjs index fe301bb4c6a..a85cca5f368 100644 --- a/sites/backend/tests/index.mjs +++ b/sites/backend/tests/index.mjs @@ -3,16 +3,18 @@ import { mfaTests } from './mfa.mjs' import { accountTests } from './account.mjs' import { apikeyTests } from './apikey.mjs' import { setTests } from './set.mjs' +import { curatedSetTests } from './curated-set.mjs' import { patternTests } from './pattern.mjs' import { setup } from './shared.mjs' const runTests = async (...params) => { - await userTests(...params) - await mfaTests(...params) - await apikeyTests(...params) - await accountTests(...params) - await setTests(...params) - await patternTests(...params) + //await userTests(...params) + //await mfaTests(...params) + //await apikeyTests(...params) + //await accountTests(...params) + //await setTests(...params) + await curatedSetTests(...params) + //await patternTests(...params) } // Load initial data required for tests diff --git a/sites/backend/tests/set.mjs b/sites/backend/tests/set.mjs index 4319bf0b07c..23c98e0b1c1 100644 --- a/sites/backend/tests/set.mjs +++ b/sites/backend/tests/set.mjs @@ -10,7 +10,7 @@ export const setTests = async (chai, config, expect, store) => { neck: 420, }, public: true, - unittest: true, + test: true, imperial: true, }, key: { @@ -22,7 +22,7 @@ export const setTests = async (chai, config, expect, store) => { }, public: false, img: cat, - unittest: true, + test: true, imperial: false, }, } @@ -56,8 +56,7 @@ export const setTests = async (chai, config, expect, store) => { expect(res.status).to.equal(201) expect(res.body.result).to.equal(`success`) for (const [key, val] of Object.entries(data[auth])) { - if (!['measies', 'img', 'unittest'].includes(key)) - expect(res.body.set[key]).to.equal(val) + if (!['measies', 'img', 'test'].includes(key)) expect(res.body.set[key]).to.equal(val) } store.set[auth] = res.body.set done() diff --git a/sites/backend/tests/shared.mjs b/sites/backend/tests/shared.mjs index 7d101c69d7f..6277ad4a94c 100644 --- a/sites/backend/tests/shared.mjs +++ b/sites/backend/tests/shared.mjs @@ -53,7 +53,8 @@ export const setup = async () => { result = await axios.post(`${store.config.api}/signup`, { email: store[acc].email, language: store[acc].language, - unittest: true, + test: true, + role: 'curator', }) } catch (err) { console.log('Failed at first setup request', err) @@ -80,7 +81,7 @@ export const setup = async () => { `${store.config.api}/apikeys/jwt`, { name: 'Test API key', - level: 4, + level: 5, expiresIn: 60, }, {