Update backend services, user right tests and user right documentation (#1217)

* update some backend tests slightly, and update user right docs

* improve locality updating tests

* refactor species merge tests, and add more user rights tests to merging

* fix locality user right issue with users being able to bypass project membership requirements by adding new projects when updating a project, and add tests

* fix small, copilot suggested issues

* change bigint value back to number, and fix hanging test

* add error content checks and  get request test for regions

* add error content checks and reorder tests in time bound/time unit

* add person get tests, update delete tests, and update user right docs

* update museum tests

* update testing docs

* add tests for all user groups in project delete tests

* add more user right tests to project/ and projects/

* Fix pid test

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Rename project get test describe block

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
juhanikat
2026-07-20 15:01:14 +03:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent 4dff86367b
commit fa6dc11fee
23 changed files with 509 additions and 254 deletions
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import { login, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
type CollectingMethodValue = { coll_meth_value: string }
@@ -27,12 +27,20 @@ describe('GET /collecting-method-values/all', () => {
expect(values).toEqual(sorted)
})
it('rejects users without locality edit access', async () => {
await login('testPl', 'test')
it('rejects anonymous users', async () => {
logout()
const { body: noUserBody, status: noUserStatus } = await send('collecting-method-values/all', 'GET')
expect(noUserStatus).toBe(403)
expect(noUserBody).toEqual(noPermError)
})
const { body, status } = await send('collecting-method-values/all', 'GET')
it('accepts users with locality edit access', async () => {
await login('testEr', 'test')
const { status: erStatus } = await send('collecting-method-values/all', 'GET')
expect(erStatus).toBe(200)
expect(status).toBe(403)
expect(body).toEqual(noPermError)
await login('testEu', 'test')
const { status: euStatus } = await send('collecting-method-values/all', 'GET')
expect(euStatus).toBe(200)
})
})
@@ -240,7 +240,7 @@ describe('Getting cross-search data', () => {
})
describe('does not show drafts without proper user rights', () => {
it('Get all with admin has correct amount of data', async () => {
it('Get all with admin has correct data', async () => {
await login()
const response = await send<CrossSearch[]>(`crosssearch/all/999/0/[]/[]`, 'GET')
expect(response.status).toEqual(200)
-16
View File
@@ -218,22 +218,6 @@ export const editedLocality = {
lid: 21050,
loc_name: 'New test name',
now_mus: [],
now_ls: [
{
rowState: 'new',
species_id: 21052,
lid: 21050,
com_species: { species_id: 21052 } as SpeciesDetailsType,
},
{
rowState: 'removed',
species_id: 85730,
lid: 21050,
com_species: { species_id: 85730 } as SpeciesDetailsType,
},
],
projects: [],
now_plr: [],
now_ss: [],
now_coll_meth: [],
now_lau: [],
+71 -7
View File
@@ -9,7 +9,7 @@ import {
invalidPollenUpdateLocality,
newLocalityBasis,
} from './data'
import { login, resetDatabase, send, resetDatabaseTimeout } from '../utils'
import { login, logout, resetDatabase, send, resetDatabaseTimeout, noPermError } from '../utils'
import { pool } from '../../utils/db'
let resultLocality: LocalityDetailsType | null = null
@@ -21,22 +21,38 @@ const buildUpdatePayload = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
describe('Locality update works', () => {
describe('Locality update', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
await resetDatabase()
await login()
resultLocality = null
})
afterAll(async () => {
await pool.end()
})
describe('with a successful locality update applied', () => {
beforeEach(async () => {
const writeResult = await send<{ id: number }>('locality', 'PUT', { locality: buildUpdatePayload() })
describe('works and updates data correctly', () => {
beforeAll(async () => {
await login()
const writeResult = await send<{ id: number }>('locality', 'PUT', {
locality: buildUpdatePayload({
now_ls: [
{
rowState: 'new',
species_id: 21052,
lid: 21050,
com_species: { species_id: 21052 },
},
{
rowState: 'removed',
species_id: 85730,
lid: 21050,
com_species: { species_id: 85730 },
},
],
}),
})
expect(writeResult.status).toEqual(200)
expect(writeResult.body.id).toEqual(editedLocality.lid) // `Invalid result returned on write: ${writeResult.body.id}`
@@ -117,6 +133,18 @@ describe('Locality update works', () => {
})
})
describe('works with edge cases, and fails with invalid values or permissions', () => {
beforeAll(async () => {
await resetDatabase()
await login()
const writeResult = await send<{ id: number }>('locality', 'PUT', {
locality: buildUpdatePayload(),
})
expect(writeResult.status).toEqual(200)
expect(writeResult.body.id).toEqual(editedLocality.lid) // `Invalid result returned on write: ${writeResult.body.id}`
})
it('Clearing an optional text field persists null', async () => {
const writeResult = await send<{ id: number }>('locality', 'PUT', {
locality: buildUpdatePayload({ rock_type: '' }),
@@ -183,4 +211,40 @@ describe('Locality update works', () => {
expect(writeResult.status).toEqual(200)
expect(writeResult.body.id).toEqual(editedLocality.lid) // `Invalid result returned on write: ${writeResult.body.id}
})
it('Editing locality as an editrestricted user that belongs in the same project as the locality succeeds', async () => {
logout()
await login('testEr', 'test')
const erWriteResult = await send<{ id: number }>('locality', 'PUT', { locality: buildUpdatePayload() })
// testEr belongs to the NOW Database project, which is already linked to the locality
expect(erWriteResult.status).toEqual(200)
})
it('Editing locality fails as an anonymous user', async () => {
logout()
const writeResult = await send<{ id: number }>('locality', 'PUT', { locality: buildUpdatePayload() })
expect(writeResult.status).toEqual(403)
})
it('Editing locality fails as an EditRestricted user, when the locality is not in the same project as the user', async () => {
const changeProjectResult = await send<{ id: number }>('locality', 'PUT', {
locality: buildUpdatePayload({ now_plr: [{ pid: 35, rowState: 'new' }] }),
})
expect(changeProjectResult.status).toBe(200)
logout()
await login('testEr', 'test')
const resultEr = await send('locality', 'PUT', {
locality: buildUpdatePayload(),
})
expect(resultEr.body).toEqual(noPermError)
expect(resultEr.status).toEqual(403)
const resultErWithAddedPlr = await send('locality', 'PUT', {
locality: buildUpdatePayload({ now_plr: [{ pid: 3, rowState: 'new' }] }),
})
expect(resultErWithAddedPlr.body).toEqual(noPermError)
expect(resultErWithAddedPlr.status).toEqual(403)
})
})
})
+13 -9
View File
@@ -54,6 +54,7 @@ describe('Creating new museum works', () => {
expect(getReqStatus).toEqual(409)
expect(resultBody.code).toEqual('duplicate_museum_code')
})
it('Creation fails with empty museum code', async () => {
const { body: resultBody, status: getReqStatus } = await send('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: null },
@@ -62,26 +63,29 @@ describe('Creating new museum works', () => {
expect(resultBody.length).toEqual(1) //There should be 1 validation error
})
it('Creation fails without permissions', async () => {
it('Creation succeeds for all logged in users', async () => {
logout()
const { body: resultBodyNoPerm, status: resultStatusNoPerm } = await send('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: 'New Museum 2' },
})
expect(resultBodyNoPerm).toEqual(noPermError)
expect(resultStatusNoPerm).toEqual(403)
await login('testEr')
const { body: resultBodyEr, status: resultStatusEr } = await send<{ museum: string }>('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: 'NM3' },
museum: { ...newMuseumBasis, museum: 'NewMuseum2' },
})
expect(resultStatusEr).toEqual(200)
expect(typeof resultBodyEr.museum).toEqual('string')
await login('testEu')
const { body: resultBodyEu, status: resultStatusEu } = await send<{ museum: string }>('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: 'NM4' },
museum: { ...newMuseumBasis, museum: 'NM3' },
})
expect(resultStatusEu).toEqual(200)
expect(typeof resultBodyEu.museum).toEqual('string')
})
it('Creation fails for anonymous users', async () => {
logout()
const { body: resultBodyNoPerm, status: resultStatusNoPerm } = await send('museum/', 'PUT', {
museum: { ...newMuseumBasis, museum: 'NM4' },
})
expect(resultBodyNoPerm).toEqual(noPermError)
expect(resultStatusNoPerm).toEqual(403)
})
})
+11 -9
View File
@@ -47,14 +47,7 @@ describe('Updating museum works', () => {
expect(resultBody.length).toEqual(1) //There should be 1 validation error
})
it('Updating fails without permissions', async () => {
logout()
const { body: resultBodyNoPerm, status: resultStatusNoPerm } = await send('museum/', 'PUT', {
museum: { ...editedMuseum, institution: 'New Museum 2' },
})
expect(resultBodyNoPerm).toEqual(noPermError)
expect(resultStatusNoPerm).toEqual(403)
it('Updating succeeds for all logged in users', async () => {
await login('testEr')
const { body: resultBodyEr, status: resultStatusEr } = await send<{ museum: string }>('museum/', 'PUT', {
museum: { ...editedMuseum, institution: 'New Museum 2' },
@@ -64,9 +57,18 @@ describe('Updating museum works', () => {
await login('testEu')
const { body: resultBodyEu, status: resultStatusEu } = await send<{ museum: string }>('museum/', 'PUT', {
museum: { ...editedMuseum, institution: 'New Museum 2' },
museum: { ...editedMuseum, institution: 'NM3' },
})
expect(resultStatusEu).toEqual(200)
expect(typeof resultBodyEu.museum).toEqual('string')
})
it('Updating fails for anonymous users', async () => {
logout()
const { body: resultBodyNoPerm, status: resultStatusNoPerm } = await send('museum/', 'PUT', {
museum: { ...editedMuseum, institution: 'NM4' },
})
expect(resultBodyNoPerm).toEqual(noPermError)
expect(resultStatusNoPerm).toEqual(403)
})
})
@@ -94,6 +94,11 @@ describe('Deleting person works', () => {
expect(deleteResultEr.status).toEqual(403)
expect(deleteResultEr.body).toEqual(noPermError)
await login('testEu')
const deleteResultEu = await send('person/DEL-PERM', 'DELETE')
expect(deleteResultEu.status).toEqual(403)
expect(deleteResultEu.body).toEqual(noPermError)
const person = await nowDb.com_people.findUnique({ where: { initials: 'DEL-PERM' } })
expect(person).not.toBeNull()
})
+69
View File
@@ -0,0 +1,69 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { pool, nowDb } from '../../utils/db'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send, unauthenticatedError } from '../utils'
import { existingPerson } from './data'
import { PersonDetails } from '../../../../frontend/src/shared/types'
describe('Getting a persons information works', () => {
beforeAll(async () => {
await resetDatabase()
const existing = await nowDb.com_people.findUnique({ where: { initials: existingPerson.initials } })
expect(existing).not.toBeNull()
}, resetDatabaseTimeout)
beforeEach(async () => {
await login()
})
afterAll(async () => {
await pool.end()
})
it('getting a list of all persons succeeds with admin permissions', async () => {
const getResultAdmin = await send(`person/all`, 'GET')
expect(getResultAdmin.status).toEqual(200)
expect(getResultAdmin.body.length).toEqual(13)
expect((getResultAdmin.body as PersonDetails[])[0].first_name).toEqual(existingPerson.first_name)
})
it('getting person details succeeds with admin permissions', async () => {
const getResultAdmin = await send(`person/${existingPerson.initials}`, 'GET')
expect(getResultAdmin.status).toEqual(200)
expect((getResultAdmin.body as PersonDetails).first_name).toEqual(existingPerson.first_name)
})
it('getting a list of all persons fails without admin permissions', async () => {
logout()
const getResultNoPerm = await send(`person/all`, 'GET')
expect(getResultNoPerm.body).toEqual(noPermError)
expect(getResultNoPerm.status).toEqual(403)
await login('testEr')
const getResultEr = await send(`person/all`, 'GET')
expect(getResultEr.body).toEqual(noPermError)
expect(getResultEr.status).toEqual(403)
await login('testEu')
const getResultEu = await send(`person/all`, 'GET')
expect(getResultEu.body).toEqual(noPermError)
expect(getResultEu.status).toEqual(403)
})
it('getting person details fails without admin permissions', async () => {
logout()
const getResultNoPerm = await send(`person/${existingPerson.initials}`, 'GET')
// this throws a different error since access checking happens differently in person router
expect(getResultNoPerm.body).toEqual(unauthenticatedError)
expect(getResultNoPerm.status).toEqual(401)
await login('testEr')
const getResultEr = await send(`person/${existingPerson.initials}`, 'GET')
expect(getResultEr.body).toEqual(noPermError)
expect(getResultEr.status).toEqual(403)
await login('testEu')
const getResultEu = await send(`person/${existingPerson.initials}`, 'GET')
expect(getResultEu.body).toEqual(noPermError)
expect(getResultEu.status).toEqual(403)
})
})
+23 -3
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
describe('DELETE /project/:id', () => {
@@ -38,8 +38,28 @@ describe('DELETE /project/:id', () => {
})
await login('testPl', 'test')
const deleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
const plDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
expect(plDeleteAttempt.status).toEqual(403)
expect(plDeleteAttempt.body).toEqual(noPermError)
expect(deleteAttempt.status).toEqual(403)
await login('testEu', 'test')
const euDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
expect(euDeleteAttempt.status).toEqual(403)
expect(euDeleteAttempt.body).toEqual(noPermError)
await login('testEr', 'test')
const erDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
expect(erDeleteAttempt.status).toEqual(403)
expect(erDeleteAttempt.body).toEqual(noPermError)
logout()
const anonDeleteAttempt = await send(`project/${createdProject.body.pid}`, 'DELETE')
expect(anonDeleteAttempt.status).toEqual(403)
expect(anonDeleteAttempt.body).toEqual(noPermError)
await login('testSu', 'test')
const fetchAfterAttempts = await send(`project/${createdProject.body.pid}`, 'GET')
expect(fetchAfterAttempts.status).toEqual(200)
expect(fetchAfterAttempts.body).toHaveProperty('pid', createdProject.body.pid)
})
})
+22 -6
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
describe('POST /projects', () => {
@@ -59,14 +59,30 @@ describe('POST /projects', () => {
})
it('denies non-admin users', async () => {
await login('testPl', 'test')
const result = await send('projects', 'POST', {
const unauthorizedProject = {
projectCode: 'PRJ-004',
projectName: 'Unauthorized Create',
coordinatorUserId: 163,
})
}
expect(result.status).toEqual(403)
await login('testPl', 'test')
const plResult = await send('projects', 'POST', unauthorizedProject)
expect(plResult.status).toEqual(403)
expect(plResult.body).toEqual(noPermError)
await login('testEu', 'test')
const euResult = await send('projects', 'POST', unauthorizedProject)
expect(euResult.status).toEqual(403)
expect(euResult.body).toEqual(noPermError)
await login('testEr', 'test')
const erResult = await send('projects', 'POST', unauthorizedProject)
expect(erResult.status).toEqual(403)
expect(erResult.body).toEqual(noPermError)
logout()
const anonResult = await send('projects', 'POST', unauthorizedProject)
expect(anonResult.status).toEqual(403)
expect(anonResult.body).toEqual(noPermError)
})
})
@@ -0,0 +1,39 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
describe('GET /project/:id', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
afterAll(async () => {
await pool.end()
})
it('viewing project details fails for non-admin users', async () => {
await login('testSu', 'test')
const suResult = await send('project/3', 'GET')
expect(suResult.status).toEqual(200)
await login('testPl', 'test')
const plResult = await send('project/3', 'GET')
expect(plResult.status).toEqual(403)
expect(plResult.body).toEqual(noPermError)
await login('testEu', 'test')
const euResult = await send('project/3', 'GET')
expect(euResult.status).toEqual(403)
expect(euResult.body).toEqual(noPermError)
await login('testEr', 'test')
const erResult = await send('project/3', 'GET')
expect(erResult.status).toEqual(403)
expect(erResult.body).toEqual(noPermError)
logout()
const anonResult = await send('project/3', 'GET')
expect(anonResult.status).toEqual(403)
expect(anonResult.body).toEqual(noPermError)
})
})
+20 -11
View File
@@ -1,5 +1,5 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { pool } from '../../utils/db'
describe('PUT /projects/:id', () => {
@@ -9,6 +9,7 @@ describe('PUT /projects/:id', () => {
beforeEach(async () => {
await resetDatabase()
await login('testSu', 'test')
})
afterAll(async () => {
@@ -16,8 +17,6 @@ describe('PUT /projects/:id', () => {
})
it('updates coordinator and members', async () => {
await login('testSu', 'test')
const createResult = await send<{ pid: number }>('projects', 'POST', {
projectCode: 'PRJ-UPDATE',
projectName: 'Project To Update',
@@ -47,8 +46,6 @@ describe('PUT /projects/:id', () => {
})
it('returns validation error for invalid members', async () => {
await login('testSu', 'test')
const createResult = await send<{ pid: number }>('projects', 'POST', {
projectCode: 'PRJ-INV1',
projectName: 'Project Invalid Members',
@@ -64,8 +61,6 @@ describe('PUT /projects/:id', () => {
})
it('returns validation error for too-long project code', async () => {
await login('testSu', 'test')
const createResult = await send<{ pid: number }>('projects', 'POST', {
projectCode: 'PRJ-LONG1',
projectName: 'Project Long Code',
@@ -82,12 +77,26 @@ describe('PUT /projects/:id', () => {
})
it('denies non-admin users', async () => {
const unauthorizedUpdate = { projectName: 'Unauthorized Update' }
await login('testPl', 'test')
const plResult = await send('projects/1', 'PUT', unauthorizedUpdate)
expect(plResult.status).toEqual(403)
expect(plResult.body).toEqual(noPermError)
const result = await send('projects/1', 'PUT', {
projectName: 'Unauthorized Update',
})
await login('testEu', 'test')
const euResult = await send('projects/1', 'PUT', unauthorizedUpdate)
expect(euResult.status).toEqual(403)
expect(euResult.body).toEqual(noPermError)
expect(result.status).toEqual(403)
await login('testEr', 'test')
const erResult = await send('projects/1', 'PUT', unauthorizedUpdate)
expect(erResult.status).toEqual(403)
expect(erResult.body).toEqual(noPermError)
logout()
const anonResult = await send('projects/1', 'PUT', unauthorizedUpdate)
expect(anonResult.status).toEqual(403)
expect(anonResult.body).toEqual(noPermError)
})
})
+4 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, beforeAll, afterAll, describe, it, expect } from '@jest/globals'
import { login, logout, resetDatabase, send, resetDatabaseTimeout } from '../utils'
import { login, logout, resetDatabase, send, resetDatabaseTimeout, noPermError } from '../utils'
import { pool } from '../../utils/db'
describe('Deleting a region works', () => {
@@ -23,14 +23,17 @@ describe('Deleting a region works', () => {
it('Deleting fails without permissions', async () => {
logout()
const deleteResultNoPerm = await send<{ id: number }>('region/2', 'DELETE')
expect(deleteResultNoPerm.body).toEqual(noPermError)
expect(deleteResultNoPerm.status).toEqual(403)
await login('testEr')
const deleteResultEr = await send<{ id: number }>('region/2', 'DELETE')
expect(deleteResultEr.body).toEqual(noPermError)
expect(deleteResultEr.status).toEqual(403)
await login('testEu')
const deleteResultEu = await send<{ id: number }>('region/2', 'DELETE')
expect(deleteResultEu.body).toEqual(noPermError)
expect(deleteResultEu.status).toEqual(403)
await login()
+21 -1
View File
@@ -1,5 +1,5 @@
import { beforeAll, afterAll, describe, it, expect, beforeEach } from '@jest/globals'
import { login, logout, resetDatabase, send, resetDatabaseTimeout } from '../utils'
import { login, logout, resetDatabase, send, resetDatabaseTimeout, noPermError } from '../utils'
import { pool } from '../../utils/db'
describe('Getting region data works', () => {
@@ -18,17 +18,37 @@ describe('Getting region data works', () => {
expect(getResultSu.status).toEqual(200)
})
it('Getting a list of all regions fails without permissions', async () => {
logout()
const getResultNoPerm = await send('region/all', 'GET')
expect(getResultNoPerm.body).toEqual(noPermError)
expect(getResultNoPerm.status).toEqual(403)
await login('testEr')
const getResultEr = await send('region/all', 'GET')
expect(getResultEr.body).toEqual(noPermError)
expect(getResultEr.status).toEqual(403)
await login('testEu')
const getResultEu = await send('region/all', 'GET')
expect(getResultEu.body).toEqual(noPermError)
expect(getResultEu.status).toEqual(403)
})
it('Getting region data fails without permissions', async () => {
logout()
const getResultNoPerm = await send('region/2', 'GET')
expect(getResultNoPerm.body).toEqual(noPermError)
expect(getResultNoPerm.status).toEqual(403)
await login('testEr')
const getResultEr = await send('region/2', 'GET')
expect(getResultEr.body).toEqual(noPermError)
expect(getResultEr.status).toEqual(403)
await login('testEu')
const getResultEu = await send('region/2', 'GET')
expect(getResultEu.body).toEqual(noPermError)
expect(getResultEu.status).toEqual(403)
})
})
+6 -1
View File
@@ -1,5 +1,6 @@
import { beforeAll, describe, expect, it } from '@jest/globals'
import { beforeAll, afterAll, describe, expect, it } from '@jest/globals'
import { login, resetDatabase, resetDatabaseTimeout, send } from './utils'
import { pool } from '../utils/db'
type SequenceResponse = {
rows: Array<{ sequence: string; seq_name: string; display_value: string }>
@@ -14,6 +15,10 @@ describe('Sequence API', () => {
await login()
}, resetDatabaseTimeout)
afterAll(async () => {
await pool.end()
})
it('returns sequences with display labels', async () => {
const { body, status } = await send<SequenceResponse>('time-unit/sequences', 'GET')
+71 -91
View File
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { pool } from '../../utils/db'
import { login, noPermError, resetDatabaseTimeout, send, setToken } from '../utils'
import { login, logout, noPermError, resetDatabase, resetDatabaseTimeout, send } from '../utils'
import { cloneSpeciesData } from './data'
import { OCCURRENCE_MERGE_FIELDS } from '../../services/speciesMerge'
import { SpeciesDetailsType } from '../../../../frontend/src/shared/types'
@@ -14,6 +14,14 @@ type MergeResponse = {
comment: string
}
const buildSpeciesPayload = (overrides: Partial<ReturnType<typeof cloneSpeciesData>> = {}) => {
return {
...cloneSpeciesData(),
now_ls: [],
...overrides,
}
}
const buildOccurrenceChoices = (
lid: number,
overrides?: Partial<Record<(typeof OCCURRENCE_MERGE_FIELDS)[number], 'accepted' | 'obsolete'>>
@@ -37,19 +45,11 @@ const buildOccurrenceChoices = (
describe('Species merge endpoint', () => {
beforeAll(async () => {
const resetResult = await send('test/reset-test-database', 'GET')
expect(resetResult.status).toEqual(200)
const createUsersResult = await send('test/create-test-users', 'GET')
expect(createUsersResult.status).toEqual(200)
const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' })
expect(loginResult.status).toEqual(200)
setToken(loginResult.body.token)
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' })
expect(loginResult.status).toEqual(200)
setToken(loginResult.body.token)
await login()
})
afterAll(async () => {
@@ -59,11 +59,9 @@ describe('Species merge endpoint', () => {
it('merges species, migrates synonyms, and deletes obsolete species', async () => {
const references = cloneSpeciesData().references
const acceptedPayload = {
...cloneSpeciesData(),
const acceptedPayload = buildSpeciesPayload({
genus_name: 'MergeAcceptedGenus',
species_name: 'mergeAccepted',
unique_identifier: 'merge-accepted',
diet1: 'a',
now_ls: [
{
@@ -75,15 +73,11 @@ describe('Species merge endpoint', () => {
},
],
com_taxa_synonym: [],
comment: 'create accepted',
references,
}
})
const obsoletePayload = {
...cloneSpeciesData(),
const obsoletePayload = buildSpeciesPayload({
genus_name: 'MergeObsoleteGenus',
species_name: 'mergeObsolete',
unique_identifier: 'merge-obsolete',
diet1: 'b',
now_ls: [
{
@@ -102,9 +96,7 @@ describe('Species merge endpoint', () => {
syn_comment: 'legacy synonym',
},
],
comment: 'create obsolete',
references,
}
})
const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', { species: acceptedPayload })
const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', { species: obsoletePayload })
@@ -164,29 +156,12 @@ describe('Species merge endpoint', () => {
})
it('requires references', async () => {
const references = cloneSpeciesData().references
const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', {
species: {
...cloneSpeciesData(),
genus_name: 'RefAcceptGenus',
species_name: 'refAccept',
unique_identifier: 'ref-accept',
now_ls: [],
comment: 'create accepted',
references,
},
species: buildSpeciesPayload({ genus_name: 'RefAcceptGenus' }),
})
const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', {
species: {
...cloneSpeciesData(),
genus_name: 'RefObsoleteGenus',
species_name: 'refObsolete',
unique_identifier: 'ref-obsolete',
now_ls: [],
comment: 'create obsolete',
references,
},
species: buildSpeciesPayload({ genus_name: 'RefObsoleteGenus' }),
})
const mergeResult = await send('admin/species-merge', 'POST', {
@@ -208,32 +183,12 @@ describe('Species merge endpoint', () => {
})
it('summary includes non-taxonomic fields and omits taxonomic fields', async () => {
const references = cloneSpeciesData().references
const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', {
species: {
...cloneSpeciesData(),
genus_name: 'SummaryAcceptGenus',
species_name: 'summaryAccept',
unique_identifier: 'summary-accept',
diet_description: 'accept diet description',
now_ls: [],
comment: 'create accepted',
references,
},
species: buildSpeciesPayload({ genus_name: 'SummaryAcceptGenus' }),
})
const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', {
species: {
...cloneSpeciesData(),
genus_name: 'SummaryObsoleteGenus',
species_name: 'summaryObsolete',
unique_identifier: 'summary-obsolete',
diet_description: 'obsolete diet description',
now_ls: [],
comment: 'create obsolete',
references,
},
species: buildSpeciesPayload({ genus_name: 'SummaryObsoleteGenus' }),
})
const summaryResult = await send<{
@@ -256,29 +211,13 @@ describe('Species merge endpoint', () => {
const references = cloneSpeciesData().references
const acceptedCreate = await send<{ species_id: number }>('species', 'PUT', {
species: {
...cloneSpeciesData(),
genus_name: 'BoolAcceptGenus',
species_name: 'boolAccept',
unique_identifier: 'bool-accept',
body_mass: 10,
now_ls: [],
comment: 'create accepted',
references,
},
//@ts-expect-error body_mass is expected to be a BigInt
species: buildSpeciesPayload({ genus_name: 'BoolAcceptGenus', body_mass: 10 }),
})
const obsoleteCreate = await send<{ species_id: number }>('species', 'PUT', {
species: {
...cloneSpeciesData(),
genus_name: 'BoolObsoleteGenus',
species_name: 'boolObsolete',
unique_identifier: 'bool-obsolete',
body_mass: 99,
now_ls: [],
comment: 'create obsolete',
references,
},
//@ts-expect-error body_mass is expected to be a BigInt
species: buildSpeciesPayload({ genus_name: 'BoolObsoleteGenus', body_mass: 99 }),
})
const acceptedId = acceptedCreate.body.species_id
@@ -307,19 +246,60 @@ describe('Species merge endpoint', () => {
})
it('rejects non-admin users', async () => {
await login('testEr')
const result = await send('admin/species-merge', 'POST', {
obsoleteSpeciesId: 1,
acceptedSpeciesId: 2,
const acceptedCreateResult = await send<{ species_id: number }>('species', 'PUT', {
species: buildSpeciesPayload({ genus_name: 'UserRightTestAcceptedGenus' }),
})
expect(acceptedCreateResult.status).toEqual(200)
const obsoleteCreateResult = await send<{ species_id: number }>('species', 'PUT', {
species: buildSpeciesPayload({ genus_name: 'UserRightTestObsoleteGenus' }),
})
expect(obsoleteCreateResult.status).toEqual(200)
const acceptedId = acceptedCreateResult.body.species_id
const obsoleteId = obsoleteCreateResult.body.species_id
const references = cloneSpeciesData().references
logout()
const anonymousUserResult = await send('admin/species-merge', 'POST', {
obsoleteSpeciesId: obsoleteId,
acceptedSpeciesId: acceptedId,
selectedSpeciesFieldValues: {},
occurrenceFieldChoices: [],
addObsoleteAsSynonym: false,
addSourceNameToOccurrences: false,
comment: 'merge species',
references: [],
references,
})
expect(anonymousUserResult.body).toEqual(noPermError)
expect(anonymousUserResult.status).toEqual(403)
expect(result.status).toEqual(403)
expect(result.body).toEqual(noPermError)
await login('testEr')
const erResult = await send('admin/species-merge', 'POST', {
obsoleteSpeciesId: obsoleteId,
acceptedSpeciesId: acceptedId,
selectedSpeciesFieldValues: {},
occurrenceFieldChoices: [],
addObsoleteAsSynonym: false,
addSourceNameToOccurrences: false,
comment: 'merge species',
references,
})
expect(erResult.status).toEqual(403)
expect(erResult.body).toEqual(noPermError)
await login('testEu')
const euResult = await send('admin/species-merge', 'POST', {
obsoleteSpeciesId: obsoleteId,
acceptedSpeciesId: acceptedId,
selectedSpeciesFieldValues: {},
occurrenceFieldChoices: [],
addObsoleteAsSynonym: false,
addSourceNameToOccurrences: false,
comment: 'merge species',
references,
})
expect(euResult.status).toEqual(403)
expect(euResult.body).toEqual(noPermError)
})
})
+16 -16
View File
@@ -51,6 +51,22 @@ describe('Creating new time bound works', () => {
expect(resultWithRef.status).toEqual(200)
})
it('Update logs are correct', () => {
const lastUpdate = createdTimeBound!.now_bau[createdTimeBound!.now_bau.length - 1]
expect(lastUpdate.bau_comment).toEqual(newTimeBoundBasis.comment) // 'Comment is correct'
const logRows = lastUpdate.updates
const expectedLogRows: Partial<LogRow>[] = [
{
table: 'now_tu_bound',
column: 'b_name',
oldValue: null,
value: newTimeBoundBasis.b_name!,
type: 'add',
},
]
testLogRows(logRows, expectedLogRows, 3)
})
it('Creation fails without permissions', async () => {
logout()
const resultNoPerm = await send('time-bound', 'PUT', {
@@ -67,20 +83,4 @@ describe('Creating new time bound works', () => {
expect(resultEr.body).toEqual(noPermError)
expect(resultEr.status).toEqual(403)
})
it('Update logs are correct', () => {
const lastUpdate = createdTimeBound!.now_bau[createdTimeBound!.now_bau.length - 1]
expect(lastUpdate.bau_comment).toEqual(newTimeBoundBasis.comment) // 'Comment is correct'
const logRows = lastUpdate.updates
const expectedLogRows: Partial<LogRow>[] = [
{
table: 'now_tu_bound',
column: 'b_name',
oldValue: null,
value: newTimeBoundBasis.b_name!,
type: 'add',
},
]
testLogRows(logRows, expectedLogRows, 3)
})
})
@@ -1,5 +1,5 @@
import { beforeAll, beforeEach, afterAll, describe, it, expect } from '@jest/globals'
import { login, logout, send, resetDatabase, resetDatabaseTimeout } from '../utils'
import { login, logout, send, resetDatabase, resetDatabaseTimeout, noPermError } from '../utils'
import { pool } from '../../utils/db'
import { newTimeBoundBasis } from './data'
@@ -37,10 +37,12 @@ describe('Deleting a time bound', () => {
it('without permissions fails', async () => {
logout()
const deleteResultNoPerm = await send<{ id: number }>('time-bound/11', 'DELETE')
expect(deleteResultNoPerm.body).toEqual(noPermError)
expect(deleteResultNoPerm.status).toEqual(403)
await login('testEr')
const deleteResultEr = await send<{ id: number }>('time-bound/11', 'DELETE')
expect(deleteResultEr.body).toEqual(noPermError)
expect(deleteResultEr.status).toEqual(403)
})
})
+17 -17
View File
@@ -128,23 +128,6 @@ describe('Creating new time unit', () => {
})
})
it('Creation fails without permissions', async () => {
logout()
const resultNoPerm = await send('time-unit', 'PUT', {
timeUnit: { ...newTimeUnitBasis },
})
expect(resultNoPerm.body).toEqual(noPermError)
expect(resultNoPerm.status).toEqual(403)
logout()
await login('testEr', 'test')
const resultEr = await send('time-unit', 'PUT', {
timeUnit: { ...newTimeUnitBasis },
})
expect(resultEr.body).toEqual(noPermError)
expect(resultEr.status).toEqual(403)
})
it('Creation fails without reference', async () => {
const resultNoRef = await send('time-unit', 'PUT', {
timeUnit: { ...newTimeUnitBasis, tu_display_name: 'Bahean Test 2', references: [] },
@@ -234,5 +217,22 @@ describe('Creating new time unit', () => {
expect(getReqStatus).not.toEqual(500)
expect(hasStructuredErrorPayload(resultBody)).toBe(true)
})
it('Creation fails without permissions', async () => {
logout()
const resultNoPerm = await send('time-unit', 'PUT', {
timeUnit: { ...newTimeUnitBasis },
})
expect(resultNoPerm.body).toEqual(noPermError)
expect(resultNoPerm.status).toEqual(403)
logout()
await login('testEr', 'test')
const resultEr = await send('time-unit', 'PUT', {
timeUnit: { ...newTimeUnitBasis },
})
expect(resultEr.body).toEqual(noPermError)
expect(resultEr.status).toEqual(403)
})
})
})
@@ -1,5 +1,5 @@
import { beforeAll, beforeEach, afterAll, describe, it, expect } from '@jest/globals'
import { login, logout, send, resetDatabase, resetDatabaseTimeout } from '../utils'
import { login, logout, send, resetDatabase, resetDatabaseTimeout, noPermError } from '../utils'
import { pool } from '../../utils/db'
import { newTimeUnitBasis } from './data'
import { TIME_UNIT_IN_USE_MESSAGE } from '../../services/write/timeUnit'
@@ -41,12 +41,14 @@ describe('Deleting a time unit', () => {
it('without permissions fails', async () => {
logout()
const deleteResultNoPerm = await send<{ id: number }>('time-unit/bahean', 'DELETE')
expect(deleteResultNoPerm.body).toEqual(noPermError)
expect(deleteResultNoPerm.status).toEqual(403)
const getResultNoPerm = await send('time-unit/bahean', 'GET')
expect(getResultNoPerm.status).toEqual(200)
await login('testEr')
const deleteResultEr = await send<{ id: number }>('time-unit/bahean', 'DELETE')
expect(deleteResultEr.body).toEqual(noPermError)
expect(deleteResultEr.status).toEqual(403)
const getResultEr = await send('time-unit/bahean', 'GET')
expect(getResultEr.status).toEqual(200)
+9 -3
View File
@@ -75,13 +75,19 @@ const getActiveProjectIds = (locality: EditDataType<LocalityDetailsType>) => {
export const canEditRestrictedWriteLocality = async (locality: EditDataType<LocalityDetailsType>, user: User) => {
const userProjectIds = await getIdsOfUsersProjects(user)
let activeProjectIds = getActiveProjectIds(locality)
if (activeProjectIds.length === 0 && locality.lid) {
const existingLocality = await getLocalityDetails(locality.lid, user)
let activeProjectIds: number[] = []
if (locality.lid) {
// this ensures that a user without edit rights cannot simply add the needed projects to the locality while editing it
const existingLocality = await nowDb.now_loc.findUnique({
where: { lid: locality.lid },
select: { now_plr: { select: { pid: true } } },
})
if (existingLocality) {
activeProjectIds = existingLocality.now_plr.map(link => link.pid)
}
} else {
activeProjectIds = getActiveProjectIds(locality)
}
if (activeProjectIds.length === 0) return false
+5
View File
@@ -74,3 +74,8 @@ Your feedback is essential to improving the user experience—thank you for part
- The Cypress env variable `databaseResetUrl` points to `http://localhost:4000/test/reset-test-database` by default. Hitting this endpoint (for example with `curl http://localhost:4000/test/reset-test-database`) resets seeded data before running the suite.
- When running inside Docker, the provided scripts already pass through the defaults; override with `CYPRESS_databaseResetUrl` if the API host/port differs.
## Jest API Tests
See [commands.md](devops/commands.md) for commands to run API tests.
NOTE: When writing API tests, make sure to test that the error messages of failed requests are correct, along with the status code. Both invalid user rights and incorrect requests (invalid or missing values when creating entities etc.) result in error code 403, which might be misleading if only the code is checked.
+14 -2
View File
@@ -3,6 +3,7 @@
This document shows what each user role can and cannot do.
Previously, there were 7 different user roles:
- Admin (su)
- EditUnrestricted (eu)
- EditRestricted (er)
@@ -12,6 +13,7 @@ Previously, there were 7 different user roles:
- ReadOnly (ro)
In the new UI platform, there are 4 user roles:
- Admin (su)
- EditUnrestricted (eu)
- EditRestricted (er, pl, plp, no)
@@ -42,6 +44,15 @@ Users with the **EditUnrestricted** role are not limited by project membership w
- Deleting a species synonym requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles.
- Merging species requires the **Admin** role (via `/admin/species-merge`).
## Occurrence (Cross-Search)
- Any user can get a list of occurrences.
- Any user can view the details of an occurrence.
- Creating or updating an occurrence requires the **Admin**, **EditUnrestricted**, or **EditRestricted** roles.
- Deleting an occurrence is not possible.
NOTE: Users with the **EditRestricted** role can only update occurrences that relate to a locality created by that user. Otherwise, they have reading rights only.
## References
- Any user can get a list of References.
@@ -74,7 +85,9 @@ Users with the **EditUnrestricted** role are not limited by project membership w
## Person
- Viewing a list of every person requires the **Admin** role.
- Any user can get their information using their ID, but cannot view other people's information (unless they have the **Admin** role)
- Any user can get their own information using their ID, but cannot view other people's information (unless they have the **Admin** role)
- Any user can update their own information, but cannot update anyone else's (unless they have the **Admin** role)
- Deleting a Person requires the **Admin** role.
## Project
@@ -99,7 +112,6 @@ Users with the **EditUnrestricted** role are not limited by project membership w
## User Rights Table
| User group | Locality | Species | References | Time Units | Time Bounds | Regions | Persons | Projects | Museums | Sedimentary Structures | Sending Email |
| ---------------- | -------- | ------- | ---------- | ---------- | ----------- | ------- | ------- | -------- | ------- | ---------------------- | ------------- |
| Admin | ALL | ALL | ALL | ALL | ALL | ALL | ALL | ALL | ALL | ALL | C |