diff --git a/.adonisjs/server/pages.d.ts b/.adonisjs/server/pages.d.ts index d2c4aa1..69d550b 100644 --- a/.adonisjs/server/pages.d.ts +++ b/.adonisjs/server/pages.d.ts @@ -34,9 +34,9 @@ declare module '@adonisjs/inertia/types' { 'widgets/ComputersViewer': ExtractProps<(typeof import('../../inertia/pages/widgets/ComputersViewer.vue'))['default']> 'widgets/CustomSelect': ExtractProps<(typeof import('../../inertia/pages/widgets/CustomSelect.vue'))['default']> 'widgets/DeleteConfirmationModal': ExtractProps<(typeof import('../../inertia/pages/widgets/DeleteConfirmationModal.vue'))['default']> + 'widgets/FilterDropdown': ExtractProps<(typeof import('../../inertia/pages/widgets/FilterDropdown.vue'))['default']> 'widgets/SimpleGallery': ExtractProps<(typeof import('../../inertia/pages/widgets/SimpleGallery.vue'))['default']> 'widgets/TableComponent': ExtractProps<(typeof import('../../inertia/pages/widgets/TableComponent.vue'))['default']> 'widgets/ValidationError': ExtractProps<(typeof import('../../inertia/pages/widgets/ValidationError.vue'))['default']> - 'widgets/FilterDropdown': ExtractProps<(typeof import('../../inertia/pages/widgets/FilterDropdown.vue'))['default']> } } diff --git a/package-lock.json b/package-lock.json index 5598c04..3abaf8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "@adonisjs/eslint-config": "^3.1.0", "@adonisjs/prettier-config": "^1.5.0", "@adonisjs/tsconfig": "^2.0.0", + "@japa/api-client": "^3.2.1", "@japa/assert": "^4.2.0", "@japa/browser-client": "^2.3.0", "@japa/plugin-adonisjs": "^5.2.0", @@ -1864,6 +1865,45 @@ "vue": "^3.0.0" } }, + "node_modules/@japa/api-client": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@japa/api-client/-/api-client-3.2.1.tgz", + "integrity": "sha512-dICbeEkgGRpjkm3CviOpvPfYMBZddaoW2w20pgNMm3CfvD2bixWOFn6FBRZRq5L+fHYe/O/xfSNGMCQBFy7Zlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/hooks": "^7.3.0", + "@poppinss/macroable": "^1.1.0", + "@poppinss/qs": "^6.15.0", + "@types/superagent": "^8.1.9", + "cookie-es": "^2.0.0", + "set-cookie-parser": "^2.7.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=18.16.0" + }, + "peerDependencies": { + "@japa/assert": "^2.0.0 || ^3.0.0 || ^4.0.0", + "@japa/openapi-assertions": "^0.1.1", + "@japa/runner": "^3.1.2 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@japa/assert": { + "optional": true + }, + "@japa/openapi-assertions": { + "optional": true + } + } + }, + "node_modules/@japa/api-client/node_modules/cookie-es": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.1.tgz", + "integrity": "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==", + "dev": true, + "license": "MIT" + }, "node_modules/@japa/assert": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@japa/assert/-/assert-4.2.0.tgz", @@ -2058,6 +2098,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2096,6 +2149,16 @@ "node": ">= 8" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -3101,6 +3164,13 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3157,6 +3227,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.9.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", @@ -3180,6 +3257,19 @@ "integrity": "sha512-JOqsl+ZoCpP4e8TDke9W79FDcSgPAR0l6pixx2JHkhnRjvShyYiAYw2LVsnA7K08Y6DeOnaU6ujmENO4os/cYg==", "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.11", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.11.tgz", + "integrity": "sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -3853,6 +3943,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4528,6 +4625,16 @@ "dev": true, "license": "ISC" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -4560,6 +4667,13 @@ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -4809,6 +4923,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -5797,6 +5922,24 @@ "node": ">= 0.6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -7019,6 +7162,16 @@ "node": ">= 8" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -7044,6 +7197,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -8628,6 +8794,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9127,6 +9300,27 @@ "node": ">= 6" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", diff --git a/package.json b/package.json index aabf556..1affb86 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@adonisjs/eslint-config": "^3.1.0", "@adonisjs/prettier-config": "^1.5.0", "@adonisjs/tsconfig": "^2.0.0", + "@japa/api-client": "^3.2.1", "@japa/assert": "^4.2.0", "@japa/browser-client": "^2.3.0", "@japa/plugin-adonisjs": "^5.2.0", diff --git a/tests/bootstrap.ts b/tests/bootstrap.ts index 2484bbb..22664ac 100644 --- a/tests/bootstrap.ts +++ b/tests/bootstrap.ts @@ -1,9 +1,13 @@ import { assert } from '@japa/assert' +import { apiClient } from '@japa/api-client' import app from '@adonisjs/core/services/app' import type { Config } from '@japa/runner/types' import { pluginAdonisJS } from '@japa/plugin-adonisjs' import { dbAssertions } from '@adonisjs/lucid/plugins/db' import testUtils from '@adonisjs/core/services/test_utils' +import { authApiClient } from '@adonisjs/auth/plugins/api_client' +import { sessionApiClient } from '@adonisjs/session/plugins/api_client' +import { shieldApiClient } from '@adonisjs/shield/plugins/api_client' /** * This file is imported by the "bin/test.ts" entrypoint file @@ -13,7 +17,15 @@ import testUtils from '@adonisjs/core/services/test_utils' * Configure Japa plugins in the plugins array. * Learn more - https://japa.dev/docs/runner-config#plugins-optional */ -export const plugins: Config['plugins'] = [assert(), pluginAdonisJS(app), dbAssertions(app)] +export const plugins: Config['plugins'] = [ + assert(), + apiClient(), + sessionApiClient(app), + authApiClient(app), + shieldApiClient(), + pluginAdonisJS(app), + dbAssertions(app), +] /** * Configure lifecycle function to run before and after all the @@ -33,6 +45,9 @@ export const runnerHooks: Required> = { */ export const configureSuite: Config['configureSuite'] = (suite) => { if (['browser', 'functional', 'e2e'].includes(suite.name)) { - return suite.setup(() => testUtils.httpServer().start()) + suite.setup(() => testUtils.httpServer().start()) + suite.onGroup((group) => { + group.each.setup(() => testUtils.db().withGlobalTransaction()) + }) } } diff --git a/tests/functional/components.spec.ts b/tests/functional/components.spec.ts new file mode 100644 index 0000000..f0c9808 --- /dev/null +++ b/tests/functional/components.spec.ts @@ -0,0 +1,104 @@ +import { test } from '@japa/runner' +import { DateTime } from 'luxon' +import User from '#models/user' +import Component from '#models/component' +import ComponentType from '#models/component_type' + +async function createUser(email: string) { + return User.create({ + name: 'Test User', + email, + password: 'secret@123A', + }) +} + +async function createType(name: string, unique = true) { + return ComponentType.firstOrCreate({ name }, { name, unique }) +} + +async function createComponent(userId: number, typeId: number) { + return Component.create({ + name: 'Test Component', + price: 20, + typeId, + userId, + computerId: null, + purchaseDate: DateTime.now(), + }) +} + +test.group('Components authorization (IDOR)', () => { + test('owner can update their own component', async ({ client, assert }) => { + const owner = await createUser('owner-comp-update@example.com') + const type = await createType('RAM-comp-1') + const component = await createComponent(owner.id, type.id) + + const response = await client + .put(`/components/${component.id}`) + .loginAs(owner) + .withCsrfToken() + .redirects(0) + .json({ + name: 'Updated name', + price: 30, + typeId: type.id, + purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'), + }) + + response.assertStatus(302) + await component.refresh() + assert.equal(component.name, 'Updated name') + }) + + test('a user cannot update another user component', async ({ client, assert }) => { + const owner = await createUser('owner-comp-a@example.com') + const attacker = await createUser('attacker-comp-a@example.com') + const type = await createType('RAM-comp-2') + const component = await createComponent(owner.id, type.id) + + const response = await client + .put(`/components/${component.id}`) + .loginAs(attacker) + .withCsrfToken() + .redirects(0) + .json({ + name: 'Hacked name', + price: 1, + typeId: type.id, + purchaseDate: DateTime.now().toFormat('yyyy-MM-dd'), + }) + + response.assertStatus(404) + await component.refresh() + assert.notEqual(component.name, 'Hacked name') + }) + + test('a user cannot delete another user component', async ({ client, assert }) => { + const owner = await createUser('owner-comp-b@example.com') + const attacker = await createUser('attacker-comp-b@example.com') + const type = await createType('RAM-comp-3') + const component = await createComponent(owner.id, type.id) + + const response = await client + .delete(`/components/${component.id}`) + .loginAs(attacker) + .withCsrfToken() + .redirects(0) + + response.assertStatus(404) + const stillExists = await Component.find(component.id) + assert.isNotNull(stillExists) + }) + + test('an unauthenticated request cannot delete a component', async ({ client, assert }) => { + const owner = await createUser('owner-comp-c@example.com') + const type = await createType('RAM-comp-4') + const component = await createComponent(owner.id, type.id) + + const response = await client.delete(`/components/${component.id}`) + + response.assertRedirectsTo('/auth/login') + const stillExists = await Component.find(component.id) + assert.isNotNull(stillExists) + }) +}) diff --git a/tests/functional/computers.spec.ts b/tests/functional/computers.spec.ts new file mode 100644 index 0000000..3b01884 --- /dev/null +++ b/tests/functional/computers.spec.ts @@ -0,0 +1,198 @@ +import { test } from '@japa/runner' +import { DateTime } from 'luxon' +import User from '#models/user' +import Computer from '#models/computer' +import ComputerState from '#models/computer_state' +import Component from '#models/component' +import ComponentType from '#models/component_type' + +async function createUser(email: string) { + return User.create({ + name: 'Test User', + email, + password: 'secret@123A', + }) +} + +async function createState(name = 'ON_GOING') { + return ComputerState.firstOrCreate({ name }, { name }) +} + +async function createType(name: string, unique = true) { + return ComponentType.firstOrCreate({ name }, { name, unique }) +} + +async function createComponent(userId: number, typeId: number, computerId: number | null = null) { + return Component.create({ + name: 'Test Component', + price: 42.5, + typeId, + userId, + computerId, + purchaseDate: DateTime.now(), + }) +} + +async function createComputer(userId: number, stateId: number) { + return Computer.create({ + title: 'My PC', + sellPrice: 100, + gpuScore: 1, + cpuScore: 1, + globalScore: 1, + stateId, + userId, + }) +} + +test.group('Computers authorization (IDOR)', () => { + test('owner can update their own computer', async ({ client, assert }) => { + const owner = await createUser('owner-update@example.com') + const state = await createState() + const computer = await createComputer(owner.id, state.id) + + const response = await client + .put(`/computers/${computer.id}`) + .loginAs(owner) + .withCsrfToken() + .redirects(0) + .json({ + title: 'Updated title', + sellPrice: 150, + gpuScore: 2, + cpuScore: 2, + globalScore: 2, + stateId: state.id, + componentsId: [], + pictures: [], + }) + + response.assertStatus(302) + await computer.refresh() + assert.equal(computer.title, 'Updated title') + }) + + test('a user cannot update another user computer', async ({ client }) => { + const owner = await createUser('owner-a@example.com') + const attacker = await createUser('attacker-a@example.com') + const state = await createState() + const computer = await createComputer(owner.id, state.id) + + const response = await client + .put(`/computers/${computer.id}`) + .loginAs(attacker) + .withCsrfToken() + .redirects(0) + .json({ + title: 'Hacked title', + sellPrice: 1, + gpuScore: 1, + cpuScore: 1, + globalScore: 1, + stateId: state.id, + componentsId: [], + pictures: [], + }) + + response.assertStatus(404) + }) + + test('a user cannot delete another user computer', async ({ client, assert }) => { + const owner = await createUser('owner-b@example.com') + const attacker = await createUser('attacker-b@example.com') + const state = await createState() + const computer = await createComputer(owner.id, state.id) + + const response = await client + .delete(`/computers/${computer.id}`) + .loginAs(attacker) + .withCsrfToken() + .redirects(0) + + response.assertStatus(404) + const stillExists = await Computer.find(computer.id) + assert.isNotNull(stillExists) + }) + + test('a user cannot attach another user component via componentsId', async ({ + client, + assert, + }) => { + const owner = await createUser('owner-c@example.com') + const attacker = await createUser('attacker-c@example.com') + const state = await createState() + const type = await createType('Processor-IDOR', true) + const victimComponent = await createComponent(owner.id, type.id) + const attackerComputer = await createComputer(attacker.id, state.id) + + const response = await client + .put(`/computers/${attackerComputer.id}`) + .loginAs(attacker) + .withCsrfToken() + .redirects(0) + .json({ + title: attackerComputer.title, + sellPrice: attackerComputer.sellPrice, + gpuScore: 0, + cpuScore: 0, + globalScore: 0, + stateId: state.id, + componentsId: [victimComponent.id], + pictures: [], + }) + + // handleComponents scopes the lookup to the current user, so the + // victim's component cannot be found/attached -> 404. + response.assertStatus(404) + await victimComponent.refresh() + assert.isNull(victimComponent.computerId) + }) + + test('cannot attach two components of the same unique type to a computer', async ({ client }) => { + const owner = await createUser('owner-d@example.com') + const state = await createState() + const type = await createType('Motherboard-IDOR', true) + const componentA = await createComponent(owner.id, type.id) + const componentB = await createComponent(owner.id, type.id) + const computer = await createComputer(owner.id, state.id) + + const response = await client + .put(`/computers/${computer.id}`) + .loginAs(owner) + .withCsrfToken() + .redirects(0) + .json({ + title: computer.title, + sellPrice: computer.sellPrice, + gpuScore: 0, + cpuScore: 0, + globalScore: 0, + stateId: state.id, + componentsId: [componentA.id, componentB.id], + pictures: [], + }) + + response.assertStatus(422) + }) + + test('deleting a computer detaches its components instead of destroying them', async ({ + client, + assert, + }) => { + const owner = await createUser('owner-e@example.com') + const state = await createState() + const type = await createType('Fan-IDOR', false) + const computer = await createComputer(owner.id, state.id) + const component = await createComponent(owner.id, type.id, computer.id) + + const response = await client + .delete(`/computers/${computer.id}`) + .loginAs(owner) + .withCsrfToken() + .redirects(0) + + response.assertStatus(302) + await component.refresh() + assert.isNull(component.computerId) + }) +})