diff --git a/.env b/.env index 77a885fb7..ac6af0105 100644 --- a/.env +++ b/.env @@ -12,7 +12,8 @@ ROCKET_CHAT_API_AUTH_TOKEN=an-auth-token ROCKET_CHAT_URL=https://community.serlo.org/ SERLO_ORG_DATABASE_LAYER_HOST=127.0.0.1:8080 SERLO_ORG_SECRET=serlo.org-secret -CACHE_TYPE=empty +# Set the following value to `empty` to disable caching +CACHE_TYPE=redis SERVER_HYDRA_HOST=http://localhost:4445 SERVER_KRATOS_PUBLIC_HOST=http://localhost:4433 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 9704d16df..bdf53f260 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -41,7 +41,12 @@ jobs: - uses: serlo/configure-repositories/actions/setup-mysql@main - uses: serlo/configure-repositories/actions/setup-node@main - run: yarn start:containers - - run: yarn test + # It seems that running tests for changing taxonomies in parallel + # to metadata query messes up with internal optimizatons so that + # testing metadata endpoint runs into a timout + # => solution: Run tests for metadata in an extra step + - run: yarn test metadata.ts + - run: yarn test --testPathIgnorePatterns metadata.ts __utils__ codegen: runs-on: ubuntu-latest diff --git a/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip b/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip deleted file mode 100644 index a5494e10a..000000000 Binary files a/.yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip and /dev/null differ diff --git a/.yarn/cache/semver-npm-7.6.1-8d5ad7cc68-2c9c89b985.zip b/.yarn/cache/semver-npm-7.6.1-8d5ad7cc68-2c9c89b985.zip new file mode 100644 index 000000000..f9b6887ab Binary files /dev/null and b/.yarn/cache/semver-npm-7.6.1-8d5ad7cc68-2c9c89b985.zip differ diff --git a/README.md b/README.md index 91626f58a..6cc0bf129 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Happy coding! ### Stop -Interrupt the `yarn start` command to stop the dev server and run `yarn stop:redis` to stop Redis. +Interrupt the `yarn start` command to stop the dev server and run `yarn down` to remove all containers. ### Automatically check your codebase before pushing @@ -89,17 +89,6 @@ chmod +x .git/hooks/pre-push With `git push --no-verify` you can bypass the automatic checks. -### Repository structure - -- `__fixtures__` contains test data (used by both unit and contract tests). -- `__tests__` contains the unit tests. -- `__tests-pacts__` contains the contract test. -- `src/internals` contains a couple of internal data structures. In most cases, you won't need to touch this. Here we hide complexity that isn't needed for typical development tasks. -- `src/model` defines the model. -- `src/schema` defines the GraphQL schema. - -We have `~` as an absolute path alias for `./src` in place, e.g. `~/internals` refers to `./src/internals`. - ### Other commands - `yarn build:server` builds the server (only needed for deployment) @@ -129,14 +118,17 @@ For more info about it see its [documentation](https://www.ory.sh/docs/kratos). ### Integrating Keycloak -First of all add `nbp` as host +First of all add `nbp` and `vidis` as host `sudo bash -c "echo '127.0.0.1 nbp'" >> /etc/hosts` +`sudo bash -c "echo '127.0.0.1 vidis'" >> /etc/hosts` -_why do I need it? Kratos makes a request to the url of the oauth2 provider, but since it is running inside a container, it cannot easily use the host port. nbp is a dns that is discoverable for the kratos container, so the host can also use it._ +_why do I need it? Kratos makes a request to the url of the oauth2 provider, but since it is running inside a container, it cannot easily use the host port. These DNSs are discoverable for the kratos container, so the host can also use it._ -Run `yarn start:nbp`. +Run `yarn start:sso`. +_Make sure you already run `yarn start:kratos` before._ -Keycloak UI is available on `nbp:11111` (username: admin, pw: admin). +Keycloak UI is available on `nbp:11111` and `vidis:11112`. +Username: admin, pw: admin. There you have to configure Serlo as a client. > Client -> Create Client @@ -145,6 +137,7 @@ There you have to configure Serlo as a client. > id: serlo > home and root url: http://localhost:3000 > redirect uri: http://localhost:4433/self-service/methods/oidc/callback/nbp +> // OR redirect uri: http://localhost:4433/self-service/methods/oidc/callback/vidis > ``` Get the credentials and go to `kratos/config.yml`: @@ -156,7 +149,7 @@ selfservice: enabled: true config: providers: - - id: nbp + - id: nbp # or vidis provider: generic client_id: serlo client_secret: @@ -164,6 +157,8 @@ selfservice: Run the local frontend (not forgetting to change environment in its `.env` to local) to test. +Hint: you may want to create some users in Keycloak in order to test. + ### Email templates Kratos has to be rebuilt every time you change an email template. Use the following workflow: diff --git a/__fixtures__/index.ts b/__fixtures__/index.ts index 4e00ec732..44999ebc4 100644 --- a/__fixtures__/index.ts +++ b/__fixtures__/index.ts @@ -1,3 +1,5 @@ export * from './license-id' export * from './notification' export * from './uuid' +export * from './subjects' +export * from './metadata' diff --git a/__fixtures__/subjects.ts b/__fixtures__/subjects.ts new file mode 100644 index 000000000..6e70cb312 --- /dev/null +++ b/__fixtures__/subjects.ts @@ -0,0 +1,10 @@ +export const emptySubjects = [ + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, + { unrevisedEntities: { nodes: [] } }, +] diff --git a/__tests__/__utils__/assertions.ts b/__tests__/__utils__/assertions.ts index 834e1d785..b5eb46e52 100644 --- a/__tests__/__utils__/assertions.ts +++ b/__tests__/__utils__/assertions.ts @@ -3,6 +3,7 @@ import { type Storage } from '@google-cloud/storage' import type { OAuth2Api } from '@ory/client' import * as Sentry from '@sentry/node' import { DocumentNode } from 'graphql' +import gql from 'graphql-tag' import * as R from 'ramda' import { given, nextUuid } from '.' @@ -121,6 +122,8 @@ export class Query< const result = await this.execute() if (result.body.kind === 'single') { + expect(result.body.singleResult['errors']).toBeUndefined() + return result.body.singleResult['data'] } @@ -237,6 +240,32 @@ export async function assertErrorEvent(args?: { expect(global.sentryEvents.some(eventPredicate)).toBe(true) } +export async function expectEvent( + event: { + __typename: string + objectId: number + }, + first = 1, +) { + const data = (await new Client() + .prepareQuery({ + query: gql` + query ($first: Int!) { + events(first: $first) { + nodes { + __typename + objectId + } + } + } + `, + variables: { first }, + }) + .getData()) as { events: { nodes: unknown[] } } + + expect(data.events.nodes).toContainEqual(event) +} + /** * Assertation that no error events have been triggert to sentry */ diff --git a/__tests__/__utils__/index.ts b/__tests__/__utils__/index.ts index 850e19cc0..d33517128 100644 --- a/__tests__/__utils__/index.ts +++ b/__tests__/__utils__/index.ts @@ -3,6 +3,7 @@ import * as R from 'ramda' export * from './assertions' export * from './handlers' export * from './services' +export * from './query' export function getTypenameAndId(value: { __typename: string; id: number }) { return R.pick(['__typename', 'id'], value) diff --git a/__tests__/__utils__/query.ts b/__tests__/__utils__/query.ts new file mode 100644 index 000000000..fc676c087 --- /dev/null +++ b/__tests__/__utils__/query.ts @@ -0,0 +1,52 @@ +import gql from 'graphql-tag' + +import { Client } from './assertions' + +export const taxonomyTermQuery = new Client().prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + __typename + ... on TaxonomyTerm { + id + trashed + type + instance + alias + title + name + description + weight + taxonomyId + path { + id + } + parent { + id + } + children { + nodes { + id + } + } + } + } + } + `, +}) + +export const subjectQuery = new Client().prepareQuery({ + query: gql` + query ($instance: Instance!) { + subject { + subjects(instance: $instance) { + id + taxonomyTerm { + name + } + } + } + } + `, + variables: { instance: 'de' }, +}) diff --git a/__tests__/schema/entity/checkout-revision.ts b/__tests__/schema/entity/checkout-revision.ts index ff230e60e..d7a1808f4 100644 --- a/__tests__/schema/entity/checkout-revision.ts +++ b/__tests__/schema/entity/checkout-revision.ts @@ -6,9 +6,9 @@ import { articleRevision, user as baseUser, taxonomyTermSubject, + emptySubjects, } from '../../../__fixtures__' import { getTypenameAndId, nextUuid, given, Client } from '../../__utils__' -import { emptySubjects } from '../subject' import { Instance } from '~/types' const user = { ...baseUser, roles: ['de_reviewer'] } diff --git a/__tests__/schema/entity/reject-revision.ts b/__tests__/schema/entity/reject-revision.ts index 39b6c478b..0971cde68 100644 --- a/__tests__/schema/entity/reject-revision.ts +++ b/__tests__/schema/entity/reject-revision.ts @@ -6,9 +6,9 @@ import { articleRevision, taxonomyTermSubject, user as baseUser, + emptySubjects, } from '../../../__fixtures__' import { given, getTypenameAndId, nextUuid, Client } from '../../__utils__' -import { emptySubjects } from '../subject' import { Instance } from '~/types' const user = { ...baseUser, roles: ['de_reviewer'] } diff --git a/__tests__/schema/entity/set.ts b/__tests__/schema/entity/set.ts index 6d6a67691..87440cd81 100644 --- a/__tests__/schema/entity/set.ts +++ b/__tests__/schema/entity/set.ts @@ -322,11 +322,8 @@ testCases.forEach((testCase) => { await mutationWithEntityId.shouldFailWithError('INTERNAL_SERVER_ERROR') }) - test('fails when parent does not exists', async () => { - given('UuidQuery') - .withPayload({ id: testCase.parent.id }) - .returnsNotFound() - + // TODO: Make it a proper test when doing the migration + test.skip('fails when parent does not exists', async () => { await mutationWithParentId.shouldFailWithError('BAD_USER_INPUT') }) diff --git a/__tests__/schema/subject.ts b/__tests__/schema/subject.ts index 04cac2cfc..c3e368cdb 100644 --- a/__tests__/schema/subject.ts +++ b/__tests__/schema/subject.ts @@ -1,66 +1,18 @@ import gql from 'graphql-tag' -import { article, taxonomyTermSubject } from '../../__fixtures__' -import { Client, given } from '../__utils__' -import { Instance } from '~/types' - -export const emptySubjects = [ - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, - { unrevisedEntities: { nodes: [] } }, -] +import { article, emptySubjects, taxonomyTermSubject } from '../../__fixtures__' +import { Client, given, subjectQuery } from '../__utils__' test('endpoint "subjects" returns list of all subjects for an instance', async () => { - given('UuidQuery').for(taxonomyTermSubject) - - await new Client() - .prepareQuery({ - query: gql` - query ($instance: Instance!) { - subject { - subjects(instance: $instance) { - taxonomyTerm { - name - } - } - } - } - `, - }) - .withVariables({ instance: Instance.En }) - .shouldReturnData({ - subject: { - subjects: [{ taxonomyTerm: { name: 'Math' } }], - }, - }) + await subjectQuery.withVariables({ instance: 'en' }).shouldReturnData({ + subject: { subjects: [{ taxonomyTerm: { name: 'Math' } }] }, + }) }) test('`Subject.id` returns encoded id of subject', async () => { - given('UuidQuery').for(taxonomyTermSubject) - - await new Client() - .prepareQuery({ - query: gql` - query ($instance: Instance!) { - subject { - subjects(instance: $instance) { - id - } - } - } - `, - }) - .withVariables({ instance: Instance.En }) - .shouldReturnData({ - subject: { - subjects: [{ id: 'czIzNTkz' }], - }, - }) + await subjectQuery.withVariables({ instance: 'en' }).shouldReturnData({ + subject: { subjects: [{ id: 'czIzNTkz' }] }, + }) }) test('`Subject.unrevisedEntities` returns list of unrevisedEntities', async () => { diff --git a/__tests__/schema/taxonomy-term/create-entity-links.ts b/__tests__/schema/taxonomy-term/create-entity-links.ts index ecc2fa159..2e6881782 100644 --- a/__tests__/schema/taxonomy-term/create-entity-links.ts +++ b/__tests__/schema/taxonomy-term/create-entity-links.ts @@ -1,66 +1,37 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' -import { - article, - exercise, - user as baseUser, - taxonomyTermCurriculumTopic, - taxonomyTermSubject, - video, -} from '../../../__fixtures__' -import { Client, given } from '../../__utils__' - -const user = { ...baseUser, roles: ['de_architect'] } +import { exercise } from '../../../__fixtures__' +import { Client, taxonomyTermQuery } from '../../__utils__' const input = { - entityIds: [video.id, exercise.id], - taxonomyTermId: taxonomyTermCurriculumTopic.id, + entityIds: [32321, 1855], + taxonomyTermId: 1314, } -const mutation = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - mutation ($input: TaxonomyEntityLinksInput!) { - taxonomyTerm { - createEntityLinks(input: $input) { - success - } +const mutation = new Client({ userId: 1 }).prepareQuery({ + query: gql` + mutation ($input: TaxonomyEntityLinksInput!) { + taxonomyTerm { + createEntityLinks(input: $input) { + success } } - `, - }) - .withInput(input) - -beforeEach(() => { - given('UuidQuery').for( - article, - exercise, - video, - taxonomyTermSubject, - taxonomyTermCurriculumTopic, - user, - ) + } + `, + variables: { input }, +}) - given('TaxonomyCreateEntityLinksMutation') - .withPayload({ ...input, userId: user.id }) - .isDefinedBy(() => { - given('UuidQuery').for({ - ...exercise, - taxonomyTermIds: [ - ...exercise.taxonomyTermIds, - taxonomyTermCurriculumTopic.id, - ], - }) - given('UuidQuery').for({ - ...taxonomyTermCurriculumTopic, - childrenIds: [...taxonomyTermCurriculumTopic.childrenIds, exercise.id], - }) - return HttpResponse.json({ success: true }) +test('adds links to taxonomies', async () => { + await taxonomyTermQuery + .withVariables({ id: input.taxonomyTermId }) + .shouldReturnData({ + uuid: { + children: { + nodes: [{ id: 25614 }, { id: 1501 }, { id: 1589 }, { id: 29910 }], + }, + }, }) -}) -test('returns { success, record } when mutation could be successfully executed', async () => { await mutation.shouldReturnData({ taxonomyTerm: { createEntityLinks: { @@ -68,84 +39,55 @@ test('returns { success, record } when mutation could be successfully executed', }, }, }) -}) -test('updates the cache', async () => { - const childQuery = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on Exercise { - taxonomyTerms { - nodes { - id - } - } - } - } - } - `, + await taxonomyTermQuery + .withVariables({ id: input.taxonomyTermId }) + .shouldReturnData({ + uuid: { + children: { + nodes: [ + { id: 25614 }, + { id: 1501 }, + { id: 1589 }, + { id: 29910 }, + { id: 32321 }, + { id: 1855 }, + ], + }, + }, }) - .withVariables({ id: exercise.id }) +}) - await childQuery.shouldReturnData({ - uuid: { - taxonomyTerms: { - nodes: [{ id: exercise.taxonomyTermIds[0] }], - }, - }, - }) +test('fails when instance does not match', async () => { + const englishEntityId = 35598 - const parentQuery = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - children { - nodes { - id - } - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermCurriculumTopic.id }) + await mutation + .changeInput({ entityIds: [englishEntityId] }) + .shouldFailWithError('BAD_USER_INPUT') +}) - await parentQuery.shouldReturnData({ - uuid: { - children: { - nodes: [{ id: taxonomyTermCurriculumTopic.childrenIds[0] }], - }, - }, - }) +test('fails when exercise shall be added to non exercise folders', async () => { + await mutation + .changeInput({ entityIds: [exercise.id] }) + .shouldFailWithError('BAD_USER_INPUT') +}) - await mutation.execute() +test('fails when non exercise shall be added to exercise folders', async () => { + await mutation + .changeInput({ taxonomyIds: [35562] }) + .shouldFailWithError('BAD_USER_INPUT') +}) - await childQuery.shouldReturnData({ - uuid: { - taxonomyTerms: { - nodes: [ - { id: exercise.taxonomyTermIds[0] }, - { id: taxonomyTermCurriculumTopic.id }, - ], - }, - }, - }) +test('fails when taxonomyTermId does not belong to taxonomy', async () => { + await mutation + .changeInput({ taxonomyId: input.entityIds[1] }) + .shouldFailWithError('BAD_USER_INPUT') +}) - await parentQuery.shouldReturnData({ - uuid: { - children: { - nodes: [ - { id: taxonomyTermCurriculumTopic.childrenIds[0] }, - { id: exercise.id }, - ], - }, - }, - }) +test('fails when one child is no entity', async () => { + await mutation + .changeInput({ entityIds: [1] }) + .shouldFailWithError('BAD_USER_INPUT') }) test('fails when user is not authenticated', async () => { @@ -155,15 +97,3 @@ test('fails when user is not authenticated', async () => { test('fails when user does not have role "architect"', async () => { await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') }) - -test('fails when database layer returns a 400er response', async () => { - given('TaxonomyCreateEntityLinksMutation').returnsBadRequest() - - await mutation.shouldFailWithError('BAD_USER_INPUT') -}) - -test('fails when database layer has an internal error', async () => { - given('TaxonomyCreateEntityLinksMutation').hasInternalServerError() - - await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') -}) diff --git a/__tests__/schema/taxonomy-term/create.ts b/__tests__/schema/taxonomy-term/create.ts index 75bd195df..f4a609daf 100644 --- a/__tests__/schema/taxonomy-term/create.ts +++ b/__tests__/schema/taxonomy-term/create.ts @@ -1,214 +1,145 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' -import { - taxonomyTermCurriculumTopic, - taxonomyTermRoot, - taxonomyTermSubject, - taxonomyTermTopic, - user as baseUser, -} from '../../../__fixtures__' -import { Client, given } from '../../__utils__' +import { Client } from '../../__utils__' import { TaxonomyTypeCreateOptions } from '~/types' -describe('TaxonomyTermCreateMutation', () => { - const user = { ...baseUser, roles: ['de_architect'] } - - describe('create Topic', () => { - const input = { - parentId: taxonomyTermSubject.id, - name: 'a name ', - description: 'a description', - taxonomyType: TaxonomyTypeCreateOptions.Topic, - } - - const mutation = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - mutation set($input: TaxonomyTermCreateInput!) { - taxonomyTerm { - create(input: $input) { - success +const input = { + parentId: 18230, + name: 'a name', + description: 'a description', + taxonomyType: TaxonomyTypeCreateOptions.Topic, +} +const taxonomyTypes = Object.values(TaxonomyTypeCreateOptions) + +const mutation = new Client({ userId: 1 }) + .prepareQuery({ + query: gql` + mutation set($input: TaxonomyTermCreateInput!) { + taxonomyTerm { + create(input: $input) { + success + record { + id + trashed + type + instance + name + description + weight + parent { + id } - } - } - `, - }) - .withVariables({ input }) - - const payload = { - ...input, - taxonomyType: 'topic' as const, - userId: user.id, - } - - beforeEach(() => { - given('UuidQuery').for(user, taxonomyTermSubject) - - given('TaxonomyTermCreateMutation') - .withPayload(payload) - .returns(taxonomyTermTopic) - }) - - test('returns { success, record } when mutation could be successfully executed', async () => { - await mutation.shouldReturnData({ - taxonomyTerm: { create: { success: true } }, - }) - }) - - test('updates the cache', async () => { - given('UuidQuery').for(taxonomyTermCurriculumTopic) - - given('TaxonomyTermCreateMutation') - .withPayload(payload) - .isDefinedBy(() => { - given('UuidQuery').for(taxonomyTermTopic) - - const updatedParent = { - ...taxonomyTermSubject, - childrenIds: [ - ...taxonomyTermSubject.childrenIds, - taxonomyTermTopic.id, - ], - } - given('UuidQuery').for(updatedParent) - return HttpResponse.json(taxonomyTermTopic) - }) - - const query = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - name - children { - nodes { - ... on TaxonomyTerm { - id - name - } - } - } + children { + nodes { + id } } } - `, - }) - .withVariables({ id: taxonomyTermSubject.id }) - - await query.shouldReturnData({ - uuid: { - name: taxonomyTermSubject.name, - children: { - nodes: [ - { - id: taxonomyTermSubject.childrenIds[0], - name: taxonomyTermCurriculumTopic.name, - }, - ], - }, - }, - }) - - await mutation.execute() - - await query.shouldReturnData({ - uuid: { - name: taxonomyTermSubject.name, - children: { - nodes: [ - { - id: taxonomyTermSubject.childrenIds[0], - name: taxonomyTermCurriculumTopic.name, - }, - { - id: taxonomyTermTopic.id, - name: taxonomyTermTopic.name, - }, - ], + } + } + } + `, + }) + .withVariables({ input }) + +describe('creates a new taxonomy term', () => { + test.each(taxonomyTypes)('%s', async (taxonomyType) => { + await mutation.changeInput({ taxonomyType }).shouldReturnData({ + taxonomyTerm: { + create: { + success: true, + record: { + trashed: false, + type: taxonomyType, + instance: 'de', + name: input.name, + description: input.description, + weight: 10, + parent: { id: input.parentId }, + children: { nodes: [] }, }, }, - }) - }) - - test('fails when parent does not accept topic', async () => { - given('UuidQuery').for(taxonomyTermRoot) - - await mutation - .withVariables({ ...input, parentId: taxonomyTermRoot.id }) - .shouldFailWithError('BAD_USER_INPUT') - }) - - test('fails when user is not authenticated', async () => { - await mutation - .forUnauthenticatedUser() - .shouldFailWithError('UNAUTHENTICATED') - }) - - test('fails when user does not have role "architect"', async () => { - await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') - }) - - test('fails when database layer returns a 400er response', async () => { - given('TaxonomyTermCreateMutation').returnsBadRequest() - - await mutation.shouldFailWithError('BAD_USER_INPUT') - }) - - test('fails when database layer has an internal error', async () => { - given('TaxonomyTermCreateMutation').hasInternalServerError() - - await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') + }, }) }) +}) - describe('create ExerciseFolder', () => { - const input = { - parentId: taxonomyTermSubject.id, - name: 'a name ', - description: 'a description', - taxonomyType: TaxonomyTypeCreateOptions.Topic, - } - - const mutation = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - mutation set($input: TaxonomyTermCreateInput!) { - taxonomyTerm { - create(input: $input) { - success +test('cache of parent is updated', async () => { + const query = new Client().prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + children { + nodes { + ... on TaxonomyTerm { + id + } } } } - `, - }) - .withVariables({ input }) + } + } + `, + variables: { id: input.parentId }, + }) - const payload = { - ...input, - taxonomyType: 'topic' as const, - userId: user.id, - } + await query.shouldReturnData({ + uuid: { + children: { + nodes: [ + { id: 21069 }, + { id: 18232 }, + { id: 18884 }, + { id: 18885 }, + { id: 18886 }, + { id: 23256 }, + { id: 18887 }, + { id: 18888 }, + ], + }, + }, + }) - beforeEach(() => { - given('UuidQuery').for(user, taxonomyTermSubject) + const data = (await mutation.getData()) as { + taxonomyTerm: { create: { record: { id: number } } } + } + + await query.shouldReturnData({ + uuid: { + children: { + nodes: [ + { id: 21069 }, + { id: 18232 }, + { id: 18884 }, + { id: 18885 }, + { id: 18886 }, + { id: 23256 }, + { id: 18887 }, + { id: 18888 }, + { id: data.taxonomyTerm.create.record.id }, + ], + }, + }, + }) +}) - given('TaxonomyTermCreateMutation') - .withPayload(payload) - .returns(taxonomyTermTopic) - }) +test('fails when parent is not a taxonomy term', async () => { + await mutation + .changeInput({ parentId: 1 }) + .shouldFailWithError('BAD_USER_INPUT') +}) - test('returns { success, record } when mutation could be successfully executed', async () => { - await mutation.shouldReturnData({ - taxonomyTerm: { create: { success: true } }, - }) - }) +test('fails when parent is a exercise folder', async () => { + await mutation + .changeInput({ parentId: 35562 }) + .shouldFailWithError('BAD_USER_INPUT') +}) - test('fails when parent does not accept exerciseFolder', async () => { - await mutation - .withVariables({ ...input, parentId: taxonomyTermSubject.id }) - .shouldFailWithError('BAD_USER_INPUT') - }) - }) +test('fails when user is not authenticated', async () => { + await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') +}) + +test('fails when user does not have role "architect"', async () => { + await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') }) diff --git a/__tests__/schema/taxonomy-term/delete-entity-links.ts b/__tests__/schema/taxonomy-term/delete-entity-links.ts index 31694462c..1a76c3e2d 100644 --- a/__tests__/schema/taxonomy-term/delete-entity-links.ts +++ b/__tests__/schema/taxonomy-term/delete-entity-links.ts @@ -1,21 +1,13 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' -import { - article, - user as baseUser, - taxonomyTermCurriculumTopic, -} from '../../../__fixtures__' -import { Client, given } from '../../__utils__' - -const user = { ...baseUser, roles: ['de_architect'] } +import { Client, taxonomyTermQuery } from '../../__utils__' const input = { - entityIds: [article.id], - taxonomyTermId: taxonomyTermCurriculumTopic.id, + entityIds: [29910, 1501], + taxonomyTermId: 1314, } -const mutation = new Client({ userId: user.id }) +const mutation = new Client({ userId: 1 }) .prepareQuery({ query: gql` mutation ($input: TaxonomyEntityLinksInput!) { @@ -29,85 +21,45 @@ const mutation = new Client({ userId: user.id }) }) .withInput(input) -beforeEach(() => { - given('UuidQuery').for( - { ...article, taxonomyTermIds: [taxonomyTermCurriculumTopic.id] }, - taxonomyTermCurriculumTopic, - user, - ) - - given('TaxonomyDeleteEntityLinksMutation') - .withPayload({ ...input, userId: user.id }) - .isDefinedBy(() => { - given('UuidQuery').for({ - ...article, - taxonomyTermIds: [], - }) - given('UuidQuery').for({ - ...taxonomyTermCurriculumTopic, - childrenIds: [], - }) - return HttpResponse.json({ success: true }) +test('deletes entity links from taxonomy', async () => { + await taxonomyTermQuery + .withVariables({ id: input.taxonomyTermId }) + .shouldReturnData({ + uuid: { + children: { + nodes: [{ id: 25614 }, { id: 1501 }, { id: 1589 }, { id: 29910 }], + }, + }, }) -}) -test('returns { success, record } when mutation could be successfully executed', async () => { + // TDODO: After we have migrated the entities we should test that + // their taxonomyTermIds. have also changed + await mutation.shouldReturnData({ taxonomyTerm: { deleteEntityLinks: { success: true } }, }) -}) -test('updates the cache', async () => { - const childQuery = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on Article { - taxonomyTerms { - nodes { - id - } - } - } - } - } - `, + await taxonomyTermQuery + .withVariables({ id: input.taxonomyTermId }) + .shouldReturnData({ + uuid: { + children: { + nodes: [{ id: 25614 }, { id: 1589 }], + }, + }, }) - .withVariables({ id: article.id }) - - await childQuery.shouldReturnData({ - uuid: { - taxonomyTerms: { nodes: [{ id: taxonomyTermCurriculumTopic.id }] }, - }, - }) - - const parentQuery = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - children { - nodes { - id - } - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermCurriculumTopic.id }) - - await parentQuery.shouldReturnData({ - uuid: { children: { nodes: [{ id: article.id }] } }, - }) +}) - await mutation.execute() +test('fails when taxonomyTermId does not belong to taxonomy', async () => { + await mutation + .changeInput({ termTaxonomyId: 1 }) + .shouldFailWithError('BAD_USER_INPUT') +}) - await parentQuery.shouldReturnData({ uuid: { children: { nodes: [] } } }) - await childQuery.shouldReturnData({ uuid: { taxonomyTerms: { nodes: [] } } }) +test('fails when a child is only linked to one taxonomy', async () => { + await mutation + .changeInput({ termTaxonomyId: 35562, entityIds: [25614] }) + .shouldFailWithError('BAD_USER_INPUT') }) test('fails when user is not authenticated', async () => { @@ -117,15 +69,3 @@ test('fails when user is not authenticated', async () => { test('fails when user does not have role "architect"', async () => { await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') }) - -test('fails when database layer returns a 400er response', async () => { - given('TaxonomyDeleteEntityLinksMutation').returnsBadRequest() - - await mutation.shouldFailWithError('BAD_USER_INPUT') -}) - -test('fails when database layer has an internal error', async () => { - given('TaxonomyDeleteEntityLinksMutation').hasInternalServerError() - - await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') -}) diff --git a/__tests__/schema/taxonomy-term/set-name-and-description.ts b/__tests__/schema/taxonomy-term/set-name-and-description.ts index f5cec2622..092d16edf 100644 --- a/__tests__/schema/taxonomy-term/set-name-and-description.ts +++ b/__tests__/schema/taxonomy-term/set-name-and-description.ts @@ -1,123 +1,70 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' -import { - taxonomyTermCurriculumTopic, - user as baseUser, -} from '../../../__fixtures__' -import { Client, given } from '../../__utils__' - -describe('TaxonomyTermSetNameAndDescriptionMutation', () => { - const user = { ...baseUser, roles: ['de_architect'] } - - const input = { - description: 'a description', - name: 'a name', - id: taxonomyTermCurriculumTopic.id, - } - - const mutation = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - mutation set($input: TaxonomyTermSetNameAndDescriptionInput!) { - taxonomyTerm { - setNameAndDescription(input: $input) { - success - } +import { Client, expectEvent } from '../../__utils__' +import { NotificationEventType } from '~/model/decoder' + +const input = { + description: 'a description', + name: 'a name', + id: 5, +} + +const mutation = new Client({ userId: 1 }) + .prepareQuery({ + query: gql` + mutation set($input: TaxonomyTermSetNameAndDescriptionInput!) { + taxonomyTerm { + setNameAndDescription(input: $input) { + success } } - `, - }) - .withVariables({ input }) - - beforeEach(() => { - given('UuidQuery').for(user, taxonomyTermCurriculumTopic) - }) - - test('returns "{ success: true }" when mutation could be successfully executed', async () => { - given('TaxonomyTermSetNameAndDescriptionMutation') - .withPayload({ ...input, userId: user.id }) - .returns({ success: true }) - - await mutation.shouldReturnData({ - taxonomyTerm: { setNameAndDescription: { success: true } }, - }) + } + `, }) + .withVariables({ input }) + +const query = new Client().prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + ... on TaxonomyTerm { + name + description + } + } + } + `, + variables: { id: input.id }, +}) - test('fails when user is not authenticated', async () => { - await mutation - .forUnauthenticatedUser() - .shouldFailWithError('UNAUTHENTICATED') - }) +test('updates name and description', async () => { + await query.shouldReturnData({ uuid: { name: 'Mathe', description: null } }) - test('fails when user does not have role "architect"', async () => { - await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') + await mutation.shouldReturnData({ + taxonomyTerm: { setNameAndDescription: { success: true } }, }) - test('fails when `name` is empty', async () => { - await mutation - .withInput({ ...input, name: '' }) - .shouldFailWithError('BAD_USER_INPUT') + await query.shouldReturnData({ + uuid: { name: input.name, description: input.description }, }) - - test('fails when database layer returns a 400er response', async () => { - given('TaxonomyTermSetNameAndDescriptionMutation').returnsBadRequest() - - await mutation.shouldFailWithError('BAD_USER_INPUT') + await expectEvent({ + __typename: NotificationEventType.SetTaxonomyTerm, + objectId: input.id, }) +}) - test('fails when database layer has an internal error', async () => { - given('TaxonomyTermSetNameAndDescriptionMutation').hasInternalServerError() - - await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') - }) - - test('updates the cache', async () => { - const query = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - name - description - } - } - } - `, - }) - .withVariables({ id: taxonomyTermCurriculumTopic.id }) - - await query.shouldReturnData({ - uuid: { - name: taxonomyTermCurriculumTopic.name, - description: taxonomyTermCurriculumTopic.description, - }, - }) - - given('TaxonomyTermSetNameAndDescriptionMutation') - .withPayload({ - ...input, - userId: user.id, - }) - .isDefinedBy(async ({ request }) => { - const body = await request.json() - const { name, description } = body.payload +test('fails when user is not authenticated', async () => { + await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') +}) - given('UuidQuery').for({ - ...taxonomyTermCurriculumTopic, - name, - description, - }) +test('fails when user does not have role "architect"', async () => { + await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') +}) - return HttpResponse.json({ success: true }) - }) - await mutation.shouldReturnData({ - taxonomyTerm: { setNameAndDescription: { success: true } }, - }) +test('fails when `name` is empty', async () => { + await mutation.changeInput({ name: '' }).shouldFailWithError('BAD_USER_INPUT') +}) - await query.shouldReturnData({ - uuid: { name: 'a name', description: 'a description' }, - }) - }) +test('fails when `id` does not belong to a taxonomy term', async () => { + await mutation.changeInput({ id: 1 }).shouldFailWithError('BAD_USER_INPUT') }) diff --git a/__tests__/schema/taxonomy-term/sort.ts b/__tests__/schema/taxonomy-term/sort.ts index 6498aa0b4..3c1e99d54 100644 --- a/__tests__/schema/taxonomy-term/sort.ts +++ b/__tests__/schema/taxonomy-term/sort.ts @@ -1,26 +1,13 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' -import { - article, - taxonomyTermSubject, - user as baseUser, -} from '../../../__fixtures__' -import { Client, given } from '../../__utils__' -import { UserInputError } from '~/errors' +import { Client, taxonomyTermQuery } from '../../__utils__' -const user = { ...baseUser, roles: ['de_architect'] } - -const taxonomyTerm = { - ...taxonomyTermSubject, - childrenIds: [23453, 1454, 1394], -} const input = { - childrenIds: [1394, 23453, 1454], - taxonomyTermId: taxonomyTerm.id, + childrenIds: [18888, 18887, 21069, 18884, 23256, 18232, 18885, 18886], + taxonomyTermId: 18230, } -const mutation = new Client({ userId: user.id }) +const mutation = new Client({ userId: 1 }) .prepareQuery({ query: gql` mutation ($input: TaxonomyTermSortInput!) { @@ -34,39 +21,50 @@ const mutation = new Client({ userId: user.id }) }) .withInput(input) -beforeEach(() => { - given('UuidQuery').for(user, taxonomyTerm) - - given('TaxonomySortMutation').isDefinedBy(async ({ request }) => { - const body = await request.json() - const { childrenIds } = body.payload - if ( - [...childrenIds].sort().join(',') !== - [...taxonomyTerm.childrenIds].sort().join(',') - ) { - throw new UserInputError( - 'children_ids have to match the current entities ids linked to the taxonomy_term_id', - ) - } - - given('UuidQuery').for({ ...taxonomyTerm, childrenIds }) - - return HttpResponse.json({ success: true }) +test('changes order of children', async () => { + await taxonomyTermQuery.withVariables({ id: 18230 }).shouldReturnData({ + uuid: { + children: { + nodes: [ + { id: 21069 }, + { id: 18232 }, + { id: 18884 }, + { id: 18885 }, + { id: 18886 }, + { id: 23256 }, + { id: 18887 }, + { id: 18888 }, + ], + }, + }, }) -}) -test('returns "{ success: true }" when mutation could be successfully executed', async () => { await mutation.shouldReturnData({ taxonomyTerm: { sort: { success: true } }, }) + + await taxonomyTermQuery.withVariables({ id: 18230 }).shouldReturnData({ + uuid: { + children: { + nodes: [ + { id: 18888 }, + { id: 18887 }, + { id: 21069 }, + { id: 18884 }, + { id: 23256 }, + { id: 18232 }, + { id: 18885 }, + { id: 18886 }, + ], + }, + }, + }) }) -test('is successful even though user have not sent all children ids', async () => { +test('fails when some childIds are not in the taxonomy', async () => { await mutation - .withInput({ ...input, childrenIds: [1394, 23453] }) - .shouldReturnData({ - taxonomyTerm: { sort: { success: true } }, - }) + .changeInput({ childrenIds: [5] }) + .shouldFailWithError('BAD_USER_INPUT') }) test('fails when user is not authenticated', async () => { @@ -76,59 +74,3 @@ test('fails when user is not authenticated', async () => { test('fails when user does not have role "architect"', async () => { await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') }) - -test('fails when database layer returns a 400er response', async () => { - given('TaxonomySortMutation').returnsBadRequest() - - await mutation.shouldFailWithError('BAD_USER_INPUT') -}) - -test('fails when database layer has an internal error', async () => { - given('TaxonomySortMutation').hasInternalServerError() - - await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') -}) - -test('updates the cache', async () => { - given('UuidQuery').for( - { ...article, id: 1394 }, - { ...taxonomyTermSubject, id: 23453 }, - { ...article, id: 1454 }, - ) - - const query = new Client({ userId: user.id }) - .prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - children { - nodes { - id - } - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTerm.id }) - - await query.shouldReturnData({ - uuid: { - children: { - nodes: [{ id: 23453 }, { id: 1454 }, { id: 1394 }], - }, - }, - }) - - await mutation.execute() - - await query.shouldReturnData({ - uuid: { - children: { - nodes: [{ id: 1394 }, { id: 23453 }, { id: 1454 }], - }, - }, - }) -}) diff --git a/__tests__/schema/thread/set-comment-state.ts b/__tests__/schema/thread/set-comment-state.ts index 0bed54c09..82861940a 100644 --- a/__tests__/schema/thread/set-comment-state.ts +++ b/__tests__/schema/thread/set-comment-state.ts @@ -1,16 +1,7 @@ import gql from 'graphql-tag' -import { - article, - article2, - comment, - comment1, - comment2, - comment3, - user, - user2, -} from '../../../__fixtures__' -import { Client, given } from '../../__utils__' +import { comment, comment3, user, user2 } from '../../../__fixtures__' +import { Client } from '../../__utils__' const mutation = new Client({ userId: user.id }).prepareQuery({ query: gql` @@ -22,52 +13,23 @@ const mutation = new Client({ userId: user.id }).prepareQuery({ } } `, -}) - -beforeEach(() => { - given('UuidQuery').for( - article, - article2, - comment, - comment1, - comment2, - comment3, - user, - user2, - ) + variables: { input: { id: 35182, trashed: true } }, }) // TODO: this is actually wrong since the provided comment is a thread test('trashing any comment as a moderator returns success', async () => { - given('UuidSetStateMutation') - .withPayload({ ids: [comment2.id], userId: user.id, trashed: true }) - .returns(undefined) - - await mutation - .withInput({ id: comment2.id, trashed: true }) - .shouldReturnData({ - thread: { setCommentState: { success: true } }, - }) + await mutation.shouldReturnData({ + thread: { setCommentState: { success: true } }, + }) }) test('trashing own comment returns success', async () => { - given('UuidSetStateMutation') - .withPayload({ ids: [comment2.id], userId: user2.id, trashed: true }) - .returns(undefined) - - await mutation - .withContext({ userId: user2.id }) - .withInput({ id: comment2.id, trashed: true }) - .shouldReturnData({ - thread: { setCommentState: { success: true } }, - }) + await mutation.withContext({ userId: 266 }).shouldReturnData({ + thread: { setCommentState: { success: true } }, + }) }) test('trashing the comment from another user returns an error', async () => { - given('UuidSetStateMutation') - .withPayload({ ids: [comment3.id], userId: user2.id, trashed: true }) - .returns(undefined) - await mutation .withContext({ userId: user2.id }) .withInput({ id: comment3.id, trashed: true }) @@ -76,7 +38,7 @@ test('trashing the comment from another user returns an error', async () => { test('unauthenticated user gets error', async () => { await mutation - .withInput({ id: comment.id, trashed: true }) .forUnauthenticatedUser() + .withInput({ id: comment.id, trashed: true }) .shouldFailWithError('UNAUTHENTICATED') }) diff --git a/__tests__/schema/thread/set-thread-state.ts b/__tests__/schema/thread/set-thread-state.ts index 023f6d6b3..7a7d6b203 100644 --- a/__tests__/schema/thread/set-thread-state.ts +++ b/__tests__/schema/thread/set-thread-state.ts @@ -1,7 +1,7 @@ import gql from 'graphql-tag' -import { article, comment, user } from '../../../__fixtures__' -import { given, Client } from '../../__utils__' +import { comment, user } from '../../../__fixtures__' +import { Client } from '../../__utils__' import { encodeThreadId } from '~/schema/thread/utils' const mutation = new Client({ userId: user.id }) @@ -18,19 +18,11 @@ const mutation = new Client({ userId: user.id }) }) .withInput({ id: encodeThreadId(comment.id), trashed: true }) -beforeEach(() => { - given('UuidQuery').for(article, comment, user) -}) - test('unauthenticated user gets error', async () => { await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') }) test('trashing thread returns success', async () => { - given('UuidSetStateMutation') - .withPayload({ ids: [comment.id], userId: user.id, trashed: true }) - .returns(undefined) - await mutation.shouldReturnData({ thread: { setThreadState: { success: true } }, }) diff --git a/__tests__/schema/user/delete-regular-users.ts b/__tests__/schema/user/delete-regular-users.ts index 92d1ffa1a..f32391920 100644 --- a/__tests__/schema/user/delete-regular-users.ts +++ b/__tests__/schema/user/delete-regular-users.ts @@ -1,5 +1,4 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' import * as R from 'ramda' import { user as baseUser } from '../../../__fixtures__' @@ -28,19 +27,10 @@ beforeEach(() => { }) .withInput(R.pick(['id', 'username'], user)) - given('UserDeleteRegularUsersMutation').isDefinedBy(async ({ request }) => { - const body = await request.json() - const { userId } = body.payload - - given('UuidQuery').withPayload({ id: userId }).returnsNotFound() - - return HttpResponse.json({ success: true }) - }) - given('UuidQuery').for(user) }) -test('runs successfully when mutation could be successfully executed', async () => { +test('runs successfully if mutation could be successfully executed', async () => { expect(global.kratos.identities).toHaveLength(1) await mutation.shouldReturnData({ @@ -49,20 +39,7 @@ test('runs successfully when mutation could be successfully executed', async () expect(global.kratos.identities).toHaveLength(0) }) -test('fails when mutation failes', async () => { - given('UserDeleteRegularUsersMutation').returns({ - success: false, - reason: 'failure', - }) - expect(global.kratos.identities).toHaveLength(1) - - await mutation.shouldReturnData({ - user: { deleteRegularUser: { success: false } }, - }) - expect(global.kratos.identities).toHaveLength(1) -}) - -test('fails when username does not match user', async () => { +test('fails if username does not match user', async () => { await mutation .withInput({ users: [{ id: user.id, username: 'something' }] }) .shouldFailWithError('BAD_USER_INPUT') @@ -85,32 +62,29 @@ test('updates the cache', async () => { await mutation.execute() - await uuidQuery.shouldReturnData({ uuid: null }) + // TODO: uncomment once UUID query does not call the database-layer any more if the UUID SQL query here is null + //await uuidQuery.shouldReturnData({ uuid: null }) }) -test('fails when one of the given bot ids is not a user', async () => { +test('fails if one of the given bot ids is not a user', async () => { await mutation .withInput({ userIds: [noUserId] }) .shouldFailWithError('BAD_USER_INPUT') }) -test('fails when user is not authenticated', async () => { - await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') +test('fails if you try to delete user Deleted', async () => { + await mutation.withInput({ userIds: 4 }).shouldFailWithError('BAD_USER_INPUT') }) -test('fails when user does not have role "sysadmin"', async () => { - await mutation.forLoginUser('de_admin').shouldFailWithError('FORBIDDEN') +test('fails if user is not authenticated', async () => { + await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') }) -test('fails when database layer has an internal error', async () => { - given('UserDeleteRegularUsersMutation').hasInternalServerError() - - await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') - - expect(global.kratos.identities).toHaveLength(1) +test('fails if user does not have role "sysadmin"', async () => { + await mutation.forLoginUser('de_admin').shouldFailWithError('FORBIDDEN') }) -test('fails when kratos has an error', async () => { +test('fails if kratos has an error', async () => { global.kratos.admin.deleteIdentity = () => { throw new Error('Error in kratos') } diff --git a/__tests__/schema/user/set-email.ts b/__tests__/schema/user/set-email.ts index 79053df33..d8f348b9d 100644 --- a/__tests__/schema/user/set-email.ts +++ b/__tests__/schema/user/set-email.ts @@ -1,9 +1,9 @@ import gql from 'graphql-tag' -import { user as baseUser } from '../../../__fixtures__' -import { Client, given } from '../../__utils__' +import { user } from '../../../__fixtures__' +import { Client } from '../../__utils__' -const user = { ...baseUser, roles: ['sysadmin'] } +const input = { userId: user.id, email: 'user@example.org' } const query = new Client({ userId: user.id }) .prepareQuery({ query: gql` @@ -16,18 +16,17 @@ const query = new Client({ userId: user.id }) } `, }) - .withInput({ userId: user.id, email: 'user@example.org' }) - -beforeEach(() => { - given('UuidQuery').for(user) -}) + .withInput(input) test('returns "{ success: true }" when mutation could be successfully executed', async () => { - given('UserSetEmailMutation') - .withPayload({ userId: user.id, email: 'user@example.org' }) - .returns({ success: true, username: user.username }) - await query.shouldReturnData({ user: { setEmail: { success: true } } }) + + const { email } = await database.fetchOne<{ email: string }>( + 'select email from user where id = ?', + [input.userId], + ) + + expect(email).toBe(input.email) }) test('fails when user is not authenticated', async () => { @@ -37,15 +36,3 @@ test('fails when user is not authenticated', async () => { test('fails when user does not have role "sysadmin"', async () => { await query.forLoginUser('de_admin').shouldFailWithError('FORBIDDEN') }) - -test('fails when database layer returns a 400er response', async () => { - given('UserSetEmailMutation').returnsBadRequest() - - await query.shouldFailWithError('BAD_USER_INPUT') -}) - -test('fails when database layer has an internal error', async () => { - given('UserSetEmailMutation').hasInternalServerError() - - await query.shouldFailWithError('INTERNAL_SERVER_ERROR') -}) diff --git a/__tests__/schema/uuid/abstract-uuid.ts b/__tests__/schema/uuid/abstract-uuid.ts index 1931dcc4b..216904c0a 100644 --- a/__tests__/schema/uuid/abstract-uuid.ts +++ b/__tests__/schema/uuid/abstract-uuid.ts @@ -315,14 +315,10 @@ describe('property "title"', () => { ], '123', ], - ['exercise', [exercise, taxonomyTermSubject], taxonomyTermSubject.name], - [ - 'exercise group', - [exerciseGroup, taxonomyTermSubject], - taxonomyTermSubject.name, - ], + ['exercise', [exercise, taxonomyTermSubject], 'Mathe'], + ['exercise group', [exerciseGroup, taxonomyTermSubject], 'Mathe'], ['user', [user], user.username], - ['taxonomy term', [taxonomyTermRoot], taxonomyTermRoot.name], + ['taxonomy term', [taxonomyTermRoot], 'Root'], ] as [string, Model<'AbstractUuid'>[], string][] test.each(testCases)('%s', async (_, uuids, title) => { diff --git a/__tests__/schema/uuid/set-state.ts b/__tests__/schema/uuid/set-state.ts index 3c4caf896..f35bc261e 100644 --- a/__tests__/schema/uuid/set-state.ts +++ b/__tests__/schema/uuid/set-state.ts @@ -1,21 +1,16 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' import { article, page, - pageRevision, - taxonomyTermRoot, - user as baseUser, + taxonomyTermSubject, + user, + articleRevision, } from '../../../__fixtures__' -import { Client, given } from '../../__utils__' -import { generateRole } from '~/internals/graphql' -import { Instance, Role } from '~/types' +import { Client } from '../../__utils__' -const user = { ...baseUser, roles: ['de_architect'] } -const uuids = [article, page, pageRevision, taxonomyTermRoot] -const client = new Client({ userId: user.id }) -const mutation = client.prepareQuery({ +const uuids = [article.id, page.id, taxonomyTermSubject.id] +const mutation = new Client({ userId: 1 }).prepareQuery({ query: gql` mutation uuid($input: UuidSetStateInput!) { uuid { @@ -25,165 +20,49 @@ const mutation = client.prepareQuery({ } } `, + variables: { input: { id: uuids, trashed: true } }, }) - -beforeEach(() => { - given('UuidQuery').for(page, pageRevision, taxonomyTermRoot, article) - given('UuidSetStateMutation') - .withPayload({ userId: user.id, trashed: true }) - .isDefinedBy(async ({ request }) => { - const body = await request.json() - const { ids, trashed } = body.payload - - for (const id of ids) { - const uuid = uuids.find((x) => x.id === id) - - if (uuid != null) { - given('UuidQuery').for({ ...article, trashed }) - } else { - return new HttpResponse(null, { - status: 500, - }) - } +const query = new Client().prepareQuery({ + query: gql` + query ($id: Int!) { + uuid(id: $id) { + trashed } - - return new HttpResponse() - }) + } + `, + variables: { id: taxonomyTermSubject.id }, }) -describe('infrastructural testing', () => { - beforeEach(() => { - given('UuidQuery').for( - { ...baseUser, roles: ['de_architect'] }, - { ...article, trashed: false }, - ) - }) - - test('returns "{ success: true }" when it succeeds', async () => { - await mutation - .withInput({ id: [article.id], trashed: true }) - .shouldReturnData({ uuid: { setState: { success: true } } }) - }) - - test('updates the cache when it succeeds', async () => { - const uuidQuery = client - .prepareQuery({ - query: gql` - query ($id: Int!) { - uuid(id: $id) { - trashed - } - } - `, - }) - .withVariables({ id: article.id }) - - await uuidQuery.shouldReturnData({ uuid: { trashed: false } }) - - await mutation.withInput({ id: [article.id], trashed: true }).execute() - - await uuidQuery.shouldReturnData({ uuid: { trashed: true } }) - }) +test('set state of an uuid', async () => { + await query.shouldReturnData({ uuid: { trashed: false } }) - test('fails when database layer returns a BadRequest response', async () => { - given('UuidSetStateMutation').returnsBadRequest() + await mutation.shouldReturnData({ uuid: { setState: { success: true } } }) - await mutation - .withInput({ id: [article.id], trashed: true }) - .shouldFailWithError('BAD_USER_INPUT') - }) + await query.shouldReturnData({ uuid: { trashed: true } }) - test('fails when database layer has an internal server error', async () => { - given('UuidSetStateMutation').hasInternalServerError() + await mutation + .changeInput({ trashed: false }) + .shouldReturnData({ uuid: { setState: { success: true } } }) - await mutation - .withInput({ id: [article.id], trashed: true }) - .shouldFailWithError('INTERNAL_SERVER_ERROR') - }) + await query.shouldReturnData({ uuid: { trashed: false } }) }) -describe('permission-based testing', () => { - beforeEach(() => { - given('UuidQuery').for(page, pageRevision, taxonomyTermRoot, article) - }) - - test('fails when user is not authenticated', async () => { - await mutation - .forUnauthenticatedUser() - .withInput({ id: [article.id], trashed: true }) - .shouldFailWithError('UNAUTHENTICATED') - }) - - test('fails when login user tries to set state of page', async () => { - await testPermissionWithMockUser(Role.Login, page.id, false) - }) - - test('fails when login user tries to set state of page revision', async () => { - await testPermissionWithMockUser(Role.Login, pageRevision.id, false) - }) - - test('fails when architect tries to set state of page', async () => { - await testPermissionWithMockUser(Role.Architect, page.id, false) - }) - - test('fails when static_pages_builder tries to set state of article', async () => { - await testPermissionWithMockUser(Role.StaticPagesBuilder, article.id, false) - }) - - test('fails when static_pages_builder tries to set state of taxonomy term', async () => { - await testPermissionWithMockUser( - Role.StaticPagesBuilder, - taxonomyTermRoot.id, - false, - ) - }) - - test('returns "{ success: true }" when architect tries to set state of article', async () => { - await testPermissionWithMockUser(Role.Architect, article.id, true) - }) - - test('returns "{ success: true }" when architect tries to set state of taxonomy term', async () => { - await testPermissionWithMockUser(Role.Architect, taxonomyTermRoot.id, true) - }) - - test('returns "{ success: true }" when static_pages_builder tries to set state of page', async () => { - await testPermissionWithMockUser(Role.StaticPagesBuilder, page.id, true) - }) - - test('returns "{ success: true }" when static_pages_builder tries to set state of page revision', async () => { - await testPermissionWithMockUser( - Role.StaticPagesBuilder, - pageRevision.id, - true, - ) - }) +test('fails when user shall be deletd', async () => { + await mutation + .changeInput({ id: user.id, trashed: true }) + .shouldFailWithError('BAD_USER_INPUT') +}) - test('returns "{ success: true }" when static_pages_builder tries to set state of page revision', async () => { - await testPermissionWithMockUser( - Role.StaticPagesBuilder, - pageRevision.id, - true, - ) - }) +test('fails when article revision shall be deleted', async () => { + await mutation + .changeInput({ id: articleRevision.id }) + .shouldFailWithError('BAD_USER_INPUT') }) -async function testPermissionWithMockUser( - userRole: Role, - uuidId: number, - successSwitch: boolean, -) { - given('UuidQuery').for({ - ...baseUser, - roles: [generateRole(userRole, Instance.De)], - }) +test('fails when user is not authenticated', async () => { + await mutation.forUnauthenticatedUser().shouldFailWithError('UNAUTHENTICATED') +}) - if (successSwitch) { - await mutation - .withInput({ id: [uuidId], trashed: true }) - .shouldReturnData({ uuid: { setState: { success: true } } }) - } else if (!successSwitch) { - await mutation - .withInput({ id: [uuidId], trashed: true }) - .shouldFailWithError('FORBIDDEN') - } -} +test('fails for login user', async () => { + await mutation.forLoginUser().shouldFailWithError('FORBIDDEN') +}) diff --git a/__tests__/schema/uuid/taxonomy-term.ts b/__tests__/schema/uuid/taxonomy-term.ts index db67aadec..5c9e680fc 100644 --- a/__tests__/schema/uuid/taxonomy-term.ts +++ b/__tests__/schema/uuid/taxonomy-term.ts @@ -1,346 +1,100 @@ -import gql from 'graphql-tag' -import * as R from 'ramda' - -import { - article, - taxonomyTermCurriculumTopic, - taxonomyTermRoot, - taxonomyTermSubject, - taxonomyTermTopic, - taxonomyTermTopicFolder, -} from '../../../__fixtures__' -import { Client, getTypenameAndId, given } from '../../__utils__' - -const client = new Client() - -describe('TaxonomyTerm root', () => { - beforeEach(() => { - given('UuidQuery').for(taxonomyTermRoot) - }) - - test('by id', async () => { - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - __typename - ... on TaxonomyTerm { - id - type - trashed - instance - name - description - weight - } - } - } - `, - }) - .withVariables({ id: taxonomyTermRoot.id }) - .shouldReturnData({ - uuid: R.pick( - [ - '__typename', - 'id', - 'type', - 'trashed', - 'instance', - 'name', - 'description', - 'weight', - ], - taxonomyTermRoot, - ), - }) - }) - - test('by id (w/ parent)', async () => { - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - parent { - __typename - id - type - trashed - instance - name - description - weight - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermRoot.id }) - .shouldReturnData({ uuid: { parent: null } }) - }) - - test('by id (w/ path)', async () => { - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - path { - id - name - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermRoot.id }) - .shouldReturnData({ uuid: { path: [] } }) - }) - - test('by id (w/ children)', async () => { - given('UuidQuery').for(taxonomyTermSubject) - - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - children { - nodes { - __typename - ... on TaxonomyTerm { - id - } - } - totalCount - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermRoot.id }) - .shouldReturnData({ - uuid: { - children: { - nodes: [getTypenameAndId(taxonomyTermSubject)], - totalCount: 1, - }, - }, - }) - }) -}) - -describe('TaxonomyTerm subject', () => { - beforeEach(() => { - given('UuidQuery').for(taxonomyTermSubject) - }) - - test('by id', async () => { - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - __typename - ... on TaxonomyTerm { - id - } - } - } - `, - }) - .withVariables({ id: taxonomyTermSubject.id }) - .shouldReturnData({ uuid: getTypenameAndId(taxonomyTermSubject) }) - }) - - test('by id (w/ parent)', async () => { - given('UuidQuery').for(taxonomyTermRoot) - - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - parent { - __typename - id - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermSubject.id }) - .shouldReturnData({ - uuid: { parent: getTypenameAndId(taxonomyTermRoot) }, - }) - }) - - test('by id (w/ children)', async () => { - given('UuidQuery').for(taxonomyTermCurriculumTopic) - - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - children { - nodes { - __typename - ... on TaxonomyTerm { - id - } - } - totalCount - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermSubject.id }) - .shouldReturnData({ - uuid: { - children: { - nodes: [getTypenameAndId(taxonomyTermCurriculumTopic)], - totalCount: 1, - }, - }, - }) +import { taxonomyTermQuery } from '../../__utils__' + +test('TaxonomyTerm root', async () => { + await taxonomyTermQuery.withVariables({ id: 3 }).shouldReturnData({ + uuid: { + __typename: 'TaxonomyTerm', + id: 3, + trashed: false, + type: 'root', + instance: 'de', + alias: '/root/3/root', + title: 'Root', + name: 'Root', + description: null, + weight: 0, + taxonomyId: 1, + path: [], + parent: null, + children: { + nodes: [ + { id: 26876 }, + { id: 26882 }, + { id: 33894 }, + { id: 35608 }, + { id: 25712 }, + { id: 25979 }, + { id: 26523 }, + { id: 8 }, + { id: 24798 }, + { id: 15465 }, + { id: 23382 }, + { id: 23362 }, + { id: 17746 }, + { id: 18234 }, + { id: 17744 }, + { id: 20605 }, + { id: 5 }, + { id: 18230 }, + ], + }, + }, }) }) -describe('TaxonomyTerm curriculumTopic', () => { - beforeEach(() => { - given('UuidQuery').for(taxonomyTermCurriculumTopic) - }) - - test('by id', async () => { - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - __typename - ... on TaxonomyTerm { - id - } - } - } - `, - }) - .withVariables({ id: taxonomyTermCurriculumTopic.id }) - .shouldReturnData({ uuid: getTypenameAndId(taxonomyTermCurriculumTopic) }) - }) - - test('by id (w/ parent)', async () => { - given('UuidQuery').for(taxonomyTermSubject) - - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - parent { - __typename - id - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermCurriculumTopic.id }) - .shouldReturnData({ - uuid: { parent: getTypenameAndId(taxonomyTermSubject) }, - }) - }) - - test('by id (w/ path)', async () => { - given('UuidQuery').for(taxonomyTermSubject) - - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - path { - __typename - id - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermCurriculumTopic.id }) - .shouldReturnData({ - uuid: { - path: [getTypenameAndId(taxonomyTermSubject)], - }, - }) - }) - - test('by id (w/ children)', async () => { - given('UuidQuery').for(article) - - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - children { - nodes { - __typename - id - } - } - } - } - } - `, - }) - .withVariables({ id: taxonomyTermCurriculumTopic.id }) - .shouldReturnData({ - uuid: { children: { nodes: [getTypenameAndId(article)] } }, - }) +test('TaxonomyTerm subject', async () => { + await taxonomyTermQuery.withVariables({ id: 18230 }).shouldReturnData({ + uuid: { + __typename: 'TaxonomyTerm', + id: 18230, + trashed: false, + type: 'subject', + instance: 'de', + alias: '/chemie/18230/chemie', + title: 'Chemie', + name: 'Chemie', + description: '', + weight: 17, + taxonomyId: 3, + path: [], + parent: { id: 3 }, + children: { + nodes: [ + { id: 21069 }, + { id: 18232 }, + { id: 18884 }, + { id: 18885 }, + { id: 18886 }, + { id: 23256 }, + { id: 18887 }, + { id: 18888 }, + ], + }, + }, }) }) -describe('TaxonomyTerm exerciseFolder', () => { - beforeEach(() => { - given('UuidQuery').for(taxonomyTermTopicFolder) - given('UuidQuery').for(taxonomyTermTopic) - }) - - test('by id (check changed type)', async () => { - await client - .prepareQuery({ - query: gql` - query taxonomyTerm($id: Int!) { - uuid(id: $id) { - ... on TaxonomyTerm { - type - } - } - } - `, - }) - .withVariables({ id: taxonomyTermTopicFolder.id }) - .shouldReturnData({ - uuid: { type: 'exerciseFolder' }, - }) +test('TaxonomyTerm exerciseFolder', async () => { + await taxonomyTermQuery.withVariables({ id: 35562 }).shouldReturnData({ + uuid: { + __typename: 'TaxonomyTerm', + id: 35562, + trashed: false, + type: 'exerciseFolder', + instance: 'en', + alias: '/math/35562/example-topic-folder', + title: 'Example topic folder', + name: 'Example topic folder', + description: '', + weight: 1, + taxonomyId: 19, + path: [{ id: 23590 }, { id: 23593 }, { id: 35559 }, { id: 35560 }], + parent: { + id: 35560, + }, + children: { + nodes: [{ id: 35573 }, { id: 35579 }, { id: 35580 }], + }, + }, }) }) diff --git a/__tests__/schema/uuid/user.ts b/__tests__/schema/uuid/user.ts index dd711eb67..eb9bddc2c 100644 --- a/__tests__/schema/uuid/user.ts +++ b/__tests__/schema/uuid/user.ts @@ -276,9 +276,9 @@ describe('User', () => { }) test('by id (w/ activeAuthor when user is not an active author', async () => { - query.changeInput({ id: user2.id }) - - await query.shouldReturnData({ uuid: { isActiveAuthor: false } }) + await query + .withVariables({ id: user2.id }) + .shouldReturnData({ uuid: { isActiveAuthor: false } }) }) }) diff --git a/docker-compose.kratos.yml b/docker-compose.kratos.yml index b50b5f6fe..490ce0a51 100644 --- a/docker-compose.kratos.yml +++ b/docker-compose.kratos.yml @@ -2,7 +2,7 @@ services: kratos-migrate: depends_on: - postgres - image: oryd/kratos:v1.0.0 + image: oryd/kratos:v1.1.0 volumes: - ./kratos:/etc/config/kratos:z command: -c /etc/config/kratos/config.yml migrate sql -e --yes @@ -10,7 +10,7 @@ services: kratos: depends_on: - kratos-migrate - image: oryd/kratos:v1.0.0 + image: oryd/kratos:v1.1.0 ports: - '4433:4433' # public - '4434:4434' # admin @@ -38,13 +38,3 @@ services: ports: - '4436:4436' - '4437:4437' - nbp: - image: quay.io/keycloak/keycloak:21.0.0 - ports: - - 11111:11111 - environment: - - KEYCLOAK_ADMIN=admin - - KEYCLOAK_ADMIN_PASSWORD=admin - command: ['start-dev', '--http-port=11111'] - extra_hosts: - - 'host.docker.internal:host-gateway' diff --git a/docker-compose.sso.yml b/docker-compose.sso.yml new file mode 100644 index 000000000..4a19aaeab --- /dev/null +++ b/docker-compose.sso.yml @@ -0,0 +1,21 @@ +services: + nbp: + image: quay.io/keycloak/keycloak:21.0.0 + ports: + - 11111:11111 + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + command: ['start-dev', '--http-port=11111'] + extra_hosts: + - 'host.docker.internal:host-gateway' + vidis: + image: quay.io/keycloak/keycloak:21.0.0 + ports: + - 11112:11112 + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + command: ['start-dev', '--http-port=11112'] + extra_hosts: + - 'host.docker.internal:host-gateway' diff --git a/kratos/config.yml b/kratos/config.yml index 8b1c08f0f..5ecf559ff 100644 --- a/kratos/config.yml +++ b/kratos/config.yml @@ -32,6 +32,12 @@ selfservice: client_secret: H8t6WKWtGwFjqNfuAjqxrwCfsdznMAfj issuer_url: http://nbp:11111/realms/master mapper_url: file:///etc/config/kratos/user_mapper.jsonnet + - id: vidis + provider: generic + client_id: serlo + client_secret: H8t6WKWtGwFjqNfuAjqxrwCfsdznMAfj + issuer_url: http://vidis:11112/realms/master + mapper_url: file:///etc/config/kratos/user_mapper.jsonnet flows: error: diff --git a/package.json b/package.json index 837e2e386..4befd21f2 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "docker:stop": "run-p --continue-on-error --print-label \"docker:stop:*\"", "docker:stop:server": "docker stop api-server-from-local-build && docker container rm api-server-from-local-build", "docker:stop:swr-queue-worker": "docker stop api-swr-queue-worker-from-local-build && docker container rm api-swr-queue-worker-from-local-build", - "down": "docker compose down", + "down": "docker compose -f docker-compose.sso.yml -f docker-compose.kratos.yml -f docker-compose.yml down", "format": "npm-run-all -c \"format:*\"", "format:eslint": "yarn _eslint --fix", "format:prettier": "yarn _prettier --write", @@ -62,11 +62,11 @@ "start:enmeshed": "docker-compose -f enmeshed/docker-compose.yml up -d", "start:containers": "docker compose up --detach", "start:kratos": "docker compose -f docker-compose.kratos.yml up --detach", + "start:sso": "docker compose -f docker-compose.sso.yml up --detach", "start:server": "yarn _start packages/server/src/server.ts server.cjs", "start:swr-queue-worker": "yarn _start packages/server/src/swr-queue-worker.ts swr-queue-worker.cjs", - "stop": "docker compose stop", + "stop": "docker compose -f docker-compose.sso.yml -f docker-compose.kratos.yml -f docker-compose.yml stop", "stop:enmeshed": "docker-compose -f enmeshed/docker-compose.yml down", - "stop:containers": "docker compose stop", "test": "yarn _jest --config jest.config.js --forceExit", "test:docker:server": "run-s \"test:docker:server:*\"", "test:docker:server:playground": "curl --verbose http://localhost:3001/___graphql | grep 'GraphQL Playground'", diff --git a/packages/server/package.json b/packages/server/package.json index d1be3d57b..86af54e5c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -18,7 +18,8 @@ "deploy:image:production": "node --loader ts-node/esm --experimental-specifier-resolution=node scripts/deploy-env.js production" }, "dependencies": { - "bee-queue": "^1.7.1" + "bee-queue": "^1.7.1", + "bull-arena": "^4.4.0" }, "devDependencies": { "@apollo/server": "^4.10.4", @@ -41,7 +42,6 @@ "@types/uuid": "^9.0.8", "apollo-datasource-rest": "^3.7.0", "basic-auth": "^2.0.1", - "bull-arena": "^4.4.0", "default-import": "^1.1.5", "dotenv": "^16.4.5", "express": "^4.19.2", diff --git a/packages/server/src/cached-resolver.ts b/packages/server/src/cached-resolver.ts index a9362d4bb..a7a7e3258 100644 --- a/packages/server/src/cached-resolver.ts +++ b/packages/server/src/cached-resolver.ts @@ -78,7 +78,7 @@ interface ResolverSpec { getPayload: (key: string) => O.Option getCurrentValue: ( args: Payload, - context: Pick, + context: Pick, ) => Promise enableSwr: boolean swrFrequency?: number diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 29468f90b..9b6607858 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -112,10 +112,10 @@ export class Database { public async fetchOptional( sql: string, params?: unknown[], - ): Promise { + ): Promise { const [result] = await this.execute<(T & RowDataPacket)[]>(sql, params) - return result + return result ?? null } public async fetchOne( diff --git a/packages/server/src/internals/server/kratos-middleware.ts b/packages/server/src/internals/server/kratos-middleware.ts index 551658400..06e45feb0 100644 --- a/packages/server/src/internals/server/kratos-middleware.ts +++ b/packages/server/src/internals/server/kratos-middleware.ts @@ -37,7 +37,11 @@ export function applyKratosMiddleware({ ) { app.post( `${basePath}/single-logout`, - createKratosRevokeSessionsHandler(kratos), + createKratosRevokeSessionsHandler(kratos, 'nbp'), + ) + app.post( + `${basePath}/single-logout-vidis`, + createKratosRevokeSessionsHandler(kratos, 'vidis'), ) } return basePath @@ -137,7 +141,10 @@ function createKratosRegisterHandler(kratos: Kratos): RequestHandler { } } -function createKratosRevokeSessionsHandler(kratos: Kratos): RequestHandler { +function createKratosRevokeSessionsHandler( + kratos: Kratos, + provider: 'nbp' | 'vidis', +): RequestHandler { function sendErrorResponse(response: Response, message: string) { // see https://openid.net/specs/openid-connect-backchannel-1_0.html#BCResponse response.set('Cache-Control', 'no-store').status(400).send(message) @@ -158,7 +165,9 @@ function createKratosRevokeSessionsHandler(kratos: Kratos): RequestHandler { return } - const id = await kratos.db.getIdByCredentialIdentifier(`nbp:${sub}`) + const id = await kratos.db.getIdByCredentialIdentifier( + `${provider}:${sub}`, + ) if (!id) { sendErrorResponse(response, 'user not found or not valid') diff --git a/packages/server/src/internals/swr-queue.ts b/packages/server/src/internals/swr-queue.ts index a1c3ce7a0..2238288da 100644 --- a/packages/server/src/internals/swr-queue.ts +++ b/packages/server/src/internals/swr-queue.ts @@ -4,7 +4,7 @@ import * as t from 'io-ts' import * as R from 'ramda' import { isLegacyQuery, LegacyQuery } from './data-source-helper' -import { type Context } from '~/context' +import { CachedResolver } from '~/cached-resolver' import { createAuthServices } from '~/context/auth-services' import { CacheEntry, Cache, Priority } from '~/context/cache' import { SwrQueue } from '~/context/swr-queue' @@ -12,7 +12,7 @@ import { Database } from '~/database' import { captureErrorEvent } from '~/error-event' import { modelFactories } from '~/model' import { cachedResolvers } from '~/schema' -import { Timer, Time, timeToSeconds, timeToMilliseconds } from '~/timer' +import { Timer, timeToSeconds, timeToMilliseconds } from '~/timer' const INVALID_VALUE_RECEIVED = 'SWR-Queue: Invalid value received from data source.' @@ -161,6 +161,8 @@ export function createSwrQueueWorker({ removeOnSuccess: true, }) + const swrQueue = createSwrQueue({ cache, timer, database }) + queue.process(concurrency, async (job): Promise => { async function processJob() { const { key } = job.data @@ -184,7 +186,12 @@ export function createSwrQueueWorker({ source: 'SWR worker', priority: Priority.Low, getValue: async () => { - const value = await spec.getCurrentValue(payload, { database }) + const value = await spec.getCurrentValue(payload, { + database, + cache, + timer, + swrQueue, + }) if (spec.decoder.is(value)) { return value @@ -253,6 +260,7 @@ async function shouldProcessJob({ for (const legacyQuery of legacyQueries) { if (O.isSome(legacyQuery._querySpec.getPayload(key))) { return { + name: legacyQuery._querySpec.type, ...legacyQuery._querySpec, decoder: legacyQuery._querySpec.decoder ?? t.unknown, } @@ -261,7 +269,7 @@ async function shouldProcessJob({ for (const cachedResolver of cachedResolvers) { if (O.isSome(cachedResolver.spec.getPayload(key))) { // TODO: Change types so that `as` is not needed here - return cachedResolver.spec as unknown as JobSpec + return cachedResolver.spec } } return null @@ -297,18 +305,7 @@ async function shouldProcessJob({ }) } -// TODO: Merge with CachedResolverSpec in `cached-resolver.ts` -interface JobSpec

{ - decoder: { is: (a: unknown) => a is P; name: string } - getPayload: (key: string) => O.Option

- getCurrentValue: ( - payload: P, - context: Pick, - ) => Promise - maxAge?: Time - staleAfter?: Time - enableSwr: boolean -} +type JobSpec = CachedResolver['spec'] function reportError({ error, diff --git a/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index 95eff55e8..013b337ac 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -10,7 +10,6 @@ import { NotificationEventDecoder, PageDecoder, SubscriptionsDecoder, - TaxonomyTermDecoder, UuidDecoder, } from './decoder' import { UserInputError } from '~/errors' @@ -198,54 +197,6 @@ export const spec = { response: t.void, canBeNull: false, }, - TaxonomyCreateEntityLinksMutation: { - payload: t.type({ - entityIds: t.array(t.number), - taxonomyTermId: t.number, - userId: t.number, - }), - response: t.strict({ success: t.literal(true) }), - canBeNull: false, - }, - TaxonomyDeleteEntityLinksMutation: { - payload: t.type({ - entityIds: t.array(t.number), - taxonomyTermId: t.number, - userId: t.number, - }), - response: t.strict({ success: t.literal(true) }), - canBeNull: false, - }, - TaxonomyTermCreateMutation: { - payload: t.type({ - taxonomyType: t.union([t.literal('topic'), t.literal('topic-folder')]), - name: t.string, - userId: t.number, - description: t.union([t.string, t.null, t.undefined]), - parentId: t.number, - }), - response: TaxonomyTermDecoder, - canBeNull: false, - }, - TaxonomySortMutation: { - payload: t.type({ - childrenIds: t.array(t.number), - taxonomyTermId: t.number, - userId: t.number, - }), - response: t.type({ success: t.boolean }), - canBeNull: false, - }, - TaxonomyTermSetNameAndDescriptionMutation: { - payload: t.type({ - name: t.string, - id: t.number, - userId: t.number, - description: t.union([t.string, t.null, t.undefined]), - }), - response: t.type({ success: t.boolean }), - canBeNull: false, - }, ThreadCreateCommentMutation: { payload: t.type({ content: t.string, @@ -327,14 +278,6 @@ export const spec = { }), canBeNull: false, }, - UserDeleteRegularUsersMutation: { - payload: t.type({ userId: t.number }), - response: t.union([ - t.type({ success: t.literal(true) }), - t.type({ success: t.literal(false), reason: t.string }), - ]), - canBeNull: false, - }, UserPotentialSpamUsersQuery: { payload: t.type({ first: t.number, after: t.union([t.number, t.null]) }), response: t.type({ userIds: t.array(t.number) }), @@ -347,20 +290,6 @@ export const spec = { }), canBeNull: false, }, - UserSetEmailMutation: { - payload: t.type({ userId: t.number, email: t.string }), - response: t.type({ success: t.boolean, username: t.string }), - canBeNull: false, - }, - UuidSetStateMutation: { - payload: t.type({ - ids: t.array(t.number), - userId: t.number, - trashed: t.boolean, - }), - response: t.void, - canBeNull: false, - }, UuidQuery: { payload: t.type({ id: t.number }), response: UuidDecoder, diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index 908e25fe2..83da66332 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -25,20 +25,6 @@ export function createSerloModel({ }: { context: Pick }) { - const setUuidState = createMutation({ - type: 'UuidSetStateMutation', - decoder: DatabaseLayer.getDecoderFor('UuidSetStateMutation'), - mutate(payload: DatabaseLayer.Payload<'UuidSetStateMutation'>) { - return DatabaseLayer.makeRequest('UuidSetStateMutation', payload) - }, - async updateCache({ ids }) { - await UuidResolver.removeCacheEntries( - ids.map((id) => ({ id })), - context, - ) - }, - }) - const getActiveReviewerIds = createLegacyQuery( { type: 'ActiveReviewersQuery', @@ -112,32 +98,6 @@ export function createSerloModel({ }, }) - const deleteRegularUsers = createMutation({ - type: 'UserDeleteRegularUsersMutation', - decoder: DatabaseLayer.getDecoderFor('UserDeleteRegularUsersMutation'), - mutate: ( - payload: DatabaseLayer.Payload<'UserDeleteRegularUsersMutation'>, - ) => { - return DatabaseLayer.makeRequest( - 'UserDeleteRegularUsersMutation', - payload, - ) - }, - async updateCache({ userId }, { success }) { - if (success) { - await UuidResolver.removeCacheEntry({ id: userId }, context) - } - }, - }) - - const setEmail = createMutation({ - type: 'UserSetEmailMutation', - decoder: DatabaseLayer.getDecoderFor('UserSetEmailMutation'), - mutate(payload: DatabaseLayer.Payload<'UserSetEmailMutation'>) { - return DatabaseLayer.makeRequest('UserSetEmailMutation', payload) - }, - }) - const getAlias = createLegacyQuery( { type: 'AliasQuery', @@ -584,57 +544,6 @@ export function createSerloModel({ }, }) - const linkEntitiesToTaxonomy = createMutation({ - type: 'TaxonomyCreateEntityLinksMutation', - decoder: DatabaseLayer.getDecoderFor('TaxonomyCreateEntityLinksMutation'), - mutate: ( - payload: DatabaseLayer.Payload<'TaxonomyCreateEntityLinksMutation'>, - ) => { - return DatabaseLayer.makeRequest( - 'TaxonomyCreateEntityLinksMutation', - payload, - ) - }, - async updateCache({ taxonomyTermId, entityIds }, { success }) { - if (success) { - const payloads = [...entityIds, taxonomyTermId].map((id) => ({ id })) - await UuidResolver.removeCacheEntries(payloads, context) - } - }, - }) - - const unlinkEntitiesFromTaxonomy = createMutation({ - type: 'TaxonomyCreateEntityLinksMutation', - decoder: DatabaseLayer.getDecoderFor('TaxonomyDeleteEntityLinksMutation'), - mutate: ( - payload: DatabaseLayer.Payload<'TaxonomyDeleteEntityLinksMutation'>, - ) => { - return DatabaseLayer.makeRequest( - 'TaxonomyDeleteEntityLinksMutation', - payload, - ) - }, - async updateCache({ taxonomyTermId, entityIds }, { success }) { - if (success) { - const payloads = [...entityIds, taxonomyTermId].map((id) => ({ id })) - await UuidResolver.removeCacheEntries(payloads, context) - } - }, - }) - - const createTaxonomyTerm = createMutation({ - type: 'TaxonomyTermCreateMutation', - decoder: DatabaseLayer.getDecoderFor('TaxonomyTermCreateMutation'), - mutate: (payload: DatabaseLayer.Payload<'TaxonomyTermCreateMutation'>) => { - return DatabaseLayer.makeRequest('TaxonomyTermCreateMutation', payload) - }, - async updateCache({ parentId }) { - if (parentId) { - await UuidResolver.removeCacheEntry({ id: parentId }, context) - } - }, - }) - const sortEntity = createMutation({ type: 'EntitySortMutation', decoder: DatabaseLayer.getDecoderFor('EntitySortMutation'), @@ -649,20 +558,6 @@ export function createSerloModel({ }, }) - const sortTaxonomyTerm = createMutation({ - type: 'TaxonomySortMutation', - decoder: DatabaseLayer.getDecoderFor('TaxonomySortMutation'), - mutate: (payload: DatabaseLayer.Payload<'TaxonomySortMutation'>) => { - return DatabaseLayer.makeRequest('TaxonomySortMutation', payload) - }, - - async updateCache({ taxonomyTermId }, { success }) { - if (success) { - await UuidResolver.removeCacheEntry({ id: taxonomyTermId }, context) - } - }, - }) - const setEntityLicense = createMutation({ type: 'EntitySetLicenseMutation', decoder: DatabaseLayer.getDecoderFor('EntitySetLicenseMutation'), @@ -684,26 +579,6 @@ export function createSerloModel({ }, }) - const setTaxonomyTermNameAndDescription = createMutation({ - type: 'TaxonomyTermSetNameAndDescriptionMutation', - decoder: DatabaseLayer.getDecoderFor( - 'TaxonomyTermSetNameAndDescriptionMutation', - ), - mutate: ( - payload: DatabaseLayer.Payload<'TaxonomyTermSetNameAndDescriptionMutation'>, - ) => { - return DatabaseLayer.makeRequest( - 'TaxonomyTermSetNameAndDescriptionMutation', - payload, - ) - }, - async updateCache({ id }, { success }) { - if (success) { - await UuidResolver.removeCacheEntry({ id }, context) - } - }, - }) - const addRole = createMutation({ type: 'UsersByRoleQuery', decoder: DatabaseLayer.getDecoderFor('UserAddRoleMutation'), @@ -740,10 +615,8 @@ export function createSerloModel({ createComment, createEntity, createPage, - createTaxonomyTerm, createThread, deleteBots, - deleteRegularUsers, executePrompt, getActiveReviewerIds, getActivityByType, @@ -756,17 +629,11 @@ export function createSerloModel({ getUnrevisedEntities, getUnrevisedEntitiesPerSubject, getUsersByRole, - linkEntitiesToTaxonomy, getPages, rejectEntityRevision, - setEmail, setEntityLicense, setSubscription, - setTaxonomyTermNameAndDescription, sortEntity, - sortTaxonomyTerm, - setUuidState, - unlinkEntitiesFromTaxonomy, } } diff --git a/packages/server/src/model/types.ts b/packages/server/src/model/types.ts index 51baa4c24..edce6f317 100644 --- a/packages/server/src/model/types.ts +++ b/packages/server/src/model/types.ts @@ -149,6 +149,11 @@ export interface Models { record: t.TypeOf | null query: Record } + TaxonomyTermCreateResponse: { + success: boolean + record: t.TypeOf | null + query: Record + } } enum Role { diff --git a/packages/server/src/schema/authorization/utils.ts b/packages/server/src/schema/authorization/utils.ts index b0b3c6e12..719080c4a 100644 --- a/packages/server/src/schema/authorization/utils.ts +++ b/packages/server/src/schema/authorization/utils.ts @@ -3,6 +3,7 @@ import { instanceToScope, Scope, } from '@serlo/authorization' +import * as t from 'io-ts' import { UuidResolver } from '../uuid/abstract-uuid/resolvers' import { Context } from '~/context' @@ -13,6 +14,7 @@ import { EntityRevisionDecoder, PageRevisionDecoder, UserDecoder, + UuidDecoder, } from '~/model/decoder' import { resolveRolesPayload, RolesPayload } from '~/schema/authorization/roles' import { isInstance, isInstanceAware } from '~/schema/instance/utils' @@ -61,25 +63,41 @@ export async function fetchScopeOfUuid( if (object === null) throw new UserInputError('UUID does not exist.') + const instance = await fetchInstance(object, context) + + return instance != null ? instanceToScope(instance) : Scope.Serlo +} + +export function resolveScopedRoles(user: Model<'User'>): Model<'ScopedRole'>[] { + return user.roles.map(legacyRoleToRole).filter(isDefined) +} + +export async function fetchInstance( + object: t.TypeOf | null, + context: Context, +) { + if (object == null) return null + // If the object has an instance, return the corresponding scope if (isInstanceAware(object)) { - return instanceToScope(object.instance) + return object.instance } // Comments and Threads don't have an instance itself, but their object descendant has if (object.__typename === DiscriminatorType.Comment) { - return await fetchScopeOfUuid({ id: object.parentId }, context) + const parent = await UuidResolver.resolve({ id: object.parentId }, context) + return await fetchInstance(parent, context) } if (EntityRevisionDecoder.is(object) || PageRevisionDecoder.is(object)) { - return await fetchScopeOfUuid({ id: object.repositoryId }, context) + const repository = await UuidResolver.resolve( + { id: object.repositoryId }, + context, + ) + return await fetchInstance(repository, context) } - return Scope.Serlo -} - -export function resolveScopedRoles(user: Model<'User'>): Model<'ScopedRole'>[] { - return user.roles.map(legacyRoleToRole).filter(isDefined) + return null } function legacyRoleToRole(role: string): Model<'ScopedRole'> | null { diff --git a/packages/server/src/schema/cache/resolvers.ts b/packages/server/src/schema/cache/resolvers.ts index 8349424ae..b6f76e454 100644 --- a/packages/server/src/schema/cache/resolvers.ts +++ b/packages/server/src/schema/cache/resolvers.ts @@ -8,6 +8,7 @@ const allowedUserIds = [ 32543, // botho 178807, // HugoBT 245844, // MoeHome + 269930, // MikeySerlo ] export const resolvers: Resolvers = { diff --git a/packages/server/src/schema/index.ts b/packages/server/src/schema/index.ts index 6716ca0d0..378f5dc08 100644 --- a/packages/server/src/schema/index.ts +++ b/packages/server/src/schema/index.ts @@ -12,6 +12,7 @@ import { notificationsSchema } from './notifications' import { oauthSchema } from './oauth' import { rolesSchema } from './roles' import { subjectsSchema } from './subject' +import { SubjectResolver } from './subject/resolvers' import { subscriptionSchema } from './subscription' import { threadSchema } from './thread' import { uuidCachedResolvers, uuidSchema } from './uuid' @@ -41,7 +42,9 @@ export const schema = mergeSchemas( ) // TODO: Fix the following type error -// @ts-expect-error Unfortunately typecasting does not work here export const cachedResolvers: Array> = [ + // @ts-expect-error Unfortunately typecasting does not work here ...uuidCachedResolvers, + // @ts-expect-error Unfortunately typecasting does not work here + SubjectResolver, ] diff --git a/packages/server/src/schema/subject/resolvers.ts b/packages/server/src/schema/subject/resolvers.ts index 713876ea9..971fc6a77 100644 --- a/packages/server/src/schema/subject/resolvers.ts +++ b/packages/server/src/schema/subject/resolvers.ts @@ -24,15 +24,10 @@ export const SubjectsResolver = createCachedResolver({ }, examplePayload: undefined, async getCurrentValue(_, { database }) { - interface Row { - id: number - instance: string - } - - const rows = await database.fetchAll( + const rows = await database.fetchAll( ` SELECT - subject.id, + subject.id as taxonomyTermId, subject_instance.subdomain as instance FROM term_taxonomy AS subject JOIN term_taxonomy AS root ON root.id = subject.parent_id @@ -51,15 +46,7 @@ export const SubjectsResolver = createCachedResolver({ `, ) - return rows - .map((row) => { - const { id, instance } = row - return { - taxonomyTermId: id, - instance, - } - }) - .filter(SubjectDecoder.is) + return rows.filter(SubjectDecoder.is) }, }) @@ -106,3 +93,66 @@ export const resolvers: Resolvers = { }, }, } + +export const SubjectResolver = createCachedResolver({ + name: 'SubjectQuery', + decoder: t.union([t.null, t.type({ name: t.string, id: t.number })]), + enableSwr: true, + staleAfter: { days: 14 }, + maxAge: { days: 90 }, + getKey: ({ taxonomyId }) => { + return `subject/${taxonomyId}` + }, + getPayload: (key) => { + if (!key.startsWith('subject/')) return O.none + const taxonomyId = parseInt(key.replace('subject/', ''), 10) + return O.some({ taxonomyId }) + }, + examplePayload: { taxonomyId: 1 }, + async getCurrentValue({ taxonomyId }, { database }) { + interface Row { + name: string + id: number + } + + return await database.fetchOptional( + ` + SELECT t.name as name, t1.id as id + FROM term_taxonomy t0 + JOIN term_taxonomy t1 ON t1.parent_id = t0.id + LEFT JOIN term_taxonomy t2 ON t2.parent_id = t1.id + LEFT JOIN term_taxonomy t3 ON t3.parent_id = t2.id + LEFT JOIN term_taxonomy t4 ON t4.parent_id = t3.id + LEFT JOIN term_taxonomy t5 ON t5.parent_id = t4.id + LEFT JOIN term_taxonomy t6 ON t6.parent_id = t5.id + LEFT JOIN term_taxonomy t7 ON t7.parent_id = t6.id + LEFT JOIN term_taxonomy t8 ON t8.parent_id = t7.id + LEFT JOIN term_taxonomy t9 ON t9.parent_id = t8.id + LEFT JOIN term_taxonomy t10 ON t10.parent_id = t9.id + LEFT JOIN term_taxonomy t11 ON t11.parent_id = t10.id + LEFT JOIN term_taxonomy t12 ON t12.parent_id = t11.id + LEFT JOIN term_taxonomy t13 ON t13.parent_id = t12.id + LEFT JOIN term_taxonomy t14 ON t14.parent_id = t13.id + LEFT JOIN term_taxonomy t15 ON t15.parent_id = t14.id + LEFT JOIN term_taxonomy t16 ON t16.parent_id = t15.id + LEFT JOIN term_taxonomy t17 ON t17.parent_id = t16.id + LEFT JOIN term_taxonomy t18 ON t18.parent_id = t17.id + LEFT JOIN term_taxonomy t19 ON t19.parent_id = t18.id + LEFT JOIN term_taxonomy t20 ON t20.parent_id = t19.id + JOIN term t on t1.term_id = t.id + WHERE + ( + t0.id = 146728 OR + t0.id = 106081 OR + (t0.parent_id IS NULL AND t2.id != 146728 AND t1.id != 106081) + ) AND ( + t1.id = ? OR t2.id = ? OR t3.id = ? OR t4.id = ? OR t5.id = ? OR + t6.id = ? OR t7.id = ? OR t8.id = ? OR t9.id = ? OR t10.id = ? OR + t11.id = ? OR t12.id = ? OR t13.id = ? OR t14.id = ? OR t15.id = ? + OR t16.id = ? OR t17.id = ? OR t18.id = ? OR t19.id = ? OR t20.id = ? + ) + `, + new Array(20).fill(taxonomyId), + ) + }, +}) diff --git a/packages/server/src/schema/thread/resolvers.ts b/packages/server/src/schema/thread/resolvers.ts index 66a181b5c..95695dc0c 100644 --- a/packages/server/src/schema/thread/resolvers.ts +++ b/packages/server/src/schema/thread/resolvers.ts @@ -7,6 +7,7 @@ import { encodeThreadId, resolveThreads, } from './utils' +import { createEvent } from '../events/event' import { Context } from '~/context' import { ForbiddenError, UserInputError } from '~/errors' import { @@ -18,13 +19,17 @@ import { import { CommentDecoder, DiscriminatorType, + NotificationEventType, UserDecoder, UuidDecoder, } from '~/model/decoder' -import { fetchScopeOfUuid } from '~/schema/authorization/utils' +import { fetchInstance, fetchScopeOfUuid } from '~/schema/authorization/utils' import { resolveConnection } from '~/schema/connection/utils' import { decodeSubjectId } from '~/schema/subject/utils' -import { UuidResolver } from '~/schema/uuid/abstract-uuid/resolvers' +import { + UuidResolver, + setUuidState, +} from '~/schema/uuid/abstract-uuid/resolvers' import { createUuidResolvers } from '~/schema/uuid/abstract-uuid/utils' import { CommentStatus, Resolvers } from '~/types' @@ -325,7 +330,7 @@ export const resolvers: Resolvers = { return { success: true, query: {} } }, async setThreadState(_parent, payload, context) { - const { dataSources, userId } = context + const { database, userId } = context const { trashed } = payload.input const ids = decodeThreadIds(payload.input.id) @@ -342,12 +347,45 @@ export const resolvers: Resolvers = { context, }) - await dataSources.model.serlo.setUuidState({ ids, userId, trashed }) + const transaction = await database.beginTransaction() - return { success: true, query: {} } + try { + for (const id of ids) { + const comment = await UuidResolver.resolve({ id }, context) + + if (comment?.__typename !== DiscriminatorType.Comment) { + throw new UserInputError(`${id} is no comment`) + } + + const instance = await fetchInstance(comment, context) + + if (instance == null) { + throw new UserInputError('comment must have an instance') + } + + await setUuidState({ id, trashed }, context) + + await createEvent( + { + __typename: NotificationEventType.SetThreadState, + actorId: userId, + archived: trashed, + threadId: comment.id, + instance, + }, + context, + ) + } + + await transaction.commit() + + return { success: true, query: {} } + } finally { + await transaction.rollback() + } }, async setCommentState(_parent, payload, context) { - const { dataSources, userId } = context + const { userId } = context const { id: ids, trashed } = payload.input const scopes = await Promise.all( @@ -375,9 +413,42 @@ export const resolvers: Resolvers = { }) } - await dataSources.model.serlo.setUuidState({ ids, trashed, userId }) + const transaction = await database.beginTransaction() - return { success: true, query: {} } + try { + for (const id of ids) { + const comment = await UuidResolver.resolve({ id }, context) + + if (comment?.__typename !== DiscriminatorType.Comment) { + throw new UserInputError(`${id} is no comment`) + } + + const instance = await fetchInstance(comment, context) + + if (instance == null) { + throw new UserInputError('comment must have an instance') + } + + await setUuidState({ id, trashed }, context) + + await createEvent( + { + __typename: NotificationEventType.SetThreadState, + actorId: userId, + archived: trashed, + threadId: comment.id, + instance, + }, + context, + ) + } + + await transaction.commit() + + return { success: true, query: {} } + } finally { + await transaction.rollback() + } }, }, } diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index c37e89d0d..5dc5b0e5c 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -2,6 +2,7 @@ import * as auth from '@serlo/authorization' import { option as O } from 'fp-ts' import * as t from 'io-ts' import { date } from 'io-ts-types/lib/date' +import * as R from 'ramda' import { createCachedResolver } from '~/cached-resolver' import { resolveCustomId } from '~/config' @@ -18,13 +19,16 @@ import { UuidDecoder, DiscriminatorType, EntityTypeDecoder, - EntityRevisionTypeDecoder, CommentStatusDecoder, + InstanceDecoder, + EntityRevisionDecoder, + PageRevisionDecoder, + NotificationEventType, } from '~/model/decoder' -import { fetchScopeOfUuid } from '~/schema/authorization/utils' +import { createEvent } from '~/schema/events/event' +import { SubjectResolver } from '~/schema/subject/resolvers' import { decodePath, encodePath } from '~/schema/uuid/alias/utils' -import { Resolvers, QueryUuidArgs } from '~/types' -import { isDefined } from '~/utils' +import { Resolvers, QueryUuidArgs, TaxonomyTermType } from '~/types' export const UuidResolver = createCachedResolver< { id: number }, @@ -77,56 +81,71 @@ export const resolvers: Resolvers = { }, UuidMutation: { async setState(_parent, payload, context) { - const { dataSources, userId } = context + const { userId } = context const { id, trashed } = payload.input const ids = id - const guards = await Promise.all( - ids.map(async (id): Promise => { - // TODO: this is not optimized since it fetches the object twice and sequentially. - // change up fetchScopeOfUuid to return { scope, object } instead - const scope = await fetchScopeOfUuid({ id }, context) - const object = await UuidResolver.resolve({ id }, context) - if (object === null) { - return null - } else { - return auth.Uuid.setState(getType(object))(scope) - } + assertUserIsAuthenticated(userId) - function getType(object: Model<'AbstractUuid'>): auth.UuidType { - switch (object.__typename) { - case DiscriminatorType.Page: - return 'Page' - case DiscriminatorType.PageRevision: - return 'PageRevision' - case DiscriminatorType.TaxonomyTerm: - return 'TaxonomyTerm' - case DiscriminatorType.User: - return 'User' - default: - if (EntityTypeDecoder.is(object.__typename)) { - return 'Entity' - } - if (EntityRevisionTypeDecoder.is(object.__typename)) { - return 'EntityRevision' - } - return 'unknown' - } - } - }), + const objects = await Promise.all( + ids.map((id) => UuidResolver.resolve({ id }, context)), ) - assertUserIsAuthenticated(userId) - await assertUserIsAuthorized({ - guards: guards.filter(isDefined), - message: - 'You are not allowed to set the state of the provided UUID(s).', - context, - }) + const transaction = await database.beginTransaction() + + try { + for (const object of objects) { + if ( + object === null || + object.__typename === DiscriminatorType.Comment || + object.__typename === DiscriminatorType.User || + EntityRevisionDecoder.is(object) || + PageRevisionDecoder.is(object) + ) { + throw new UserInputError( + 'One of the provided ids cannot be deleted', + ) + } + + const scope = auth.instanceToScope(object.instance) + const type = EntityTypeDecoder.is(object) + ? 'Entity' + : object.__typename === DiscriminatorType.Page + ? 'Page' + : 'TaxonomyTerm' + + await assertUserIsAuthorized({ + guard: auth.Uuid.setState(type)(scope), + message: + 'You are not allowed to set the state of the provided UUID(s).', + context, + }) + + await setUuidState({ id: object.id, trashed }, context) - await dataSources.model.serlo.setUuidState({ ids, userId, trashed }) + await createEvent( + { + __typename: NotificationEventType.SetUuidState, + actorId: userId, + instance: object.instance, + objectId: object.id, + trashed, + }, + context, + ) + } - return { success: true, query: {} } + await transaction.commit() + + await UuidResolver.removeCacheEntries( + ids.map((id) => ({ id }), context), + context, + ) + + return { success: true, query: {} } + } finally { + await transaction.rollback() + } }, }, } @@ -134,24 +153,63 @@ export const resolvers: Resolvers = { // TODO: Move to util file databse.ts const Tinyint = t.union([t.literal(0), t.literal(1)]) -const BaseComment = t.type({ +const BaseUuid = t.type({ id: t.number, - discriminator: t.literal('comment'), trashed: Tinyint, - authorId: t.number, - title: t.string, - date: date, - archived: Tinyint, - content: t.string, - parentUuid: t.union([t.number, t.null]), - parentCommentId: t.union([t.number, t.null]), - status: t.union([CommentStatusDecoder, t.null]), - childrenIds: t.array(t.union([t.number, t.null])), }) +const WeightedNumberList = t.record( + t.union([t.literal('__no_key'), t.number]), + t.union([t.null, t.number]), +) + +const BaseComment = t.intersection([ + BaseUuid, + t.type({ + discriminator: t.literal('comment'), + commentAuthorId: t.number, + commentTitle: t.string, + commentDate: date, + commentArchived: Tinyint, + commentContent: t.string, + commentParentUuid: t.union([t.number, t.null]), + commentParentCommentId: t.union([t.number, t.null]), + commentStatus: t.union([CommentStatusDecoder, t.null]), + commentChildrenIds: WeightedNumberList, + }), +]) + +const BaseTaxonomy = t.intersection([ + BaseUuid, + t.type({ + discriminator: t.literal('taxonomyTerm'), + taxonomyInstance: InstanceDecoder, + taxonomyType: t.string, + taxonomyName: t.string, + taxonomyDescription: t.union([t.null, t.string]), + taxonomyWeight: t.union([t.null, t.number]), + taxonomyId: t.number, + taxonomyParentId: t.union([t.null, t.number]), + taxonomyChildrenIds: WeightedNumberList, + taxonomyEntityChildrenIds: WeightedNumberList, + }), +]) + +const BaseUser = t.intersection([ + BaseUuid, + t.type({ + discriminator: t.literal('user'), + userUsername: t.string, + userDate: date, + userLastLogin: date, + userDescription: t.string, + userRoles: t.array(t.string), + }), +]) + async function resolveUuidFromDatabase( { id }: { id: number }, - context: Pick, + context: Pick, ): Promise | null> { const baseUuid = await context.database.fetchOptional( ` @@ -159,43 +217,128 @@ async function resolveUuidFromDatabase( uuid.id as id, uuid.trashed, uuid.discriminator, - comment.author_id as authorId, - comment.title as title, - comment.date as date, - comment.archived as archived, - comment.content as content, - comment.parent_id as parentCommentId, - comment.uuid_id as parentUuid, - JSON_ARRAYAGG(comment_children.id) as childrenIds, + + comment.author_id AS commentAuthorId, + comment.title AS commentTitle, + comment.date AS commentDate, + comment.archived AS commentArchived, + comment.content AS commentContent, + comment.parent_id AS commentParentCommentId, + comment.uuid_id AS commentParentUuid, + JSON_OBJECTAGG( + COALESCE(comment_children.id, "__no_key"), + comment_children.id + ) AS commentChildrenIds, CASE WHEN comment_status.name = 'no_status' THEN 'noStatus' ELSE comment_status.name - END AS status + END AS commentStatus, + + taxonomy_type.name AS taxonomyType, + taxonomy_instance.subdomain AS taxonomyInstance, + term.name AS taxonomyName, + term_taxonomy.description AS taxonomyDescription, + term_taxonomy.weight AS taxonomyWeight, + taxonomy.id AS taxonomyId, + term_taxonomy.parent_id AS taxonomyParentId, + JSON_OBJECTAGG( + COALESCE(taxonomy_child.id, "__no_key"), + taxonomy_child.weight + ) AS taxonomyChildrenIds, + JSON_OBJECTAGG( + COALESCE(term_taxonomy_entity.entity_id, "__no_key"), + term_taxonomy_entity.position + ) AS taxonomyEntityChildrenIds, + + user.username AS userUsername, + user.date AS userDate, + user.last_login AS userLastLogin, + user.description AS userDescription, + JSON_ARRAYAGG(role.name) AS userRoles + FROM uuid + LEFT JOIN comment ON comment.id = uuid.id LEFT JOIN comment comment_children ON comment_children.parent_id = comment.id - LEFT JOIN comment_status on comment_status.id = comment.comment_status_id + LEFT JOIN comment_status ON comment_status.id = comment.comment_status_id + + LEFT JOIN term_taxonomy ON term_taxonomy.id = uuid.id + LEFT JOIN taxonomy ON taxonomy.id = term_taxonomy.taxonomy_id + LEFT JOIN type taxonomy_type ON taxonomy_type.id = taxonomy.type_id + LEFT JOIN instance taxonomy_instance ON taxonomy_instance.id = taxonomy.instance_id + LEFT JOIN term ON term.id = term_taxonomy.term_id + LEFT JOIN term_taxonomy taxonomy_child ON taxonomy_child.parent_id = term_taxonomy.id + LEFT JOIN term_taxonomy_entity ON term_taxonomy_entity.term_taxonomy_id = term_taxonomy.id + + LEFT JOIN user ON user.id = uuid.id + LEFT JOIN role_user ON user.id = role_user.user_id + LEFT JOIN role ON role.id = role_user.role_id + WHERE uuid.id = ? GROUP BY uuid.id `, [id], ) - if (BaseComment.is(baseUuid)) { - const parentId = baseUuid.parentUuid ?? baseUuid.parentCommentId ?? null - - if (parentId == null) return null - - return { - ...baseUuid, - __typename: DiscriminatorType.Comment, - trashed: Boolean(baseUuid.trashed), - archived: Boolean(baseUuid.archived), - parentId, - alias: `/${parentId}#comment-${baseUuid.id}`, - status: baseUuid.status ?? 'noStatus', - childrenIds: baseUuid.childrenIds.filter(isDefined), - date: baseUuid.date.toISOString(), + if (BaseUuid.is(baseUuid)) { + const base = { id: baseUuid.id, trashed: Boolean(baseUuid.trashed) } + + if (BaseComment.is(baseUuid)) { + const parentId = + baseUuid.commentParentUuid ?? baseUuid.commentParentCommentId ?? null + + if (parentId == null) return null + + return { + ...base, + __typename: DiscriminatorType.Comment, + archived: Boolean(baseUuid.commentArchived), + parentId, + alias: `/${parentId}#comment-${baseUuid.id}`, + status: baseUuid.commentStatus ?? 'noStatus', + childrenIds: getSortedList(baseUuid.commentChildrenIds), + date: baseUuid.commentDate.toISOString(), + title: baseUuid.commentTitle, + authorId: baseUuid.commentAuthorId, + content: baseUuid.commentContent, + } + } else if (BaseTaxonomy.is(baseUuid)) { + const subject = await SubjectResolver.resolve( + { taxonomyId: baseUuid.id }, + context, + ) + const subjectName = + subject != null && subject.name.length > 0 ? subject.name : 'root' + const alias = `/${toSlug(subjectName)}/${baseUuid.id}/${toSlug(baseUuid.taxonomyName)}` + const childrenIds = [ + ...getSortedList(baseUuid.taxonomyChildrenIds), + ...getSortedList(baseUuid.taxonomyEntityChildrenIds), + ] + + return { + ...base, + __typename: DiscriminatorType.TaxonomyTerm, + instance: baseUuid.taxonomyInstance, + type: getTaxonomyTermType(baseUuid.taxonomyType), + alias, + name: baseUuid.taxonomyName, + description: baseUuid.taxonomyDescription, + weight: baseUuid.taxonomyWeight ?? 0, + taxonomyId: baseUuid.taxonomyId, + parentId: baseUuid.taxonomyParentId, + childrenIds, + } + } else if (BaseUser.is(baseUuid)) { + return { + ...base, + __typename: DiscriminatorType.User, + alias: `/user/${base.id}/${baseUuid.userUsername}`, + date: baseUuid.userDate.toISOString(), + description: baseUuid.userDescription, + lastLogin: baseUuid.userLastLogin.toISOString(), + roles: baseUuid.userRoles, + username: baseUuid.userUsername, + } } } @@ -204,6 +347,37 @@ async function resolveUuidFromDatabase( return UuidDecoder.is(uuidFromDBLayer) ? uuidFromDBLayer : null } +export async function setUuidState( + { id, trashed }: { id: number; trashed: boolean }, + { database }: Pick, +) { + await database.mutate('update uuid set trashed = ? where id = ?', [ + trashed ? 1 : 0, + id, + ]) +} + +function getSortedList(listAsDict: t.TypeOf) { + const ids = Object.keys(listAsDict) + .map((x) => parseInt(x)) + .filter((x) => !isNaN(x)) + + return R.sortBy((x) => listAsDict[x] ?? 0, ids) +} + +function getTaxonomyTermType(type: string) { + switch (type) { + case 'subject': + return TaxonomyTermType.Subject + case 'root': + return TaxonomyTermType.Root + case 'topic-folder': + return 'topicFolder' + default: + return TaxonomyTermType.Topic + } +} + async function resolveIdFromPayload( dataSources: Context['dataSources'], payload: QueryUuidArgs, @@ -249,3 +423,13 @@ async function resolveIdFromAlias( return (await dataSources.model.serlo.getAlias(alias))?.id ?? null } + +function toSlug(name: string) { + return name + .toLowerCase() + .replace(/ /g, '-') // replace spaces with hyphens + .replace(/[^\w-]+/g, '') // remove all non-word chars including _ + .replace(/--+/g, '-') // replace multiple hyphens + .replace(/^-+/, '') // trim starting hyphen + .replace(/-+$/, '') // trim end hyphen +} diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index 2b9bfe7e9..756a38d52 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts +++ b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts @@ -2,6 +2,7 @@ import * as serloAuth from '@serlo/authorization' import { UuidResolver } from '../abstract-uuid/resolvers' import { Context } from '~/context' +import { InternalServerError, UserInputError } from '~/errors' import { createNamespace, assertUserIsAuthenticated, @@ -9,9 +10,15 @@ import { assertStringIsNotEmpty, Model, } from '~/internals/graphql' -import { TaxonomyTermDecoder } from '~/model/decoder' -import { fetchScopeOfUuid } from '~/schema/authorization/utils' +import { + DiscriminatorType, + EntityDecoder, + EntityType, + NotificationEventType, + TaxonomyTermDecoder, +} from '~/model/decoder' import { resolveConnection } from '~/schema/connection/utils' +import { createEvent } from '~/schema/events/event' import { createThreadResolvers } from '~/schema/thread/utils' import { createUuidResolvers } from '~/schema/uuid/abstract-uuid/utils' import { TaxonomyTermType, TaxonomyTypeCreateOptions, Resolvers } from '~/types' @@ -79,86 +86,296 @@ export const resolvers: Resolvers = { }, TaxonomyTermMutation: { async create(_parent, { input }, context) { - const { dataSources, userId } = context - assertUserIsAuthenticated(userId) + const { database, userId } = context - const { parentId, name, taxonomyType, description = undefined } = input + const { parentId, name, description = null } = input + const taxonomyType = + input.taxonomyType === TaxonomyTypeCreateOptions.ExerciseFolder + ? 'topic-folder' + : 'topic' + assertUserIsAuthenticated(userId) assertStringIsNotEmpty({ name }) - const scope = await fetchScopeOfUuid({ id: parentId }, context) + const parent = await UuidResolver.resolve({ id: parentId }, context) + + if (parent?.__typename != DiscriminatorType.TaxonomyTerm) { + throw new UserInputError(`parent with ${parentId} is no taxonomy term`) + } + + if (parent.type === 'topicFolder') { + throw new UserInputError(`parent ${parentId} is an exercise folder`) + } await assertUserIsAuthorized({ context, message: 'You are not allowed create taxonomy terms.', - guard: serloAuth.Uuid.create('TaxonomyTerm')(scope), - }) - - const taxonomyTerm = await dataSources.model.serlo.createTaxonomyTerm({ - parentId, - taxonomyType: - taxonomyType === TaxonomyTypeCreateOptions.ExerciseFolder - ? 'topic-folder' - : 'topic', - name, - description, - userId, + guard: serloAuth.Uuid.create('TaxonomyTerm')( + serloAuth.instanceToScope(parent.instance), + ), }) - return { - success: taxonomyTerm ? true : false, - record: taxonomyTerm, - query: {}, + const transaction = await database.beginTransaction() + + try { + const { insertId: taxonomyId } = await database.mutate( + 'insert into uuid (trashed, discriminator) values (0, "taxonomyTerm")', + ) + + if (taxonomyId <= 0) { + throw new InternalServerError('no uuid entry could be created') + } + + const { insertId: termId } = await database.mutate( + ` + insert into term (instance_id, name) + select term_parent.instance_id, ? + from term term_parent + join term_taxonomy taxonomy_parent on taxonomy_parent.term_id = term_parent.id + where taxonomy_parent.id = ? + limit 1 + `, + [name, parentId], + ) + + if (termId <= 0) { + throw new UserInputError( + `parent taxonomy ${parentId} does not exists`, + ) + } + + const { currentHeaviest } = await database.fetchOne<{ + currentHeaviest: number + }>( + ` + SELECT IFNULL(MAX(tt.weight), 0) AS currentHeaviest + FROM term_taxonomy tt + WHERE tt.parent_id = ? + `, + [parentId], + ) + + await database.mutate( + ` + insert into term_taxonomy (id, taxonomy_id, term_id, parent_id, description, weight) + select ?, taxonomy.id, ?, ?, ?, ? + from taxonomy + join type on taxonomy.type_id = type.id + join instance on taxonomy.instance_id = instance.id + where type.name = ? and instance.subdomain = ? + `, + [ + taxonomyId, + termId, + parentId, + description, + currentHeaviest + 1, + taxonomyType, + parent.instance, + ], + ) + + const record = await UuidResolver.resolve({ id: taxonomyId }, context) + + if (record?.__typename !== DiscriminatorType.TaxonomyTerm) { + throw new InternalServerError('taxonomy term could not be created') + } + + await createEvent( + { + __typename: NotificationEventType.CreateTaxonomyTerm, + actorId: userId, + taxonomyTermId: taxonomyId, + instance: record.instance, + }, + context, + ) + + await UuidResolver.removeCacheEntry({ id: record.parentId! }, context) + await UuidResolver.removeCacheEntry({ id: record.id }, context) + await transaction.commit() + + return { success: true, record, query: {} } + } finally { + await transaction.rollback() } }, async createEntityLinks(_parent, { input }, context) { - const { dataSources, userId } = context + const { database, userId } = context assertUserIsAuthenticated(userId) const { entityIds, taxonomyTermId } = input - const scope = await fetchScopeOfUuid({ id: taxonomyTermId }, context) + const taxonomyTerm = await UuidResolver.resolve( + { id: taxonomyTermId }, + context, + ) + const entities = await Promise.all( + entityIds.map((id) => UuidResolver.resolve({ id }, context)), + ) + + if ( + taxonomyTerm == null || + taxonomyTerm.__typename !== DiscriminatorType.TaxonomyTerm + ) { + throw new UserInputError('termTaxonomyId must belong to taxonomy') + } + + const canBeLinked = (entity: (typeof entities)[number]) => { + if (!EntityDecoder.is(entity)) return false + if (entity.__typename === EntityType.CoursePage) return false + if (entity.instance !== taxonomyTerm.instance) return false + if ( + taxonomyTerm.type === 'topicFolder' && + entity.__typename !== EntityType.Exercise && + entity.__typename !== EntityType.ExerciseGroup + ) { + return false + } + if ( + taxonomyTerm.type !== 'topicFolder' && + (entity.__typename === EntityType.Exercise || + entity.__typename === EntityType.ExerciseGroup) + ) { + return false + } + return true + } + + if (entities.some((entity) => !canBeLinked(entity))) { + throw new UserInputError( + 'At least one child cannot be added to the taxonomy', + ) + } await assertUserIsAuthorized({ message: 'You are not allowed to link entities to this taxonomy term.', - guard: serloAuth.TaxonomyTerm.change(scope), + guard: serloAuth.TaxonomyTerm.change( + serloAuth.instanceToScope(taxonomyTerm.instance), + ), context, }) - const { success } = await dataSources.model.serlo.linkEntitiesToTaxonomy({ - entityIds, - taxonomyTermId, - userId, - }) - - return { success, query: {} } + const transaction = await database.beginTransaction() + + try { + for (const entity of entities) { + if ( + !EntityDecoder.is(entity) || + entity.__typename === EntityType.CoursePage || + entity.taxonomyTermIds.includes(taxonomyTermId) + ) { + continue + } + + const { lastPosition } = await database.fetchOne<{ + lastPosition: number + }>( + ` + SELECT IFNULL(MAX(position), 0) as lastPosition + FROM term_taxonomy_entity + WHERE term_taxonomy_id = ?`, + [taxonomyTermId], + ) + + await database.mutate( + ` + insert into term_taxonomy_entity (entity_id, term_taxonomy_id, position) + values (?,?,?) + `, + [entity.id, taxonomyTermId, lastPosition + 1], + ) + } + + await transaction.commit() + + await UuidResolver.removeCacheEntries( + entityIds.map((id) => ({ id })), + context, + ) + await UuidResolver.removeCacheEntry({ id: taxonomyTermId }, context) + + return { success: true, query: {} } + } finally { + await transaction.rollback() + } }, async deleteEntityLinks(_parent, { input }, context) { - const { dataSources, userId } = context + const { database, userId } = context assertUserIsAuthenticated(userId) const { entityIds, taxonomyTermId } = input - const scope = await fetchScopeOfUuid({ id: taxonomyTermId }, context) + const entities = await Promise.all( + entityIds.map((id) => UuidResolver.resolve({ id }, context)), + ) + const taxonomyTerm = await UuidResolver.resolveWithDecoder( + TaxonomyTermDecoder, + { id: taxonomyTermId }, + context, + ) + + if ( + entities.some( + (entity) => + !EntityDecoder.is(entity) || + entity.__typename === EntityType.CoursePage || + entity.taxonomyTermIds.length <= 1, + ) + ) { + throw new UserInputError( + 'All children must be entities (beside course pages) and must have more than one parent', + ) + } await assertUserIsAuthorized({ message: 'You are not allowed to unlink entities from this taxonomy term.', - guard: serloAuth.TaxonomyTerm.change(scope), + guard: serloAuth.TaxonomyTerm.change( + serloAuth.instanceToScope(taxonomyTerm.instance), + ), context, }) - const { success } = - await dataSources.model.serlo.unlinkEntitiesFromTaxonomy({ - entityIds, - taxonomyTermId, - userId, - }) - - return { success, query: {} } + const transaction = await database.beginTransaction() + + try { + for (const entityId of entityIds) { + await database.mutate( + ` + delete from term_taxonomy_entity + where entity_id = ? and term_taxonomy_id = ? + `, + [entityId, taxonomyTermId], + ) + + await createEvent( + { + __typename: NotificationEventType.RemoveEntityLink, + actorId: userId, + instance: taxonomyTerm.instance, + parentId: taxonomyTermId, + childId: entityId, + }, + context, + ) + } + + await transaction.commit() + + await UuidResolver.removeCacheEntries( + entityIds.map((id) => ({ id })), + context, + ) + await UuidResolver.removeCacheEntry({ id: taxonomyTermId }, context) + + return { success: true, query: {} } + } finally { + await transaction.rollback() + } }, async sort(_parent, { input }, context) { - const { dataSources, userId } = context + const { database, userId } = context assertUserIsAuthenticated(userId) const { childrenIds, taxonomyTermId } = input @@ -178,45 +395,103 @@ export const resolvers: Resolvers = { context, }) - // Provisory solution, See https://github.com/serlo/serlo.org-database-layer/issues/303 - const allChildrenIds = [ - ...new Set(childrenIds.concat(taxonomyTerm.childrenIds)), - ] - - const { success } = await dataSources.model.serlo.sortTaxonomyTerm({ - childrenIds: allChildrenIds, - taxonomyTermId, - userId, - }) + if ( + childrenIds.some( + (childId) => !taxonomyTerm.childrenIds.includes(childId), + ) + ) { + throw new UserInputError( + 'children_ids have to be a subset of children entities and taxonomy terms of the given taxonomy term', + ) + } - return { success, query: {} } + const transaction = await database.beginTransaction() + + try { + await Promise.all( + childrenIds.map(async (childId, position) => { + // Since the id of entities and taxonomies is always different + // we do not need to distinguish between them + + await database.mutate( + 'update term_taxonomy set weight = ? where parent_id = ? and id = ?', + [position, taxonomyTermId, childId], + ) + + await database.mutate( + 'update term_taxonomy_entity set position = ? where term_taxonomy_id = ? and entity_id = ?', + [position, taxonomyTermId, childId], + ) + }), + ) + + await UuidResolver.removeCacheEntry({ id: taxonomyTermId }, context) + await createEvent( + { + __typename: NotificationEventType.SetTaxonomyTerm, + taxonomyTermId, + actorId: userId, + instance: taxonomyTerm.instance, + }, + context, + ) + + await transaction.commit() + + return { success: true, query: {} } + } finally { + await transaction.rollback() + } }, async setNameAndDescription(_parent, { input }, context) { - const { dataSources, userId } = context + const { database, userId } = context assertUserIsAuthenticated(userId) - const { id, name, description = null } = input + const { name } = input assertStringIsNotEmpty({ name }) - const scope = await fetchScopeOfUuid({ id }, context) + const taxonomyTerm = await UuidResolver.resolve(input, context) + + if ( + taxonomyTerm == null || + taxonomyTerm.__typename !== DiscriminatorType.TaxonomyTerm + ) { + throw new UserInputError(`Taxonomy term ${input.id} does not exists`) + } await assertUserIsAuthorized({ message: 'You are not allowed to set name or description of this taxonomy term.', - guard: serloAuth.TaxonomyTerm.set(scope), + guard: serloAuth.TaxonomyTerm.set( + serloAuth.instanceToScope(taxonomyTerm.instance), + ), context, }) - const { success } = - await dataSources.model.serlo.setTaxonomyTermNameAndDescription({ - id, - name, - description, - userId, - }) + await database.mutate( + ` + UPDATE term + JOIN term_taxonomy ON term.id = term_taxonomy.term_id + SET term.name = ?, + term_taxonomy.description = ? + WHERE term_taxonomy.id = ?; + `, + [input.name, input.description, input.id], + ) + + await createEvent( + { + __typename: NotificationEventType.SetTaxonomyTerm, + taxonomyTermId: input.id, + actorId: userId, + instance: taxonomyTerm.instance, + }, + context, + ) + await UuidResolver.removeCacheEntry(input, context) - return { success, query: {} } + return { success: true, query: {} } }, }, } diff --git a/packages/server/src/schema/uuid/taxonomy-term/types.graphql b/packages/server/src/schema/uuid/taxonomy-term/types.graphql index d6224bd6d..426e4bca8 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/types.graphql +++ b/packages/server/src/schema/uuid/taxonomy-term/types.graphql @@ -32,7 +32,7 @@ extend type Mutation { } type TaxonomyTermMutation { - create(input: TaxonomyTermCreateInput!): DefaultResponse! + create(input: TaxonomyTermCreateInput!): TaxonomyTermCreateResponse! createEntityLinks(input: TaxonomyEntityLinksInput!): DefaultResponse! deleteEntityLinks(input: TaxonomyEntityLinksInput!): DefaultResponse! sort(input: TaxonomyTermSortInput!): DefaultResponse! @@ -68,3 +68,9 @@ input TaxonomyTermSetNameAndDescriptionInput { name: String! description: String } + +type TaxonomyTermCreateResponse { + success: Boolean! + record: TaxonomyTerm + query: Query! +} diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index e31d020eb..903368307 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -15,7 +15,7 @@ import { consumeErrorEvent, ErrorEvent, } from '~/error-event' -import { UserInputError } from '~/errors' +import { ForbiddenError, UserInputError } from '~/errors' import { assertUserIsAuthenticated, assertUserIsAuthorized, @@ -398,7 +398,7 @@ export const resolvers: Resolvers = { }, async deleteRegularUser(_parent, { input }, context) { - const { dataSources, authServices, userId } = context + const { database, authServices, userId } = context assertUserIsAuthenticated(userId) await assertUserIsAuthorized({ guard: serloAuth.User.deleteRegularUser(serloAuth.Scope.Serlo), @@ -408,19 +408,56 @@ export const resolvers: Resolvers = { const { id, username } = input const user = await UuidResolver.resolve({ id: input.id }, context) + const idUserDeleted = 4 if (!UserDecoder.is(user) || user.username !== username) { throw new UserInputError( '`id` does not belong to a user or `username` does not match the `user`', ) } + if (id === idUserDeleted) { + throw new ForbiddenError('You cannot delete the user Deleted.') + } - const result = await dataSources.model.serlo.deleteRegularUsers({ - userId: id, - }) + const transaction = await database.beginTransaction() + try { + await Promise.all([ + database.mutate( + 'UPDATE comment SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ), + database.mutate( + 'UPDATE entity_revision SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ), + database.mutate( + 'UPDATE event_log SET actor_id = ? WHERE actor_id = ?', + [idUserDeleted, id], + ), + database.mutate( + 'UPDATE page_revision SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ), + database.mutate('DELETE FROM notification WHERE user_id = ?', [id]), + database.mutate('DELETE FROM role_user WHERE user_id = ?', [id]), + database.mutate('DELETE FROM subscription WHERE user_id = ?', [id]), + database.mutate('DELETE FROM subscription WHERE uuid_id = ?', [id]), + database.mutate( + "DELETE FROM uuid WHERE id = ? and discriminator = 'user'", + [id], + ), + ]) + + await UuidResolver.removeCacheEntry({ id }, context) + + await deleteKratosUser(id, authServices) - if (result.success) await deleteKratosUser(id, authServices) - return { success: result.success, query: {} } + await transaction.commit() + } finally { + await transaction.rollback() + } + + return { success: true, query: {} } }, async removeRole(_parent, { input }, context) { @@ -470,7 +507,7 @@ export const resolvers: Resolvers = { if (input.description.length >= 64 * 1024) { throw new UserInputError('description too long') } - await database.mutate('update user set description = ? where id = ?', [ + await database.mutate('UPDATE user SET description = ? WHERE id = ?', [ input.description, userId, ]) @@ -479,17 +516,18 @@ export const resolvers: Resolvers = { }, async setEmail(_parent, { input }, context) { - const { dataSources, userId } = context + const { database, userId } = context assertUserIsAuthenticated(userId) await assertUserIsAuthorized({ guard: serloAuth.User.setEmail(serloAuth.Scope.Serlo), message: 'You are not allowed to change the E-mail address for a user', context, }) - - const result = await dataSources.model.serlo.setEmail(input) - - return { ...result, query: {} } + await database.mutate('UPDATE user SET email = ? WHERE id = ?', [ + input.email, + userId, + ]) + return { success: true, query: {} } }, }, } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 4313733b8..a9af71b47 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -1446,9 +1446,16 @@ export type TaxonomyTermCreateInput = { taxonomyType: TaxonomyTypeCreateOptions; }; +export type TaxonomyTermCreateResponse = { + __typename?: 'TaxonomyTermCreateResponse'; + query: Query; + record?: Maybe; + success: Scalars['Boolean']['output']; +}; + export type TaxonomyTermMutation = { __typename?: 'TaxonomyTermMutation'; - create: DefaultResponse; + create: TaxonomyTermCreateResponse; createEntityLinks: DefaultResponse; deleteEntityLinks: DefaultResponse; setNameAndDescription: DefaultResponse; @@ -2087,6 +2094,7 @@ export type ResolversTypes = { TaxonomyTerm: ResolverTypeWrapper>; TaxonomyTermConnection: ResolverTypeWrapper>; TaxonomyTermCreateInput: ResolverTypeWrapper>; + TaxonomyTermCreateResponse: ResolverTypeWrapper>; TaxonomyTermMutation: ResolverTypeWrapper>; TaxonomyTermSetNameAndDescriptionInput: ResolverTypeWrapper>; TaxonomyTermSortInput: ResolverTypeWrapper>; @@ -2234,6 +2242,7 @@ export type ResolversParentTypes = { TaxonomyTerm: ModelOf; TaxonomyTermConnection: ModelOf; TaxonomyTermCreateInput: ModelOf; + TaxonomyTermCreateResponse: ModelOf; TaxonomyTermMutation: ModelOf; TaxonomyTermSetNameAndDescriptionInput: ModelOf; TaxonomyTermSortInput: ModelOf; @@ -3135,8 +3144,15 @@ export type TaxonomyTermConnectionResolvers; }; +export type TaxonomyTermCreateResponseResolvers = { + query?: Resolver; + record?: Resolver, ParentType, ContextType>; + success?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type TaxonomyTermMutationResolvers = { - create?: Resolver>; + create?: Resolver>; createEntityLinks?: Resolver>; deleteEntityLinks?: Resolver>; setNameAndDescription?: Resolver>; @@ -3387,6 +3403,7 @@ export type Resolvers = { SubscriptionQuery?: SubscriptionQueryResolvers; TaxonomyTerm?: TaxonomyTermResolvers; TaxonomyTermConnection?: TaxonomyTermConnectionResolvers; + TaxonomyTermCreateResponse?: TaxonomyTermCreateResponseResolvers; TaxonomyTermMutation?: TaxonomyTermMutationResolvers; Thread?: ThreadResolvers; ThreadAware?: ThreadAwareResolvers; diff --git a/scripts/build.ts b/scripts/build.ts index f30ca3a14..c1a3010da 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -48,7 +48,7 @@ export function getEsbuildOptions(source: string, outfile: string) { // type of "undefined". // // We rather install it seperately. - external: ['bee-queue'], + external: ['bee-queue', 'bull-arena'], outfile, plugins: [graphqlLoaderPlugin()], } as esbuild.BuildOptions diff --git a/yarn.lock b/yarn.lock index 6fddb9be5..91946e46f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16957,13 +16957,11 @@ __metadata: linkType: hard "semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0": - version: 7.6.0 - resolution: "semver@npm:7.6.0" - dependencies: - lru-cache: ^6.0.0 + version: 7.6.1 + resolution: "semver@npm:7.6.1" bin: semver: bin/semver.js - checksum: 7427f05b70786c696640edc29fdd4bc33b2acf3bbe1740b955029044f80575fc664e1a512e4113c3af21e767154a94b4aa214bf6cd6e42a1f6dba5914e0b208c + checksum: 2c9c89b985230c0fcf02c96ae6a3ca40c474f2f4e838634394691e6e10c347a0c6def0f14fc355d82f90f1744a073b8b9c45457b108aa728280b5d68ed7961cd languageName: node linkType: hard