diff --git a/sites/backend/src/controllers/person.mjs b/sites/backend/src/controllers/person.mjs index 9dc1c10cd83..bdf32ee52a4 100644 --- a/sites/backend/src/controllers/person.mjs +++ b/sites/backend/src/controllers/person.mjs @@ -4,7 +4,6 @@ export function PersonController() {} /* * Create a person for the authenticated user - * * See: https://freesewing.dev/reference/backend/api */ PersonController.prototype.create = async (req, res, tools) => { @@ -16,34 +15,44 @@ PersonController.prototype.create = async (req, res, tools) => { /* * Read a person - * * See: https://freesewing.dev/reference/backend/api */ PersonController.prototype.read = async (req, res, tools) => { - //const Person = new PersonModel(tools) - //await Person.read({ id: req.params.id }) - //return Person.sendResponse(res) + const Person = new PersonModel(tools) + await Person.readForReturn({ id: parseInt(req.params.id) }, req.user) + + return Person.sendResponse(res) } /* * Update a person - * * See: https://freesewing.dev/reference/backend/api */ PersonController.prototype.update = async (req, res, tools) => { const Person = new PersonModel(tools) - await Person.unsafeUpdate(req) + await Person.guardedUpdate(req) return Person.sendResponse(res) } /* * Remove a person - * * See: https://freesewing.dev/reference/backend/api */ PersonController.prototype.delete = async (req, res, tools) => { - //const Person = new PersonModel(tools) - //await Person.remove(req) - //return Person.sendResponse(res) + const Person = new PersonModel(tools) + await Person.guardedDelete(req) + + return Person.sendResponse(res) } + +/* + * Clone a person + * See: https://freesewing.dev/reference/backend/api + */ +//PersonController.prototype.clone = async (req, res, tools) => { +// const Person = new PersonModel(tools) +// await Person.unsafeUpdate(req) +// +// return Person.sendResponse(res) +//} diff --git a/sites/backend/src/controllers/user.mjs b/sites/backend/src/controllers/user.mjs index 09e55e0c964..e1c20d96f84 100644 --- a/sites/backend/src/controllers/user.mjs +++ b/sites/backend/src/controllers/user.mjs @@ -48,7 +48,7 @@ UserController.prototype.login = async function (req, res, tools) { */ UserController.prototype.whoami = async (req, res, tools) => { const User = new UserModel(tools) - await User.readAsAccount({ id: req.user.uid }) + await User.readForReturn({ id: req.user.uid }) return User.sendResponse(res) } @@ -61,7 +61,7 @@ UserController.prototype.whoami = async (req, res, tools) => { UserController.prototype.update = async (req, res, tools) => { const User = new UserModel(tools) await User.read({ id: req.user.uid }) - await User.unsafeUpdate(req.body) + await User.guardedUpdate(req.body, req.user) return User.sendResponse(res) } diff --git a/sites/backend/src/models/person.mjs b/sites/backend/src/models/person.mjs index 025eff4b738..f2dfe25bc1c 100644 --- a/sites/backend/src/models/person.mjs +++ b/sites/backend/src/models/person.mjs @@ -25,7 +25,7 @@ PersonModel.prototype.create = async function ({ body, user }) { data.imperial = body.imperial === true ? true : false data.userId = user.uid // Set this one initially as we need the ID to create a custom img via Sanity - data.img = this.encrypt(this.config.avatars.person) + data.img = this.config.avatars.person // Create record try { @@ -43,7 +43,7 @@ PersonModel.prototype.create = async function ({ body, user }) { ? await setPersonAvatar(this.record.id, body.img) : false - if (img) await this.safeUpdate(this.cloak({ img: img.url })) + if (img) await this.unguardedUpdate(this.cloak({ img: img.url })) else await this.read({ id: this.record.id }) return this.setResponse(201, 'created', { person: this.asPerson() }) @@ -66,6 +66,27 @@ PersonModel.prototype.read = async function (where) { return this.setExists() } +/* + * Loads a person from the database based on the where clause you pass it + * In addition prepares it for returning the person data + * + * Stores result in this.record + */ +PersonModel.prototype.readForReturn = async function (where, user) { + if (user.level < 1) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') + + await this.read(where) + if (this.record.userId !== user.uid && user.level < 5) { + return this.setResponse(403, 'insufficientAccessLevel') + } + + return this.setResponse(200, false, { + result: 'success', + person: this.asPerson(), + }) +} + /* * Helper method to decrypt at-rest data */ @@ -85,55 +106,14 @@ PersonModel.prototype.reveal = async function () { */ PersonModel.prototype.cloak = function (data) { for (const field of this.encryptedFields) { - if (typeof data[field] !== 'undefined') data[field] = this.encrypt(data[field]) + if (typeof data[field] !== 'undefined') { + data[field] = this.encrypt(data[field]) + } } return data } -/* - * Loads a user from the database based on the where clause you pass it - * In addition prepares it for returning the account data - * - * Stores result in this.record - */ -//UserModel.prototype.readAsAccount = async function (where) { -// await this.read(where) -// -// return this.setResponse(200, false, { -// result: 'success', -// account: this.asAccount(), -// }) -//} - -/* - * Finds a user based on one of the accepted unique fields which are: - * - lusername (lowercase username) - * - ehash - * - id - * - * Stores result in this.record - */ -//UserModel.prototype.find = async function (body) { -// try { -// this.record = await this.prisma.user.findFirst({ -// where: { -// OR: [ -// { lusername: { equals: clean(body.username) } }, -// { ehash: { equals: hash(clean(body.username)) } }, -// { id: { equals: parseInt(body.username) || -1 } }, -// ], -// }, -// }) -// } catch (err) { -// log.warn({ err, body }, `Error while trying to find user: ${body.username}`) -// } -// -// this.reveal() -// -// return this.setExists() -//} - /* * Checks this.record and sets a boolean to indicate whether * the user exists or not @@ -150,7 +130,7 @@ PersonModel.prototype.setExists = function () { * Updates the person data - Used when we create the data ourselves * so we know it's safe */ -PersonModel.prototype.safeUpdate = async function (data) { +PersonModel.prototype.unguardedUpdate = async function (data) { try { this.record = await this.prisma.person.update({ where: { id: this.record.id }, @@ -170,14 +150,14 @@ PersonModel.prototype.safeUpdate = async function (data) { * Updates the person data - Used when we pass through user-provided data * so we can't be certain it's safe */ -PersonModel.prototype.unsafeUpdate = async function ({ params, body, user }) { +PersonModel.prototype.guardedUpdate = async function ({ params, body, user }) { if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') + if (user.iss && user.status < 1) return this.setResponse(403, 'accountStatusLacking') await this.read({ id: parseInt(params.id) }) - if (user.uid !== this.record.userId) return this.setResponse(403, 'accessDenied') + if (this.record.userId !== user.uid && user.level < 8) { + return this.setResponse(403, 'insufficientAccessLevel') + } const data = {} - /* - img String? - */ // Imperial if (body.imperial === true || body.imperial === false) data.imperial = body.imperial // Name @@ -189,11 +169,15 @@ PersonModel.prototype.unsafeUpdate = async function ({ params, body, user }) { // 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) && typeof val === 'number' && val > 0) - measies[key] = val + 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.clear.measies, ...measies } + for (const key of remove) delete data.measies[key] } // Image (img) @@ -203,11 +187,39 @@ PersonModel.prototype.unsafeUpdate = async function ({ params, body, user }) { } // Now update the record - await this.safeUpdate(this.cloak(data)) + await this.unguardedUpdate(this.cloak(data)) return this.setResponse(200, false, { person: this.asPerson() }) } +/* + * Removes the person - No questions asked + */ +PersonModel.prototype.unguardedDelete = async function () { + await this.prisma.person.delete({ here: { id: this.record.id } }) + this.record = null + this.clear = null + + return this.setExists() +} + +/* + * Removes the person - Checks permissions + */ +PersonModel.prototype.guardedDelete = async function ({ params, body, user }) { + if (user.level < 3) 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 */ diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index 5a6db2b4ac1..663976cc7a0 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -67,7 +67,7 @@ UserModel.prototype.cloak = function (data) { * * Stores result in this.record */ -UserModel.prototype.readAsAccount = async function (where) { +UserModel.prototype.readForReturn = async function (where) { await this.read(where) return this.setResponse(200, false, { @@ -175,7 +175,7 @@ UserModel.prototype.create = async function ({ body }) { // Update username try { - await this.safeUpdate({ + await this.unguardedUpdate({ username: `user-${this.record.id}`, lusername: `user-${this.record.id}`, }) @@ -243,7 +243,7 @@ UserModel.prototype.passwordLogin = async function (req) { // Login success if (updatedPasswordField) { // Update the password field with a v3 hash - await this.safeUpdate({ password: updatedPasswordField }) + await this.unguardedUpdate({ password: updatedPasswordField }) } return this.isOk() ? this.loginOk() : this.setResponse(401, 'loginFailed') @@ -283,7 +283,7 @@ UserModel.prototype.confirm = async function ({ body, params }) { if (this.error) return this // Update user status, consent, and last login - await this.safeUpdate({ + await this.unguardedUpdate({ status: 1, consent: body.consent, lastLogin: new Date(), @@ -298,7 +298,7 @@ UserModel.prototype.confirm = async function ({ body, params }) { * Updates the user data - Used when we create the data ourselves * so we know it's safe */ -UserModel.prototype.safeUpdate = async function (data) { +UserModel.prototype.unguardedUpdate = async function (data) { try { this.record = await this.prisma.user.update({ where: { id: this.record.id }, @@ -318,7 +318,7 @@ UserModel.prototype.safeUpdate = async function (data) { * Updates the user data - Used when we pass through user-provided data * so we can't be certain it's safe */ -UserModel.prototype.unsafeUpdate = async function (body) { +UserModel.prototype.guardedUpdate = async function (body, user) { if (user.level < 3) return this.setResponse(403, 'insufficientAccessLevel') const data = {} // Bio @@ -353,7 +353,7 @@ UserModel.prototype.unsafeUpdate = async function (body) { } // Now update the record - await this.safeUpdate(this.cloak(data)) + await this.unguardedUpdate(this.cloak(data)) const isUnitTest = this.isUnitTest(body) if (typeof body.email === 'string' && this.clear.email !== clean(body.email)) { @@ -399,7 +399,7 @@ UserModel.prototype.unsafeUpdate = async function (body) { const data = this.Confirmation.clear.data if (data.email.current === this.clear.email && typeof data.email.new === 'string') { - await this.safeUpdate({ + await this.unguardedUpdate({ email: this.encrypt(data.email.new), ehash: hash(clean(data.email.new)), }) diff --git a/sites/backend/src/routes/person.mjs b/sites/backend/src/routes/person.mjs index 395ca748c18..0abb71cb512 100644 --- a/sites/backend/src/routes/person.mjs +++ b/sites/backend/src/routes/person.mjs @@ -31,6 +31,14 @@ export function personRoutes(tools) { Person.update(req, res, tools) ) + // Clone person + app.put('/people/:id/clone/jwt', passport.authenticate(...jwt), (req, res) => + Person.clone(req, res, tools) + ) + app.put('/people/:id/clone/key', passport.authenticate(...bsc), (req, res) => + Person.clone(req, res, tools) + ) + // Delete person app.delete('/people/:id/jwt', passport.authenticate(...jwt), (req, res) => Person.delete(req, res, tools) diff --git a/sites/backend/src/utils/crypto.mjs b/sites/backend/src/utils/crypto.mjs index 07faae467ef..7dcb3246bea 100644 --- a/sites/backend/src/utils/crypto.mjs +++ b/sites/backend/src/utils/crypto.mjs @@ -85,7 +85,6 @@ export const encryption = (stringKey, salt = 'FreeSewing') => { if (!data.iv || typeof data.ct === 'undefined') { throw 'Encrypted data passed to decrypt() was malformed' } - /* * The thing that does the decrypting */ diff --git a/sites/backend/tests/index.mjs b/sites/backend/tests/index.mjs index 9cef6931d35..44543d586bf 100644 --- a/sites/backend/tests/index.mjs +++ b/sites/backend/tests/index.mjs @@ -5,9 +5,9 @@ import { personTests } from './person.mjs' import { setup } from './shared.mjs' const runTests = async (...params) => { - //await userTests(...params) - //await apikeyTests(...params) - //await accountTests(...params) + await userTests(...params) + await apikeyTests(...params) + await accountTests(...params) await personTests(...params) } diff --git a/sites/backend/tests/person.mjs b/sites/backend/tests/person.mjs index b717ce5d63d..312632056f2 100644 --- a/sites/backend/tests/person.mjs +++ b/sites/backend/tests/person.mjs @@ -30,6 +30,10 @@ export const personTests = async (chai, config, expect, store) => { jwt: {}, key: {}, } + store.altperson = { + jwt: {}, + key: {}, + } for (const auth of ['jwt', 'key']) { describe(`${store.icon('person', auth)} Person tests (${auth})`, () => { @@ -146,13 +150,169 @@ export const personTests = async (chai, config, expect, store) => { }) }) } - }) - // TODO: - // - Add non-existing measurement - // - Clear measurement - // - List/get person - // - Clone person - // - Clone person accross accounts of they are public + it(`${store.icon( + 'person', + auth + )} Should not set an non-existing measurement (${auth})`, (done) => { + chai + .request(config.api) + .put(`/people/${store.person[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.person.measies.ankle).to.equal(320) + expect(typeof res.body.person.measies.potatoe).to.equal('undefined') + done() + }) + }) + + it(`${store.icon('person', auth)} Should clear a measurement (${auth})`, (done) => { + chai + .request(config.api) + .put(`/people/${store.person[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.person.measies.chest).to.equal('undefined') + done() + }) + }) + + it(`${store.icon('person', auth)} Should read a person (${auth})`, (done) => { + chai + .request(config.api) + .get(`/people/${store.person[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.account.token + : 'Basic ' + + new Buffer(`${store.account.apikey.key}:${store.account.apikey.secret}`).toString( + 'base64' + ) + ) + .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.person.measies).to.equal('object') + done() + }) + }) + + it(`${store.icon( + 'person', + auth + )} Should not allow reading another user's person (${auth})`, (done) => { + chai + .request(config.api) + .get(`/people/${store.person[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.altaccount.token + : 'Basic ' + + new Buffer( + `${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}` + ).toString('base64') + ) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(403) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`insufficientAccessLevel`) + done() + }) + }) + + it(`${store.icon( + 'person', + auth + )} Should not allow updating another user's person (${auth})`, (done) => { + chai + .request(config.api) + .put(`/people/${store.person[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.altaccount.token + : 'Basic ' + + new Buffer( + `${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}` + ).toString('base64') + ) + .send({ + bio: 'I have been taken over', + }) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(403) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`insufficientAccessLevel`) + done() + }) + }) + + it(`${store.icon( + 'person', + auth + )} Should not allow removing another user's person (${auth})`, (done) => { + chai + .request(config.api) + .delete(`/people/${store.person[auth].id}/${auth}`) + .set( + 'Authorization', + auth === 'jwt' + ? 'Bearer ' + store.altaccount.token + : 'Basic ' + + new Buffer( + `${store.altaccount.apikey.key}:${store.altaccount.apikey.secret}` + ).toString('base64') + ) + .end((err, res) => { + expect(err === null).to.equal(true) + expect(res.status).to.equal(403) + expect(res.body.result).to.equal(`error`) + expect(res.body.error).to.equal(`insufficientAccessLevel`) + done() + }) + }) + + // TODO: + // - Clone person + // - Clone person accross accounts of they are public + }) } } diff --git a/sites/backend/tests/shared.mjs b/sites/backend/tests/shared.mjs index 21ddb7b3184..463469d381e 100644 --- a/sites/backend/tests/shared.mjs +++ b/sites/backend/tests/shared.mjs @@ -22,6 +22,11 @@ export const setup = async () => { language: 'en', password: randomString(), }, + altaccount: { + email: `test_${randomString()}@${config.tests.domain}`, + language: 'en', + password: randomString(), + }, icons: { user: '🧑 ', jwt: '🎫 ', @@ -32,53 +37,55 @@ export const setup = async () => { } store.icon = (icon1, icon2 = false) => store.icons[icon1] + (icon2 ? store.icons[icon2] : '') - // Get confirmation ID - let result - try { - result = await axios.post(`${store.config.api}/signup`, { - email: store.account.email, - language: store.account.language, - unittest: true, - }) - } catch (err) { - console.log('Failed at first setup request', err) - process.exit() - } - store.account.confirmation = result.data.confirmation + for (const acc of ['account', 'altaccount']) { + // Get confirmation ID + let result + try { + result = await axios.post(`${store.config.api}/signup`, { + email: store[acc].email, + language: store[acc].language, + unittest: true, + }) + } catch (err) { + console.log('Failed at first setup request', err) + process.exit() + } + store[acc].confirmation = result.data.confirmation - // Confirm account - try { - result = await axios.post(`${store.config.api}/confirm/signup/${store.account.confirmation}`, { - consent: 1, - }) - } catch (err) { - console.log('Failed at account confirmation request', err) - process.exit() - } - store.account.token = result.data.token - store.account.username = result.data.account.username - store.account.userid = result.data.account.id + // Confirm account + try { + result = await axios.post(`${store.config.api}/confirm/signup/${store[acc].confirmation}`, { + consent: 1, + }) + } catch (err) { + console.log('Failed at account confirmation request', err) + process.exit() + } + store[acc].token = result.data.token + store[acc].username = result.data.account.username + store[acc].userid = result.data.account.id - // Create API key - try { - result = await axios.post( - `${store.config.api}/apikey/jwt`, - { - name: 'Test API key', - level: 4, - expiresIn: 60, - }, - { - headers: { - authorization: `Bearer ${store.account.token}`, + // Create API key + try { + result = await axios.post( + `${store.config.api}/apikey/jwt`, + { + name: 'Test API key', + level: 4, + expiresIn: 60, }, - } - ) - } catch (err) { - console.log('Failed at API key creation request', err) - process.exit() + { + headers: { + authorization: `Bearer ${store[acc].token}`, + }, + } + ) + } catch (err) { + console.log('Failed at API key creation request', err) + process.exit() + } + store[acc].apikey = result.data.apikey } - store.account.apikey = result.data.apikey return { chai, config, expect, store } }