From 0a8cea60723801a70a7fc057404b94a08c4ea295 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Fri, 3 May 2024 20:46:36 +0200 Subject: [PATCH 01/69] refactor(uuid): Prepare uuid query to support other types as well --- .../schema/uuid/abstract-uuid/resolvers.ts | 83 +++++++++++-------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index ccad71bf3..d9220ded0 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -134,21 +134,27 @@ 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 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: t.array(t.union([t.number, t.null])), + }), +]) + async function resolveUuidFromDatabase( { id }: { id: number }, context: Pick, @@ -158,15 +164,15 @@ 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_status.name as status + 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_ARRAYAGG(comment_children.id) as commentChildrenIds, + comment_status.name as commentStatus from uuid left join comment on comment.id = uuid.id left join comment comment_children on comment_children.parent_id = comment.id @@ -177,21 +183,28 @@ async function resolveUuidFromDatabase( [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: baseUuid.commentChildrenIds.filter(isDefined), + date: baseUuid.commentDate.toISOString(), + title: baseUuid.commentTitle, + authorId: baseUuid.commentAuthorId, + content: baseUuid.commentContent, + } } } From f3615ef515e8c04b28508027962a7833493e90a0 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Fri, 3 May 2024 23:47:59 +0200 Subject: [PATCH 02/69] test: Make MSW to bypass to DB-Layer --- jest.setup.ts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/jest.setup.ts b/jest.setup.ts index ff00d7973..d339526ea 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -32,18 +32,7 @@ beforeAll(() => { global.timer = timer global.kratos = kratos - global.server.listen({ - async onUnhandledRequest(req) { - // eslint-disable-next-line no-console - console.error( - 'Found an unhandled %s request to %s with body %s', - req.method, - req.url, - await req.text(), - ) - return 'error' - }, - }) + global.server.listen({ onUnhandledRequest: 'bypass' }) process.env.OPENAI_API_KEY = 'fake-test-key-we-are-mocking-responses' }) From 268244c2aef7245241d3e2c5af40c5b5eba04e6e Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Fri, 3 May 2024 23:56:22 +0200 Subject: [PATCH 03/69] refactor: Move taxonomy to uuid query --- __tests__/internals/create-event.ts | 10 +- __tests__/schema/entity/set.ts | 7 +- __tests__/schema/subject.ts | 2 +- .../taxonomy-term/create-entity-links.ts | 3 +- __tests__/schema/taxonomy-term/create.ts | 3 +- .../taxonomy-term/delete-entity-links.ts | 3 +- .../taxonomy-term/set-name-and-description.ts | 3 +- __tests__/schema/taxonomy-term/sort.ts | 6 +- __tests__/schema/uuid/abstract-uuid.ts | 10 +- __tests__/schema/uuid/taxonomy-term.ts | 449 +++++------------- __tests__/schema/uuid/user.ts | 6 +- packages/server/src/cached-resolver.ts | 2 +- packages/server/src/database.ts | 4 +- packages/server/src/schema/index.ts | 5 +- .../server/src/schema/subject/resolvers.ts | 67 +++ .../schema/uuid/abstract-uuid/resolvers.ts | 126 ++++- 16 files changed, 336 insertions(+), 370 deletions(-) diff --git a/__tests__/internals/create-event.ts b/__tests__/internals/create-event.ts index 0e73a9082..48ca67a69 100644 --- a/__tests__/internals/create-event.ts +++ b/__tests__/internals/create-event.ts @@ -162,15 +162,13 @@ async function getLastEvent() { limit 1 `) - return toGraphQLModel(lastAbstractEvent) + return toGraphQLModel(lastAbstractEvent!) } async function getEventsNumber() { - return ( - await global.database.fetchOne<{ n: number }>( - 'SELECT count(*) AS n FROM event_log', - ) - ).n + return (await global.database.fetchOne<{ n: number }>( + 'SELECT count(*) AS n FROM event_log', + ))!.n } function getContext() { 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 9998521bc..ac1420e2b 100644 --- a/__tests__/schema/subject.ts +++ b/__tests__/schema/subject.ts @@ -30,7 +30,7 @@ test('endpoint "subjects" returns list of all subjects for an instance', async ( .withVariables({ instance: taxonomyTermSubject.instance }) .shouldReturnData({ subject: { - subjects: [{ taxonomyTerm: { name: taxonomyTermSubject.name } }], + subjects: [{ taxonomyTerm: { name: 'Mathe' } }], }, }) }) diff --git a/__tests__/schema/taxonomy-term/create-entity-links.ts b/__tests__/schema/taxonomy-term/create-entity-links.ts index ecc2fa159..dfd1dd4b0 100644 --- a/__tests__/schema/taxonomy-term/create-entity-links.ts +++ b/__tests__/schema/taxonomy-term/create-entity-links.ts @@ -70,7 +70,8 @@ test('returns { success, record } when mutation could be successfully executed', }) }) -test('updates the cache', async () => { +// Undo once the mutation is migrated +test.skip('updates the cache', async () => { const childQuery = new Client({ userId: user.id }) .prepareQuery({ query: gql` diff --git a/__tests__/schema/taxonomy-term/create.ts b/__tests__/schema/taxonomy-term/create.ts index 75bd195df..f0ae13863 100644 --- a/__tests__/schema/taxonomy-term/create.ts +++ b/__tests__/schema/taxonomy-term/create.ts @@ -56,7 +56,8 @@ describe('TaxonomyTermCreateMutation', () => { }) }) - test('updates the cache', async () => { + // Update once the migration is done + test.skip('updates the cache', async () => { given('UuidQuery').for(taxonomyTermCurriculumTopic) given('TaxonomyTermCreateMutation') diff --git a/__tests__/schema/taxonomy-term/delete-entity-links.ts b/__tests__/schema/taxonomy-term/delete-entity-links.ts index 31694462c..db9a58653 100644 --- a/__tests__/schema/taxonomy-term/delete-entity-links.ts +++ b/__tests__/schema/taxonomy-term/delete-entity-links.ts @@ -57,7 +57,8 @@ test('returns { success, record } when mutation could be successfully executed', }) }) -test('updates the cache', async () => { +// Needs update of mutation first +test.skip('updates the cache', async () => { const childQuery = new Client({ userId: user.id }) .prepareQuery({ query: gql` diff --git a/__tests__/schema/taxonomy-term/set-name-and-description.ts b/__tests__/schema/taxonomy-term/set-name-and-description.ts index f5cec2622..05375a782 100644 --- a/__tests__/schema/taxonomy-term/set-name-and-description.ts +++ b/__tests__/schema/taxonomy-term/set-name-and-description.ts @@ -72,7 +72,8 @@ describe('TaxonomyTermSetNameAndDescriptionMutation', () => { await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') }) - test('updates the cache', async () => { + // Todo: Needs update of mutation first + test.skip('updates the cache', async () => { const query = new Client({ userId: user.id }) .prepareQuery({ query: gql` diff --git a/__tests__/schema/taxonomy-term/sort.ts b/__tests__/schema/taxonomy-term/sort.ts index 6498aa0b4..4f06e458d 100644 --- a/__tests__/schema/taxonomy-term/sort.ts +++ b/__tests__/schema/taxonomy-term/sort.ts @@ -55,13 +55,13 @@ beforeEach(() => { }) }) -test('returns "{ success: true }" when mutation could be successfully executed', async () => { +test.skip('returns "{ success: true }" when mutation could be successfully executed', async () => { await mutation.shouldReturnData({ taxonomyTerm: { sort: { success: true } }, }) }) -test('is successful even though user have not sent all children ids', async () => { +test.skip('is successful even though user have not sent all children ids', async () => { await mutation .withInput({ ...input, childrenIds: [1394, 23453] }) .shouldReturnData({ @@ -89,7 +89,7 @@ test('fails when database layer has an internal error', async () => { await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') }) -test('updates the cache', async () => { +test.skip('updates the cache', async () => { given('UuidQuery').for( { ...article, id: 1394 }, { ...taxonomyTermSubject, id: 23453 }, 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/taxonomy-term.ts b/__tests__/schema/uuid/taxonomy-term.ts index db67aadec..69d676d18 100644 --- a/__tests__/schema/uuid/taxonomy-term.ts +++ b/__tests__/schema/uuid/taxonomy-term.ts @@ -1,346 +1,135 @@ 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 - } - } +import { Client } from '../../__utils__' + +const query = 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 } - `, - }) - .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 - } - } - } + parent { + id } - `, - }) - .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 - } - } + children { + nodes { + id } } - `, - }) - .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, - }, - }, - }) +test('TaxonomyTerm root', async () => { + await query.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 query.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 query.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/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 3f4530c57..228842308 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -104,10 +104,10 @@ export class Database { public async fetchOne( sql: string, params?: unknown[], - ): Promise { + ): Promise { const [result] = await this.execute<(T & RowDataPacket)[]>(sql, params) - return result + return result ?? null } public async mutate( 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 2b7d7a889..e3bd1bf01 100644 --- a/packages/server/src/schema/subject/resolvers.ts +++ b/packages/server/src/schema/subject/resolvers.ts @@ -1,5 +1,9 @@ +import { option as O } from 'fp-ts' +import * as t from 'io-ts' + import { resolveConnection } from '../connection/utils' import { UuidResolver } from '../uuid/abstract-uuid/resolvers' +import { createCachedResolver } from '~/cached-resolver' import { createNamespace } from '~/internals/graphql' import { EntityDecoder, TaxonomyTermDecoder } from '~/model/decoder' import { encodeSubjectId } from '~/schema/subject/utils' @@ -46,3 +50,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.fetchOne( + ` + 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/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index d9220ded0..992da9636 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' @@ -20,10 +21,12 @@ import { EntityTypeDecoder, EntityRevisionTypeDecoder, CommentStatusDecoder, + InstanceDecoder, } from '~/model/decoder' import { fetchScopeOfUuid } from '~/schema/authorization/utils' +import { SubjectResolver } from '~/schema/subject/resolvers' import { decodePath, encodePath } from '~/schema/uuid/alias/utils' -import { Resolvers, QueryUuidArgs } from '~/types' +import { Resolvers, QueryUuidArgs, TaxonomyTermType } from '~/types' import { isDefined } from '~/utils' export const UuidResolver = createCachedResolver< @@ -139,6 +142,11 @@ const BaseUuid = t.type({ trashed: Tinyint, }) +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({ @@ -151,19 +159,37 @@ const BaseComment = t.intersection([ commentParentUuid: t.union([t.number, t.null]), commentParentCommentId: t.union([t.number, t.null]), commentStatus: t.union([CommentStatusDecoder, t.null]), - commentChildrenIds: t.array(t.union([t.number, 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, }), ]) async function resolveUuidFromDatabase( { id }: { id: number }, - context: Pick, + context: Pick, ): Promise | null> { const baseUuid = await context.database.fetchOne( - ` select + ` + select uuid.id as id, uuid.trashed, uuid.discriminator, + comment.author_id as commentAuthorId, comment.title as commentTitle, comment.date as commentDate, @@ -171,12 +197,41 @@ async function resolveUuidFromDatabase( comment.content as commentContent, comment.parent_id as commentParentCommentId, comment.uuid_id as commentParentUuid, - JSON_ARRAYAGG(comment_children.id) as commentChildrenIds, - comment_status.name as commentStatus + JSON_OBJECTAGG( + COALESCE(comment_children.id, "__no_key"), + comment_children.id + ) as commentChildrenIds, + comment_status.name 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 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.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 + where uuid.id = ? group by uuid.id `, @@ -199,12 +254,38 @@ async function resolveUuidFromDatabase( parentId, alias: `/${parentId}#comment-${baseUuid.id}`, status: baseUuid.commentStatus ?? 'noStatus', - childrenIds: baseUuid.commentChildrenIds.filter(isDefined), + 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, + } } } @@ -213,6 +294,27 @@ async function resolveUuidFromDatabase( return UuidDecoder.is(uuidFromDBLayer) ? uuidFromDBLayer : null } +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, @@ -258,3 +360,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 +} From c5c839b0a4c35d613b4e17255577c487fc183302 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 01:03:14 +0200 Subject: [PATCH 04/69] refactor(taxonomy): Add SQl for TaxonomySetNameAndDescription --- .../taxonomy-term/set-name-and-description.ts | 154 ++++++------------ packages/server/src/model/database-layer.ts | 10 -- packages/server/src/model/serlo.ts | 21 --- .../schema/uuid/taxonomy-term/resolvers.ts | 30 ++-- 4 files changed, 68 insertions(+), 147 deletions(-) diff --git a/__tests__/schema/taxonomy-term/set-name-and-description.ts b/__tests__/schema/taxonomy-term/set-name-and-description.ts index 05375a782..7f6cd9db3 100644 --- a/__tests__/schema/taxonomy-term/set-name-and-description.ts +++ b/__tests__/schema/taxonomy-term/set-name-and-description.ts @@ -1,124 +1,66 @@ 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 - } +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 } }, - }) - }) - - 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 `name` is empty', async () => { - await mutation - .withInput({ ...input, name: '' }) - .shouldFailWithError('BAD_USER_INPUT') + } + `, }) + .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 database layer returns a 400er response', async () => { - given('TaxonomyTermSetNameAndDescriptionMutation').returnsBadRequest() +test('updates name and description', async () => { + await query.shouldReturnData({ uuid: { name: 'Mathe', description: null } }) - await mutation.shouldFailWithError('BAD_USER_INPUT') + await mutation.shouldReturnData({ + taxonomyTerm: { setNameAndDescription: { success: true } }, }) - test('fails when database layer has an internal error', async () => { - given('TaxonomyTermSetNameAndDescriptionMutation').hasInternalServerError() - - await mutation.shouldFailWithError('INTERNAL_SERVER_ERROR') + await query.shouldReturnData({ + uuid: { name: input.name, description: input.description }, }) +}) - // Todo: Needs update of mutation first - test.skip('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/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index c58dfa0e9..621a9bae5 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -246,16 +246,6 @@ export const spec = { 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, diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index e8cabb5f1..c3860cc5a 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -718,26 +718,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'), @@ -797,7 +777,6 @@ export function createSerloModel({ setEmail, setEntityLicense, setSubscription, - setTaxonomyTermNameAndDescription, setThreadStatus, sortEntity, sortTaxonomyTerm, diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index 2b9bfe7e9..a1818c770 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts +++ b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts @@ -16,6 +16,7 @@ import { createThreadResolvers } from '~/schema/thread/utils' import { createUuidResolvers } from '~/schema/uuid/abstract-uuid/utils' import { TaxonomyTermType, TaxonomyTypeCreateOptions, Resolvers } from '~/types' import { isDefined } from '~/utils' +import { UserInputError } from '~/errors' const typesMap = { root: TaxonomyTermType.Root, @@ -192,10 +193,10 @@ export const resolvers: Resolvers = { return { success, query: {} } }, async setNameAndDescription(_parent, { input }, context) { - const { dataSources, userId } = context + const { database, userId } = context assertUserIsAuthenticated(userId) - const { id, name, description = null } = input + const { id, name } = input assertStringIsNotEmpty({ name }) @@ -208,15 +209,24 @@ export const resolvers: Resolvers = { context, }) - const { success } = - await dataSources.model.serlo.setTaxonomyTermNameAndDescription({ - id, - name, - description, - userId, - }) + const { affectedRows } = 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], + ) - return { success, query: {} } + if (affectedRows === 0) { + throw new UserInputError(`Taxonomy term ${input.id} does not exists`) + } + + await UuidResolver.removeCacheEntry(input, context) + + return { success: true, query: {} } }, }, } From f4728dbaa19fcf92d9b8feb62a26fe0969c4fccc Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 01:15:49 +0200 Subject: [PATCH 05/69] Fix lint errors --- __tests__/schema/taxonomy-term/set-name-and-description.ts | 3 +-- packages/server/src/schema/uuid/taxonomy-term/resolvers.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/__tests__/schema/taxonomy-term/set-name-and-description.ts b/__tests__/schema/taxonomy-term/set-name-and-description.ts index 7f6cd9db3..08d9d4a8d 100644 --- a/__tests__/schema/taxonomy-term/set-name-and-description.ts +++ b/__tests__/schema/taxonomy-term/set-name-and-description.ts @@ -1,7 +1,6 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' -import { Client, given } from '../../__utils__' +import { Client } from '../../__utils__' const input = { description: 'a description', diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index a1818c770..8c0c10197 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 { UserInputError } from '~/errors' import { createNamespace, assertUserIsAuthenticated, @@ -16,7 +17,6 @@ import { createThreadResolvers } from '~/schema/thread/utils' import { createUuidResolvers } from '~/schema/uuid/abstract-uuid/utils' import { TaxonomyTermType, TaxonomyTypeCreateOptions, Resolvers } from '~/types' import { isDefined } from '~/utils' -import { UserInputError } from '~/errors' const typesMap = { root: TaxonomyTermType.Root, From f23036e2d33e4687c2ae9afd1ed3ab904488a88c Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 09:15:31 +0200 Subject: [PATCH 06/69] fix(taxonomy): Add event when setting name and description --- __tests__/__utils__/assertions.ts | 29 +++++++++++++++ .../taxonomy-term/set-name-and-description.ts | 7 +++- .../schema/uuid/taxonomy-term/resolvers.ts | 37 ++++++++++++++----- 3 files changed, 63 insertions(+), 10 deletions(-) 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__/schema/taxonomy-term/set-name-and-description.ts b/__tests__/schema/taxonomy-term/set-name-and-description.ts index 08d9d4a8d..092d16edf 100644 --- a/__tests__/schema/taxonomy-term/set-name-and-description.ts +++ b/__tests__/schema/taxonomy-term/set-name-and-description.ts @@ -1,6 +1,7 @@ import gql from 'graphql-tag' -import { Client } from '../../__utils__' +import { Client, expectEvent } from '../../__utils__' +import { NotificationEventType } from '~/model/decoder' const input = { description: 'a description', @@ -46,6 +47,10 @@ test('updates name and description', async () => { await query.shouldReturnData({ uuid: { name: input.name, description: input.description }, }) + await expectEvent({ + __typename: NotificationEventType.SetTaxonomyTerm, + objectId: input.id, + }) }) test('fails when user is not authenticated', async () => { diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index 8c0c10197..72f607a9a 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts +++ b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts @@ -10,9 +10,14 @@ import { assertStringIsNotEmpty, Model, } from '~/internals/graphql' -import { TaxonomyTermDecoder } from '~/model/decoder' +import { + DiscriminatorType, + NotificationEventType, + TaxonomyTermDecoder, +} from '~/model/decoder' import { fetchScopeOfUuid } from '~/schema/authorization/utils' 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' @@ -196,20 +201,29 @@ export const resolvers: Resolvers = { const { database, userId } = context assertUserIsAuthenticated(userId) - const { id, name } = 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 { affectedRows } = await database.mutate( + await database.mutate( ` UPDATE term JOIN term_taxonomy ON term.id = term_taxonomy.term_id @@ -220,10 +234,15 @@ export const resolvers: Resolvers = { [input.name, input.description, input.id], ) - if (affectedRows === 0) { - throw new UserInputError(`Taxonomy term ${input.id} does not exists`) - } - + await createEvent( + { + __typename: NotificationEventType.SetTaxonomyTerm, + taxonomyTermId: input.id, + actorId: userId, + instance: taxonomyTerm.instance, + }, + context, + ) await UuidResolver.removeCacheEntry(input, context) return { success: true, query: {} } From 47c81373887b20cac0d950929d16ac1f81cd9080 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 16:10:17 +0200 Subject: [PATCH 07/69] chore: Enable source-maps in "yarn start" --- scripts/build.ts | 1 + scripts/start.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/build.ts b/scripts/build.ts index e3bcab8c6..f30ca3a14 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -41,6 +41,7 @@ export function getEsbuildOptions(source: string, outfile: string) { platform: 'node', format: 'cjs', target: 'node18', + sourcemap: 'linked', // Bundling `bee-queue` inside the run package would result in an error // // Error: node_redis: The EVALSHA command contains a invalid argument diff --git a/scripts/start.ts b/scripts/start.ts index ac7c812fa..1169f1bfc 100644 --- a/scripts/start.ts +++ b/scripts/start.ts @@ -28,7 +28,9 @@ async function main() { if (serverProcess) serverProcess.kill() - serverProcess = spawn('node', [outfile], { stdio: 'inherit' }) + serverProcess = spawn('node', ['--enable-source-maps', outfile], { + stdio: 'inherit', + }) }) }, } From 61c624ff56373d21f09c8200c5373a613249b17b Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 16:16:01 +0200 Subject: [PATCH 08/69] refactor(database): Add fetchOptional() + better transaction handling --- __tests__/internals/create-event.ts | 10 ++-- packages/server/src/database.ts | 52 +++++++++++++------ packages/server/src/errors.ts | 6 +++ packages/server/src/schema/events/event.ts | 11 ++-- .../server/src/schema/subject/resolvers.ts | 2 +- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/__tests__/internals/create-event.ts b/__tests__/internals/create-event.ts index 48ca67a69..0e73a9082 100644 --- a/__tests__/internals/create-event.ts +++ b/__tests__/internals/create-event.ts @@ -162,13 +162,15 @@ async function getLastEvent() { limit 1 `) - return toGraphQLModel(lastAbstractEvent!) + return toGraphQLModel(lastAbstractEvent) } async function getEventsNumber() { - return (await global.database.fetchOne<{ n: number }>( - 'SELECT count(*) AS n FROM event_log', - ))!.n + return ( + await global.database.fetchOne<{ n: number }>( + 'SELECT count(*) AS n FROM event_log', + ) + ).n } function getContext() { diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 228842308..87c32b6b3 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -5,6 +5,8 @@ import { type ResultSetHeader, } from 'mysql2/promise' +import { InternalServerError } from './errors' + export class Database { private state: DatabaseState private pool: Pool @@ -29,9 +31,26 @@ export class Database { this.state = { type: 'InsideSavepoint', transaction, depth: newDepth } } + + let isComittedOrRollbacked = false + + return { + commit: async () => { + if (!isComittedOrRollbacked) { + await this.commitLastTransaction() + isComittedOrRollbacked = true + } + }, + rollback: async () => { + if (!isComittedOrRollbacked) { + await this.rollbackLastTransaction() + isComittedOrRollbacked = true + } + }, + } } - public async commitLastTransaction() { + private async commitLastTransaction() { if (this.state.type === 'OutsideOfTransaction') return const { transaction } = this.state @@ -53,13 +72,16 @@ export class Database { } } - public async rollbackLastTransaction() { + private async rollbackLastTransaction() { if (this.state.type === 'OutsideOfTransaction') return const { transaction } = this.state if (this.state.type === 'InsideTransaction') { - await this.commitAllTransactions() + await transaction.commit() + transaction.release() + + this.state = { type: 'OutsideOfTransaction' } } else { const { depth } = this.state @@ -72,17 +94,6 @@ export class Database { } } - public async commitAllTransactions() { - if (this.state.type === 'OutsideOfTransaction') return - - const { transaction } = this.state - - await transaction.commit() - transaction.release() - - this.state = { type: 'OutsideOfTransaction' } - } - public async rollbackAllTransactions() { if (this.state.type === 'OutsideOfTransaction') return @@ -101,7 +112,7 @@ export class Database { return this.execute<(T & RowDataPacket)[]>(sql, params) } - public async fetchOne( + public async fetchOptional( sql: string, params?: unknown[], ): Promise { @@ -110,6 +121,17 @@ export class Database { return result ?? null } + public async fetchOne( + sql: string, + params?: unknown[], + ): Promise { + const result = await this.fetchOptional(sql, params) + + if (result == null) throw new InternalServerError() + + return result + } + public async mutate( sql: string, params?: unknown[], diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 289fb3fb7..bd2bbfa3e 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -18,6 +18,12 @@ export class UserInputError extends GraphQLError { } } +export class InternalServerError extends GraphQLError { + constructor(message = '') { + super(message, { extensions: { code: 'INTERNAL_SERVER_ERROR' } }) + } +} + export class InvalidCurrentValueError extends GraphQLError { constructor( public errorContext: { diff --git a/packages/server/src/schema/events/event.ts b/packages/server/src/schema/events/event.ts index bc3e8578d..4130f461e 100644 --- a/packages/server/src/schema/events/event.ts +++ b/packages/server/src/schema/events/event.ts @@ -34,9 +34,9 @@ export async function createEvent( const abstractEventPayload = toDatabaseRepresentation(payload) const { type, actorId, objectId, instance } = abstractEventPayload - try { - await database.beginTransaction() + const transaction = await database.beginTransaction() + try { const { insertId: eventId } = await database.mutate( ` INSERT INTO event_log (actor_id, event_id, uuid_id, instance_id) @@ -84,10 +84,9 @@ export async function createEvent( await createNotifications(event, { database }) - await database.commitLastTransaction() - } catch (error) { - await database.rollbackLastTransaction() - return Promise.reject(error) + await transaction.commit() + } finally { + await transaction.rollback() } } diff --git a/packages/server/src/schema/subject/resolvers.ts b/packages/server/src/schema/subject/resolvers.ts index e3bd1bf01..7be1d38c7 100644 --- a/packages/server/src/schema/subject/resolvers.ts +++ b/packages/server/src/schema/subject/resolvers.ts @@ -72,7 +72,7 @@ export const SubjectResolver = createCachedResolver({ id: number } - return await database.fetchOne( + return await database.fetchOptional( ` SELECT t.name as name, t1.id as id FROM term_taxonomy t0 From fee05baed23921b09aa8fe782af76f96bec7848d Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 16:18:25 +0200 Subject: [PATCH 09/69] refactor(taxonomy): Move SQL for creating taxonomies --- __tests__/schema/taxonomy-term/create.ts | 316 +++++++----------- __tests__/schema/uuid/taxonomy-term.ts | 8 +- packages/server/src/model/database-layer.ts | 12 - packages/server/src/model/serlo.ts | 14 - packages/server/src/model/types.ts | 5 + .../schema/uuid/abstract-uuid/resolvers.ts | 2 +- .../schema/uuid/taxonomy-term/resolvers.ts | 124 +++++-- .../schema/uuid/taxonomy-term/types.graphql | 8 +- packages/server/src/types.ts | 21 +- 9 files changed, 262 insertions(+), 248 deletions(-) diff --git a/__tests__/schema/taxonomy-term/create.ts b/__tests__/schema/taxonomy-term/create.ts index f0ae13863..f4a609daf 100644 --- a/__tests__/schema/taxonomy-term/create.ts +++ b/__tests__/schema/taxonomy-term/create.ts @@ -1,215 +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 } }, - }) - }) - - // Update once the migration is done - test.skip('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/uuid/taxonomy-term.ts b/__tests__/schema/uuid/taxonomy-term.ts index 69d676d18..4bc9b6711 100644 --- a/__tests__/schema/uuid/taxonomy-term.ts +++ b/__tests__/schema/uuid/taxonomy-term.ts @@ -2,7 +2,7 @@ import gql from 'graphql-tag' import { Client } from '../../__utils__' -const query = new Client().prepareQuery({ +export const taxonomyTermQuery = new Client().prepareQuery({ query: gql` query ($id: Int!) { uuid(id: $id) { @@ -36,7 +36,7 @@ const query = new Client().prepareQuery({ }) test('TaxonomyTerm root', async () => { - await query.withVariables({ id: 3 }).shouldReturnData({ + await taxonomyTermQuery.withVariables({ id: 3 }).shouldReturnData({ uuid: { __typename: 'TaxonomyTerm', id: 3, @@ -78,7 +78,7 @@ test('TaxonomyTerm root', async () => { }) test('TaxonomyTerm subject', async () => { - await query.withVariables({ id: 18230 }).shouldReturnData({ + await taxonomyTermQuery.withVariables({ id: 18230 }).shouldReturnData({ uuid: { __typename: 'TaxonomyTerm', id: 18230, @@ -110,7 +110,7 @@ test('TaxonomyTerm subject', async () => { }) test('TaxonomyTerm exerciseFolder', async () => { - await query.withVariables({ id: 35562 }).shouldReturnData({ + await taxonomyTermQuery.withVariables({ id: 35562 }).shouldReturnData({ uuid: { __typename: 'TaxonomyTerm', id: 35562, diff --git a/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index 621a9bae5..4f09d1548 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -11,7 +11,6 @@ import { NotificationEventDecoder, PageDecoder, SubscriptionsDecoder, - TaxonomyTermDecoder, UuidDecoder, } from './decoder' import { UserInputError } from '~/errors' @@ -226,17 +225,6 @@ export const spec = { 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), diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index c3860cc5a..5d1ae004b 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -656,19 +656,6 @@ export function createSerloModel({ }, }) - 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'), @@ -754,7 +741,6 @@ export function createSerloModel({ createComment, createEntity, createPage, - createTaxonomyTerm, createThread, deleteBots, deleteRegularUsers, 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/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 992da9636..15799ef2e 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -183,7 +183,7 @@ async function resolveUuidFromDatabase( { id }: { id: number }, context: Pick, ): Promise | null> { - const baseUuid = await context.database.fetchOne( + const baseUuid = await context.database.fetchOptional( ` select uuid.id as id, diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index 72f607a9a..9f661754f 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts +++ b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts @@ -2,7 +2,7 @@ import * as serloAuth from '@serlo/authorization' import { UuidResolver } from '../abstract-uuid/resolvers' import { Context } from '~/context' -import { UserInputError } from '~/errors' +import { InternalServerError, UserInputError } from '~/errors' import { createNamespace, assertUserIsAuthenticated, @@ -85,36 +85,118 @@ 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) { 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/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; From 40109e96e4432ead0b8eccaaaee6ed3639546ac7 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 17:21:10 +0200 Subject: [PATCH 10/69] fix(database): Use proper rollback() --- packages/server/src/database.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 87c32b6b3..4e8de767a 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -78,10 +78,7 @@ export class Database { const { transaction } = this.state if (this.state.type === 'InsideTransaction') { - await transaction.commit() - transaction.release() - - this.state = { type: 'OutsideOfTransaction' } + await this.rollbackAllTransactions() } else { const { depth } = this.state From 104c634ebb4773b199c36a17530498b66f741286 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 17:42:38 +0200 Subject: [PATCH 11/69] test(database): Close DB after tests --- jest.setup.ts | 3 ++- packages/server/src/database.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/jest.setup.ts b/jest.setup.ts index d339526ea..093dce94e 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -76,8 +76,9 @@ afterEach(async () => { await new Promise((resolve) => setImmediate(resolve)) }) -afterAll(() => { +afterAll(async () => { global.server.close() + await global.database.close() }) class MockTimer implements Timer { diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 4e8de767a..9b6607858 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -136,6 +136,10 @@ export class Database { return this.execute(sql, params) } + public async close() { + await this.pool.end() + } + private async execute( sql: string, params?: unknown[], From 9521598339b4e90c613be417d4a580fcef086b04 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 18:12:28 +0200 Subject: [PATCH 12/69] Fix lint errors --- __tests__/internals/create-event.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/__tests__/internals/create-event.ts b/__tests__/internals/create-event.ts index 48ca67a69..0e73a9082 100644 --- a/__tests__/internals/create-event.ts +++ b/__tests__/internals/create-event.ts @@ -162,13 +162,15 @@ async function getLastEvent() { limit 1 `) - return toGraphQLModel(lastAbstractEvent!) + return toGraphQLModel(lastAbstractEvent) } async function getEventsNumber() { - return (await global.database.fetchOne<{ n: number }>( - 'SELECT count(*) AS n FROM event_log', - ))!.n + return ( + await global.database.fetchOne<{ n: number }>( + 'SELECT count(*) AS n FROM event_log', + ) + ).n } function getContext() { From 88bd9cf12d58a4391e75c42311be3180ccb8e279 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 18:23:56 +0200 Subject: [PATCH 13/69] fix(subject): Use fetchOptional() --- packages/server/src/schema/subject/resolvers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/schema/subject/resolvers.ts b/packages/server/src/schema/subject/resolvers.ts index e3bd1bf01..7be1d38c7 100644 --- a/packages/server/src/schema/subject/resolvers.ts +++ b/packages/server/src/schema/subject/resolvers.ts @@ -72,7 +72,7 @@ export const SubjectResolver = createCachedResolver({ id: number } - return await database.fetchOne( + return await database.fetchOptional( ` SELECT t.name as name, t1.id as id FROM term_taxonomy t0 From 436f95f4227e1cd268e92990087a80fde6bf9984 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 17:58:42 +0200 Subject: [PATCH 14/69] chore(github): Test metadata endpoint extra --- .github/workflows/checks.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From de1e29fb0b3efe7d37e466cebbff411065f3c591 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 19:03:13 +0200 Subject: [PATCH 15/69] refactor(taxonomy): Add SQL for sort taxonomies --- __tests__/schema/taxonomy-term/sort.ts | 139 ++++++------------ packages/server/src/model/database-layer.ts | 9 -- packages/server/src/model/serlo.ts | 15 -- .../schema/uuid/taxonomy-term/resolvers.ts | 57 +++++-- 4 files changed, 87 insertions(+), 133 deletions(-) diff --git a/__tests__/schema/taxonomy-term/sort.ts b/__tests__/schema/taxonomy-term/sort.ts index 4f06e458d..9766f8605 100644 --- a/__tests__/schema/taxonomy-term/sort.ts +++ b/__tests__/schema/taxonomy-term/sort.ts @@ -1,26 +1,14 @@ 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 } from '../../__utils__' +import { taxonomyTermQuery } from '../uuid/taxonomy-term' -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 +22,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.skip('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.skip('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 +75,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.skip('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/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index 4f09d1548..13ff6350b 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -225,15 +225,6 @@ export const spec = { response: t.strict({ success: t.literal(true) }), 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, - }, ThreadCreateCommentMutation: { payload: t.type({ content: t.string, diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index 5d1ae004b..7521778eb 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -670,20 +670,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'), @@ -765,7 +751,6 @@ export function createSerloModel({ setSubscription, setThreadStatus, sortEntity, - sortTaxonomyTerm, setUuidState, unlinkEntitiesFromTaxonomy, } diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index 9f661754f..5181a3d2c 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts +++ b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts @@ -246,7 +246,7 @@ export const resolvers: Resolvers = { return { success, query: {} } }, async sort(_parent, { input }, context) { - const { dataSources, userId } = context + const { database, userId } = context assertUserIsAuthenticated(userId) const { childrenIds, taxonomyTermId } = input @@ -266,18 +266,53 @@ 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)), - ] + 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', + ) + } - const { success } = await dataSources.model.serlo.sortTaxonomyTerm({ - childrenIds: allChildrenIds, - taxonomyTermId, - userId, - }) + const transaction = await database.beginTransaction() - return { success, query: {} } + 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 { database, userId } = context From cd279a7af19ab06ec7d0bcbe448bc2c228fb26ec Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 20:48:50 +0200 Subject: [PATCH 16/69] refactor(taxonomy): Add SQL to taxonomy.deleteEntityLink() --- .../taxonomy-term/delete-entity-links.ts | 130 +++++------------- packages/server/src/model/database-layer.ts | 9 -- packages/server/src/model/serlo.ts | 20 --- .../schema/uuid/taxonomy-term/resolvers.ts | 72 ++++++++-- 4 files changed, 97 insertions(+), 134 deletions(-) diff --git a/__tests__/schema/taxonomy-term/delete-entity-links.ts b/__tests__/schema/taxonomy-term/delete-entity-links.ts index db9a58653..663bf9c9a 100644 --- a/__tests__/schema/taxonomy-term/delete-entity-links.ts +++ b/__tests__/schema/taxonomy-term/delete-entity-links.ts @@ -1,21 +1,14 @@ 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 } from '../../__utils__' +import { taxonomyTermQuery } from '../uuid/taxonomy-term' 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,86 +22,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 } }, }) -}) -// Needs update of mutation first -test.skip('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 () => { @@ -118,15 +70,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/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index 13ff6350b..40ce8fdab 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -216,15 +216,6 @@ export const spec = { 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, - }, ThreadCreateCommentMutation: { payload: t.type({ content: t.string, diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index 7521778eb..8ab4513c0 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -637,25 +637,6 @@ export function createSerloModel({ }, }) - 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 sortEntity = createMutation({ type: 'EntitySortMutation', decoder: DatabaseLayer.getDecoderFor('EntitySortMutation'), @@ -752,7 +733,6 @@ export function createSerloModel({ setThreadStatus, sortEntity, setUuidState, - unlinkEntitiesFromTaxonomy, } } diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index 5181a3d2c..854333957 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts +++ b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts @@ -12,6 +12,8 @@ import { } from '~/internals/graphql' import { DiscriminatorType, + EntityDecoder, + EntityType, NotificationEventType, TaxonomyTermDecoder, } from '~/model/decoder' @@ -222,28 +224,78 @@ export const resolvers: Resolvers = { return { success, query: {} } }, 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, - }) + const transaction = await database.beginTransaction() - return { success, query: {} } + 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 { database, userId } = context From 8cd2efca680c8a5190bd38fb9ef63b7f153fcc8b Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sat, 4 May 2024 21:39:52 +0200 Subject: [PATCH 17/69] refactor(taxonomy): Move SQL into taxonomy.createEntityLinks() --- .../taxonomy-term/create-entity-links.ts | 200 ++++++------------ packages/server/src/model/database-layer.ts | 9 - packages/server/src/model/serlo.ts | 20 -- .../schema/uuid/taxonomy-term/resolvers.ts | 97 ++++++++- 4 files changed, 152 insertions(+), 174 deletions(-) diff --git a/__tests__/schema/taxonomy-term/create-entity-links.ts b/__tests__/schema/taxonomy-term/create-entity-links.ts index dfd1dd4b0..38aded6c3 100644 --- a/__tests__/schema/taxonomy-term/create-entity-links.ts +++ b/__tests__/schema/taxonomy-term/create-entity-links.ts @@ -1,66 +1,38 @@ 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 } from '../../__utils__' +import { taxonomyTermQuery } from '../uuid/taxonomy-term' 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,85 +40,55 @@ test('returns { success, record } when mutation could be successfully executed', }, }, }) -}) -// Undo once the mutation is migrated -test.skip('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 () => { @@ -156,15 +98,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/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index 40ce8fdab..bff88d378 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -207,15 +207,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, - }, ThreadCreateCommentMutation: { payload: t.type({ content: t.string, diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index 8ab4513c0..65ed7b4a2 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -618,25 +618,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 sortEntity = createMutation({ type: 'EntitySortMutation', decoder: DatabaseLayer.getDecoderFor('EntitySortMutation'), @@ -724,7 +705,6 @@ export function createSerloModel({ getUnrevisedEntities, getUnrevisedEntitiesPerSubject, getUsersByRole, - linkEntitiesToTaxonomy, getPages, rejectEntityRevision, setEmail, diff --git a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts index 854333957..756a38d52 100644 --- a/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts +++ b/packages/server/src/schema/uuid/taxonomy-term/resolvers.ts @@ -17,7 +17,6 @@ import { NotificationEventType, TaxonomyTermDecoder, } from '~/model/decoder' -import { fetchScopeOfUuid } from '~/schema/authorization/utils' import { resolveConnection } from '~/schema/connection/utils' import { createEvent } from '~/schema/events/event' import { createThreadResolvers } from '~/schema/thread/utils' @@ -202,26 +201,104 @@ export const resolvers: Resolvers = { } }, 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, - }) + 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], + ) + } - return { success, query: {} } + 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 { database, userId } = context From fbcd5c1d57b41c5ea9259eaf8cbab258aa535477 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sun, 5 May 2024 01:19:59 +0200 Subject: [PATCH 18/69] refactor(uuid): Replace UuidSetState with SQL --- __tests__/schema/thread/set-comment-state.ts | 58 +----- __tests__/schema/thread/set-thread-state.ts | 12 +- __tests__/schema/uuid/set-state.ts | 194 ++++-------------- packages/server/src/model/database-layer.ts | 9 - packages/server/src/model/serlo.ts | 15 -- .../server/src/schema/authorization/utils.ts | 46 +++-- .../server/src/schema/thread/resolvers.ts | 87 +++++++- .../schema/uuid/abstract-uuid/resolvers.ts | 118 ++++++----- 8 files changed, 233 insertions(+), 306 deletions(-) diff --git a/__tests__/schema/thread/set-comment-state.ts b/__tests__/schema/thread/set-comment-state.ts index 0bed54c09..fbfc70920 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, comment2, 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/uuid/set-state.ts b/__tests__/schema/uuid/set-state.ts index 3c4caf896..d98be6e27 100644 --- a/__tests__/schema/uuid/set-state.ts +++ b/__tests__/schema/uuid/set-state.ts @@ -1,5 +1,4 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' import { article, @@ -7,15 +6,14 @@ import { 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 +23,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/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index bff88d378..67af023ea 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -321,15 +321,6 @@ export const spec = { 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 65ed7b4a2..f3890bf36 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', @@ -712,7 +698,6 @@ export function createSerloModel({ setSubscription, setThreadStatus, sortEntity, - setUuidState, } } diff --git a/packages/server/src/schema/authorization/utils.ts b/packages/server/src/schema/authorization/utils.ts index 1102acc61..2b6735fe4 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,21 +63,9 @@ export async function fetchScopeOfUuid( if (object === null) throw new UserInputError('UUID does not exist.') - // If the object has an instance, return the corresponding scope - if (isInstanceAware(object)) { - return instanceToScope(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 instance = await fetchInstance(object, context) - if (EntityRevisionDecoder.is(object) || PageRevisionDecoder.is(object)) { - return await fetchScopeOfUuid({ id: object.repositoryId }, context) - } - - return Scope.Serlo + return instance != null ? instanceToScope(instance) : Scope.Serlo } export async function fetchScopeOfNotificationEvent( @@ -99,6 +89,34 @@ 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 object.instance + } + + // Comments and Threads don't have an instance itself, but their object descendant has + if (object.__typename === DiscriminatorType.Comment) { + const parent = await UuidResolver.resolve({ id: object.parentId }, context) + return await fetchInstance(parent, context) + } + + if (EntityRevisionDecoder.is(object) || PageRevisionDecoder.is(object)) { + const repository = await UuidResolver.resolve( + { id: object.repositoryId }, + context, + ) + return await fetchInstance(repository, context) + } + + return null +} + function legacyRoleToRole(role: string): Model<'ScopedRole'> | null { const globalRole = legacyRoleToGlobalRole(role) if (globalRole) { diff --git a/packages/server/src/schema/thread/resolvers.ts b/packages/server/src/schema/thread/resolvers.ts index 8069913ba..37c33e960 100644 --- a/packages/server/src/schema/thread/resolvers.ts +++ b/packages/server/src/schema/thread/resolvers.ts @@ -18,15 +18,20 @@ 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' +import { createEvent } from '../events/event' export const resolvers: Resolvers = { ThreadAware: { @@ -313,7 +318,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) @@ -330,12 +335,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( @@ -363,9 +401,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 15799ef2e..c49d34fc4 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -19,15 +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, TaxonomyTermType } from '~/types' -import { isDefined } from '~/utils' export const UuidResolver = createCachedResolver< { id: number }, @@ -80,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, - }) - - await dataSources.model.serlo.setUuidState({ ids, userId, trashed }) + 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', + ) + } - return { success: true, query: {} } + 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 createEvent( + { + __typename: NotificationEventType.SetUuidState, + actorId: userId, + instance: object.instance, + objectId: object.id, + trashed, + }, + context, + ) + } + + await transaction.commit() + + await UuidResolver.removeCacheEntries( + ids.map((id) => ({ id }), context), + context, + ) + + return { success: true, query: {} } + } finally { + await transaction.rollback() + } }, }, } @@ -294,6 +310,16 @@ 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)) From f093e43c3ce81bf84c68185d7662e0b86ef4d2b9 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sun, 5 May 2024 01:25:08 +0200 Subject: [PATCH 19/69] Fix lint errors --- __tests__/schema/thread/set-comment-state.ts | 2 +- __tests__/schema/uuid/set-state.ts | 3 --- packages/server/src/schema/thread/resolvers.ts | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/__tests__/schema/thread/set-comment-state.ts b/__tests__/schema/thread/set-comment-state.ts index fbfc70920..82861940a 100644 --- a/__tests__/schema/thread/set-comment-state.ts +++ b/__tests__/schema/thread/set-comment-state.ts @@ -1,6 +1,6 @@ import gql from 'graphql-tag' -import { comment, comment2, comment3, user, user2 } from '../../../__fixtures__' +import { comment, comment3, user, user2 } from '../../../__fixtures__' import { Client } from '../../__utils__' const mutation = new Client({ userId: user.id }).prepareQuery({ diff --git a/__tests__/schema/uuid/set-state.ts b/__tests__/schema/uuid/set-state.ts index d98be6e27..f35bc261e 100644 --- a/__tests__/schema/uuid/set-state.ts +++ b/__tests__/schema/uuid/set-state.ts @@ -3,9 +3,6 @@ import gql from 'graphql-tag' import { article, page, - pageRevision, - taxonomyTermRoot, - user as baseUser, taxonomyTermSubject, user, articleRevision, diff --git a/packages/server/src/schema/thread/resolvers.ts b/packages/server/src/schema/thread/resolvers.ts index 37c33e960..595598338 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 { @@ -31,7 +32,6 @@ import { } from '~/schema/uuid/abstract-uuid/resolvers' import { createUuidResolvers } from '~/schema/uuid/abstract-uuid/utils' import { CommentStatus, Resolvers } from '~/types' -import { createEvent } from '../events/event' export const resolvers: Resolvers = { ThreadAware: { From 7a4186f4cab3b2152ba20733ed76e83e67e4b41f Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Sun, 5 May 2024 16:53:58 +0200 Subject: [PATCH 20/69] test: Move taxonomyTermQuery to __utils__ When a test file imports another one their tests are also executed when the file is called => So we move it to `__tests__/__utils__/` --- __tests__/__utils__/index.ts | 1 + __tests__/__utils__/query.ts | 36 ++++++++++++++++++ .../taxonomy-term/create-entity-links.ts | 3 +- .../taxonomy-term/delete-entity-links.ts | 3 +- __tests__/schema/taxonomy-term/sort.ts | 3 +- __tests__/schema/uuid/taxonomy-term.ts | 37 +------------------ 6 files changed, 41 insertions(+), 42 deletions(-) create mode 100644 __tests__/__utils__/query.ts 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..6433594ca --- /dev/null +++ b/__tests__/__utils__/query.ts @@ -0,0 +1,36 @@ +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 + } + } + } + } + } + `, +}) diff --git a/__tests__/schema/taxonomy-term/create-entity-links.ts b/__tests__/schema/taxonomy-term/create-entity-links.ts index 38aded6c3..2e6881782 100644 --- a/__tests__/schema/taxonomy-term/create-entity-links.ts +++ b/__tests__/schema/taxonomy-term/create-entity-links.ts @@ -1,8 +1,7 @@ import gql from 'graphql-tag' import { exercise } from '../../../__fixtures__' -import { Client } from '../../__utils__' -import { taxonomyTermQuery } from '../uuid/taxonomy-term' +import { Client, taxonomyTermQuery } from '../../__utils__' const input = { entityIds: [32321, 1855], diff --git a/__tests__/schema/taxonomy-term/delete-entity-links.ts b/__tests__/schema/taxonomy-term/delete-entity-links.ts index 663bf9c9a..1a76c3e2d 100644 --- a/__tests__/schema/taxonomy-term/delete-entity-links.ts +++ b/__tests__/schema/taxonomy-term/delete-entity-links.ts @@ -1,7 +1,6 @@ import gql from 'graphql-tag' -import { Client } from '../../__utils__' -import { taxonomyTermQuery } from '../uuid/taxonomy-term' +import { Client, taxonomyTermQuery } from '../../__utils__' const input = { entityIds: [29910, 1501], diff --git a/__tests__/schema/taxonomy-term/sort.ts b/__tests__/schema/taxonomy-term/sort.ts index 9766f8605..3c1e99d54 100644 --- a/__tests__/schema/taxonomy-term/sort.ts +++ b/__tests__/schema/taxonomy-term/sort.ts @@ -1,7 +1,6 @@ import gql from 'graphql-tag' -import { Client } from '../../__utils__' -import { taxonomyTermQuery } from '../uuid/taxonomy-term' +import { Client, taxonomyTermQuery } from '../../__utils__' const input = { childrenIds: [18888, 18887, 21069, 18884, 23256, 18232, 18885, 18886], diff --git a/__tests__/schema/uuid/taxonomy-term.ts b/__tests__/schema/uuid/taxonomy-term.ts index 4bc9b6711..5c9e680fc 100644 --- a/__tests__/schema/uuid/taxonomy-term.ts +++ b/__tests__/schema/uuid/taxonomy-term.ts @@ -1,39 +1,4 @@ -import gql from 'graphql-tag' - -import { Client } from '../../__utils__' - -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 - } - } - } - } - } - `, -}) +import { taxonomyTermQuery } from '../../__utils__' test('TaxonomyTerm root', async () => { await taxonomyTermQuery.withVariables({ id: 3 }).shouldReturnData({ From d68bced8dc398594d204687eef2985d9c4fecce7 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 13:18:56 +0200 Subject: [PATCH 21/69] fix(uuid resolver): restore rename accidentally undone during merge --- .../src/schema/uuid/abstract-uuid/resolvers.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 3e4323dd9..6c90c852b 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -190,13 +190,13 @@ async function resolveUuidFromDatabase( 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, + 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 @@ -204,7 +204,7 @@ async function resolveUuidFromDatabase( 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, From ed4d47a76a0eabf72a964ee0510e08c221153f27 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 13:27:20 +0200 Subject: [PATCH 22/69] fix(uuid resolver): add missing comma in SQL query --- packages/server/src/schema/uuid/abstract-uuid/resolvers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 6c90c852b..137d9f8b0 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -204,7 +204,7 @@ async function resolveUuidFromDatabase( CASE WHEN comment_status.name = 'no_status' THEN 'noStatus' ELSE comment_status.name - END AS commentStatus + END AS commentStatus, taxonomy_type.name as taxonomyType, taxonomy_instance.subdomain as taxonomyInstance, From da85ce2ff7b3ae61ab02f7f362e1fcce78120c48 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 13:31:36 +0200 Subject: [PATCH 23/69] style(uuid resolver): format SQL query --- .../schema/uuid/abstract-uuid/resolvers.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 137d9f8b0..5ad4e8209 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -206,34 +206,34 @@ async function resolveUuidFromDatabase( ELSE comment_status.name 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, + 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, + ) AS taxonomyChildrenIds, JSON_OBJECTAGG( COALESCE(term_taxonomy_entity.entity_id, "__no_key"), term_taxonomy_entity.position - ) as taxonomyEntityChildrenIds + ) AS taxonomyEntityChildrenIds 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 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 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 WHERE uuid.id = ? GROUP BY uuid.id From a5d5a1c2592f1b0c47b8d510d31d5eb66312eb2b Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 13:33:15 +0200 Subject: [PATCH 24/69] style(uuid resolver): format SQL query --- .../src/schema/uuid/abstract-uuid/resolvers.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 5ad4e8209..a7082819a 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -190,13 +190,13 @@ async function resolveUuidFromDatabase( uuid.trashed, uuid.discriminator, - 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, + 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 From 095030dc2a0f005b00f0d3d11f4eb7f657d772d1 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 18:34:35 +0200 Subject: [PATCH 25/69] refactor(resolveUuidFromDatabase): define BaseUser --- .../src/schema/uuid/abstract-uuid/resolvers.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index a7082819a..8308aa7c8 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -179,6 +179,18 @@ const BaseTaxonomy = t.intersection([ }), ]) +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, From 53fdd540ded81f65d2e239aee602f16268eadb9f Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 18:46:46 +0200 Subject: [PATCH 26/69] refactor(resolveUuidFromDatabase): add LEFT JOINs for user to SQL query --- packages/server/src/schema/uuid/abstract-uuid/resolvers.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 8308aa7c8..9c339c45e 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -246,6 +246,10 @@ async function resolveUuidFromDatabase( 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 From 9183a59079169a6a77c2a78bcdfc5a94966a81a6 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 19:34:11 +0200 Subject: [PATCH 27/69] refactor(resolveUuidFromDatabase): add SELECTs for user to SQL query --- .../server/src/schema/uuid/abstract-uuid/resolvers.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 9c339c45e..ea47f6a8e 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -232,7 +232,14 @@ async function resolveUuidFromDatabase( JSON_OBJECTAGG( COALESCE(term_taxonomy_entity.entity_id, "__no_key"), term_taxonomy_entity.position - ) AS taxonomyEntityChildrenIds + ) 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 From d8b84107fc60decbab167256d7066350910cf073 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 20:03:27 +0200 Subject: [PATCH 28/69] refactor(resolveUuidFromDatabase): add case BaseUser.is(baseUuid) --- .../src/schema/uuid/abstract-uuid/resolvers.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index ea47f6a8e..0d4d7db10 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -187,8 +187,8 @@ const BaseUser = t.intersection([ userDate: date, userLastLogin: date, userDescription: t.string, - userRoles: t.array(t.string) - }) + userRoles: t.array(t.string), + }), ]) async function resolveUuidFromDatabase( @@ -312,6 +312,16 @@ async function resolveUuidFromDatabase( parentId: baseUuid.taxonomyParentId, childrenIds, } + } else if (BaseUser.is(baseUuid)) { + return { + ...base, + __typename: DiscriminatorType.User, + username: baseUuid.userUsername, + date: baseUuid.userDate.toISOString(), + lastLogin: baseUuid.userLastLogin.toISOString(), + description: baseUuid.userDescription, + roles: baseUuid.userRoles, + } } } From f487e56c987a1c8a0bfe16fae1682405aa433e1e Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Mon, 6 May 2024 20:20:58 +0200 Subject: [PATCH 29/69] fix(resolveUuidFromDatabase): add missing commas to SQL query --- .../server/src/schema/uuid/abstract-uuid/resolvers.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 0d4d7db10..3d72f61e3 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -232,12 +232,12 @@ async function resolveUuidFromDatabase( JSON_OBJECTAGG( COALESCE(term_taxonomy_entity.entity_id, "__no_key"), term_taxonomy_entity.position - ) AS taxonomyEntityChildrenIds + ) AS taxonomyEntityChildrenIds, - user.username AS userUsername - user.date AS userDate - user.last_login AS userLastLogin - user.description AS userDescription + 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 From 8fb67ef8ba9d9e5146cc81c390d17bf403ab1002 Mon Sep 17 00:00:00 2001 From: Botho <1258870+elbotho@users.noreply.github.com> Date: Mon, 6 May 2024 23:17:55 +0200 Subject: [PATCH 30/69] chore: move emptySubjects to fixtures --- __fixtures__/index.ts | 2 ++ __fixtures__/subjects.ts | 10 ++++++++++ __tests__/schema/entity/checkout-revision.ts | 2 +- __tests__/schema/entity/reject-revision.ts | 2 +- __tests__/schema/subject.ts | 13 +------------ 5 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 __fixtures__/subjects.ts 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__/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/subject.ts b/__tests__/schema/subject.ts index 04cac2cfc..225e8cded 100644 --- a/__tests__/schema/subject.ts +++ b/__tests__/schema/subject.ts @@ -1,20 +1,9 @@ import gql from 'graphql-tag' -import { article, taxonomyTermSubject } from '../../__fixtures__' +import { article, emptySubjects, 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: [] } }, -] - test('endpoint "subjects" returns list of all subjects for an instance', async () => { given('UuidQuery').for(taxonomyTermSubject) From 57f79ab5db472285650f818713795398337d7fd0 Mon Sep 17 00:00:00 2001 From: Mikey Stengel Date: Tue, 7 May 2024 13:03:17 +0200 Subject: [PATCH 31/69] chore(cache): Add myself to allowed users --- packages/server/src/schema/cache/resolvers.ts | 1 + 1 file changed, 1 insertion(+) 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 = { From b9eb9cd5773aa4a1222c81d2944b5b86975827c3 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 14:18:08 +0200 Subject: [PATCH 32/69] chore(resolveUuidFromDatabase): find arguments to return --- .../schema/uuid/abstract-uuid/resolvers.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 3d72f61e3..cf2ed8f0e 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -26,7 +26,7 @@ import { import { fetchScopeOfUuid } from '~/schema/authorization/utils' import { SubjectResolver } from '~/schema/subject/resolvers' import { decodePath, encodePath } from '~/schema/uuid/alias/utils' -import { Resolvers, QueryUuidArgs, TaxonomyTermType } from '~/types' +import { Instance, Resolvers, QueryUuidArgs, TaxonomyTermType } from '~/types' import { isDefined } from '~/utils' export const UuidResolver = createCachedResolver< @@ -316,11 +316,25 @@ async function resolveUuidFromDatabase( return { ...base, __typename: DiscriminatorType.User, - username: baseUuid.userUsername, + //activityByType: "test", + alias: "test", // TODO! + //chatUrl: "test", date: baseUuid.userDate.toISOString(), - lastLogin: baseUuid.userLastLogin.toISOString(), description: baseUuid.userDescription, + //imageUrl: "test", + //isActiveAuthor: false, + //isActiveDonor: false, + //isActiveReviewer: false, + //isNewAuthor: false, + language: Instance.De, // TODO! + lastLogin: baseUuid.userLastLogin.toISOString(), + //motivation: 'teacher', roles: baseUuid.userRoles, + //threads: [1], + //title: "test", + //unrevisedEntities: [1], + username: baseUuid.userUsername, + } } } From 424350a1507045ae8dd20e5d76fa427850f77206 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 14:20:14 +0200 Subject: [PATCH 33/69] chore(resolveUuidFromDatabase): cleanup --- .../src/schema/uuid/abstract-uuid/resolvers.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index cf2ed8f0e..06c186c8d 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -316,25 +316,13 @@ async function resolveUuidFromDatabase( return { ...base, __typename: DiscriminatorType.User, - //activityByType: "test", - alias: "test", // TODO! - //chatUrl: "test", + alias: 'test', // TODO! date: baseUuid.userDate.toISOString(), description: baseUuid.userDescription, - //imageUrl: "test", - //isActiveAuthor: false, - //isActiveDonor: false, - //isActiveReviewer: false, - //isNewAuthor: false, - language: Instance.De, // TODO! + language: Instance.De, // TODO! lastLogin: baseUuid.userLastLogin.toISOString(), - //motivation: 'teacher', roles: baseUuid.userRoles, - //threads: [1], - //title: "test", - //unrevisedEntities: [1], username: baseUuid.userUsername, - } } } From fbd2ccc1324e837389a308735b7de805f3e9f868 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 14:35:56 +0200 Subject: [PATCH 34/69] chore: fix lint errors with imports --- __tests__/schema/entity/checkout-revision.ts | 1 - __tests__/schema/entity/reject-revision.ts | 1 - packages/server/src/schema/uuid/abstract-uuid/resolvers.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/__tests__/schema/entity/checkout-revision.ts b/__tests__/schema/entity/checkout-revision.ts index 205fd916d..d7a1808f4 100644 --- a/__tests__/schema/entity/checkout-revision.ts +++ b/__tests__/schema/entity/checkout-revision.ts @@ -9,7 +9,6 @@ import { 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 14fd5ad13..0971cde68 100644 --- a/__tests__/schema/entity/reject-revision.ts +++ b/__tests__/schema/entity/reject-revision.ts @@ -9,7 +9,6 @@ import { 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/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index 2f8921825..e99675c33 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -29,7 +29,6 @@ import { createEvent } from '~/schema/events/event' import { SubjectResolver } from '~/schema/subject/resolvers' import { decodePath, encodePath } from '~/schema/uuid/alias/utils' import { Instance, Resolvers, QueryUuidArgs, TaxonomyTermType } from '~/types' -import { isDefined } from '~/utils' export const UuidResolver = createCachedResolver< { id: number }, From d9202ab29ed4514355d80b385fcf34c13ddf1d29 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 15:10:11 +0200 Subject: [PATCH 35/69] fix(resolveUuidFromDatabase): add proper alias property --- packages/server/src/schema/uuid/abstract-uuid/resolvers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index e99675c33..fdd0780a2 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -28,7 +28,7 @@ import { import { createEvent } from '~/schema/events/event' import { SubjectResolver } from '~/schema/subject/resolvers' import { decodePath, encodePath } from '~/schema/uuid/alias/utils' -import { Instance, Resolvers, QueryUuidArgs, TaxonomyTermType } from '~/types' +import { Resolvers, QueryUuidArgs, TaxonomyTermType } from '~/types' export const UuidResolver = createCachedResolver< { id: number }, @@ -332,10 +332,10 @@ async function resolveUuidFromDatabase( return { ...base, __typename: DiscriminatorType.User, - alias: 'test', // TODO! + alias: `/user/${base.id}/${baseUuid.userUsername}`, date: baseUuid.userDate.toISOString(), description: baseUuid.userDescription, - language: Instance.De, // TODO! + language: undefined, lastLogin: baseUuid.userLastLogin.toISOString(), roles: baseUuid.userRoles, username: baseUuid.userUsername, From 9787a3bfd74954f35ec00d9c5ec9a16783cf57ca Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 15:21:33 +0200 Subject: [PATCH 36/69] chore(resolveUuidFromDatabase): remove unused property --- packages/server/src/schema/uuid/abstract-uuid/resolvers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts index fdd0780a2..5dc5b0e5c 100644 --- a/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts +++ b/packages/server/src/schema/uuid/abstract-uuid/resolvers.ts @@ -335,7 +335,6 @@ async function resolveUuidFromDatabase( alias: `/user/${base.id}/${baseUuid.userUsername}`, date: baseUuid.userDate.toISOString(), description: baseUuid.userDescription, - language: undefined, lastLogin: baseUuid.userLastLogin.toISOString(), roles: baseUuid.userRoles, username: baseUuid.userUsername, From d5cc3f3fe681e02cdac4d32d8cfff8cf3d2cacf8 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 18:48:45 +0200 Subject: [PATCH 37/69] refactor(setEmail): migrate SQL from database-layer to resolver --- packages/server/src/schema/uuid/user/resolvers.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index e31d020eb..dc790254d 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -470,7 +470,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 +479,19 @@ 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, + ]) + await UuidResolver.removeCacheEntry({ id: userId }, context) + return { success: true, query: {} } }, }, } From 2fef78349f5616125ac6e7de331aabda8eaf421d Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 18:49:39 +0200 Subject: [PATCH 38/69] test(setEmail): remove tests testing case of database-layer error response --- __tests__/schema/user/set-email.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/__tests__/schema/user/set-email.ts b/__tests__/schema/user/set-email.ts index 79053df33..835215987 100644 --- a/__tests__/schema/user/set-email.ts +++ b/__tests__/schema/user/set-email.ts @@ -37,15 +37,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') -}) From 1a6d69007b6a47a4e6e1c9dee29dfc3288768f75 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 18:59:57 +0200 Subject: [PATCH 39/69] test(setEmail): remove from serlo model --- packages/server/src/model/serlo.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index 7ad482397..e0497e317 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -116,14 +116,6 @@ export function createSerloModel({ }, }) - const setEmail = createMutation({ - type: 'UserSetEmailMutation', - decoder: DatabaseLayer.getDecoderFor('UserSetEmailMutation'), - mutate(payload: DatabaseLayer.Payload<'UserSetEmailMutation'>) { - return DatabaseLayer.makeRequest('UserSetEmailMutation', payload) - }, - }) - const getAlias = createLegacyQuery( { type: 'AliasQuery', @@ -658,7 +650,6 @@ export function createSerloModel({ getUsersByRole, getPages, rejectEntityRevision, - setEmail, setEntityLicense, setSubscription, sortEntity, From 693f4e5660353e82a8a370aff49e5750e7b1e3cb Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Tue, 7 May 2024 19:02:33 +0200 Subject: [PATCH 40/69] refactor(setEmail): do not remove cache entry as mail is not in cache --- packages/server/src/schema/uuid/user/resolvers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index dc790254d..0234f3bc4 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -490,7 +490,6 @@ export const resolvers: Resolvers = { input.email, userId, ]) - await UuidResolver.removeCacheEntry({ id: userId }, context) return { success: true, query: {} } }, }, From 98905ace661efcdf4451ee779a87b7f23e21a9b6 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Tue, 7 May 2024 21:02:05 +0200 Subject: [PATCH 41/69] fix(bull-arena): Make it an external dependency --- packages/server/package.json | 4 ++-- scripts/build.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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 From ced73261df7ed75f9ec63bb731d33e3de932285a Mon Sep 17 00:00:00 2001 From: Hugo Tiburtino Date: Tue, 7 May 2024 23:10:18 +0200 Subject: [PATCH 42/69] chore(vidis): add container for local test to vidis --- README.md | 16 ++++++++++------ docker-compose.kratos.yml | 10 ---------- docker-compose.sso.yml | 21 +++++++++++++++++++++ kratos/config.yml | 6 ++++++ package.json | 6 +++--- 5 files changed, 40 insertions(+), 19 deletions(-) create mode 100644 docker-compose.sso.yml diff --git a/README.md b/README.md index 91626f58a..fe165f657 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 @@ -129,14 +129,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 dns's 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 +148,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 +160,7 @@ selfservice: enabled: true config: providers: - - id: nbp + - id: nbp # or vidis provider: generic client_id: serlo client_secret: diff --git a/docker-compose.kratos.yml b/docker-compose.kratos.yml index b50b5f6fe..e94af5a56 100644 --- a/docker-compose.kratos.yml +++ b/docker-compose.kratos.yml @@ -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'", From 933c1908daba18dfc41b03b56cb8a3fe8e7df83f Mon Sep 17 00:00:00 2001 From: Hugo Tiburtino Date: Tue, 7 May 2024 23:24:12 +0200 Subject: [PATCH 43/69] doc(readme): remove not so important info --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index fe165f657..4c1fc306e 100644 --- a/README.md +++ b/README.md @@ -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) From 8661c1dc33f7eaae847811fb22fccae3d7d90139 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Wed, 8 May 2024 08:56:01 +0200 Subject: [PATCH 44/69] fix(swr-queue): Add needed context variables for value updates --- packages/server/src/internals/swr-queue.ts | 29 ++++++++++------------ 1 file changed, 13 insertions(+), 16 deletions(-) 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, From 1fcbbc594811954f147c554fb32083f498e79d8e Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 10:41:13 +0200 Subject: [PATCH 45/69] refactor(setEmail): remove from database-layer.ts --- packages/server/src/model/database-layer.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index 0e288dea9..beaa390aa 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -298,11 +298,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, - }, UuidQuery: { payload: t.type({ id: t.number }), response: UuidDecoder, From b2bca1c9e98b1a88b81e9ce632b1afcce579711f Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 10:45:33 +0200 Subject: [PATCH 46/69] test(setEmail): remove mock --- __tests__/schema/user/set-email.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/__tests__/schema/user/set-email.ts b/__tests__/schema/user/set-email.ts index 835215987..bb2d8453f 100644 --- a/__tests__/schema/user/set-email.ts +++ b/__tests__/schema/user/set-email.ts @@ -23,10 +23,6 @@ beforeEach(() => { }) 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 } } }) }) From 068d4522192e8578b0a9e24688e2d4f39d26db05 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 12:08:21 +0200 Subject: [PATCH 47/69] refactor(deleteRegularUser): remove from serlo model --- packages/server/src/model/serlo.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/server/src/model/serlo.ts b/packages/server/src/model/serlo.ts index e0497e317..83da66332 100644 --- a/packages/server/src/model/serlo.ts +++ b/packages/server/src/model/serlo.ts @@ -98,24 +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 getAlias = createLegacyQuery( { type: 'AliasQuery', @@ -635,7 +617,6 @@ export function createSerloModel({ createPage, createThread, deleteBots, - deleteRegularUsers, executePrompt, getActiveReviewerIds, getActivityByType, From 21c8a6e4c95d8efafbbcfa685619daa802365954 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 12:08:39 +0200 Subject: [PATCH 48/69] refactor(deleteRegularUser): remove from database-layer.ts --- packages/server/src/model/database-layer.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/server/src/model/database-layer.ts b/packages/server/src/model/database-layer.ts index 0e288dea9..013b337ac 100644 --- a/packages/server/src/model/database-layer.ts +++ b/packages/server/src/model/database-layer.ts @@ -278,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) }), @@ -298,11 +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, - }, UuidQuery: { payload: t.type({ id: t.number }), response: UuidDecoder, From b0e3d18c006541d88637ad5e65eb4d2899311ec7 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 12:09:06 +0200 Subject: [PATCH 49/69] refactor(deleteRegularUser): move SQL to resolver --- .../server/src/schema/uuid/user/resolvers.ts | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index 0234f3bc4..f61c594cf 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,33 @@ 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 must not delete the user Deleted.', + ) + } - const result = await dataSources.model.serlo.deleteRegularUsers({ - userId: id, - }) - - if (result.success) await deleteKratosUser(id, authServices) - return { success: result.success, query: {} } + 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]) + + UuidResolver.removeCacheEntry( {id}, context ) + + await deleteKratosUser(id, authServices) + return { success: true, query: {} } }, async removeRole(_parent, { input }, context) { From 56b9a59c7ef79a94620e9d6b6da98eaa50a33212 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Wed, 8 May 2024 13:10:50 +0200 Subject: [PATCH 50/69] test(setEmail): Test that email has been changed --- __tests__/schema/user/set-email.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/__tests__/schema/user/set-email.ts b/__tests__/schema/user/set-email.ts index bb2d8453f..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,14 +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 () => { 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 () => { From 72fc01e10fb230c42ee038a2bf391136b7addf33 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Wed, 8 May 2024 13:05:34 +0200 Subject: [PATCH 51/69] refactor(subject): Shorten code to query subjects --- .../server/src/schema/subject/resolvers.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/server/src/schema/subject/resolvers.ts b/packages/server/src/schema/subject/resolvers.ts index 13630534f..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) }, }) From a30dcd57761d41085e47f4926d8d23e2f20582de Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 13:19:58 +0200 Subject: [PATCH 52/69] test(deleteRegularUser): remove mocks and tests for database-layer failure --- __tests__/schema/user/delete-regular-users.ts | 59 ++++--------------- 1 file changed, 12 insertions(+), 47 deletions(-) diff --git a/__tests__/schema/user/delete-regular-users.ts b/__tests__/schema/user/delete-regular-users.ts index 92d1ffa1a..482b0bb19 100644 --- a/__tests__/schema/user/delete-regular-users.ts +++ b/__tests__/schema/user/delete-regular-users.ts @@ -3,7 +3,7 @@ import { HttpResponse } from 'msw' import * as R from 'ramda' import { user as baseUser } from '../../../__fixtures__' -import { Client, given, nextUuid, Query } from '../../__utils__' +import { createFakeIdentity, Client, given, nextUuid, Query } from '../../__utils__' let client: Client let mutation: Query @@ -27,42 +27,9 @@ 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 () => { - expect(global.kratos.identities).toHaveLength(1) - - await mutation.shouldReturnData({ - user: { deleteRegularUser: { success: true } }, - }) - 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') @@ -88,29 +55,27 @@ test('updates the cache', async () => { 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') } From a98c65202225a471e8e7c167266ab6968c389380 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 13:25:17 +0200 Subject: [PATCH 53/69] fix(deleteRegularUser): await promise --- packages/server/src/schema/uuid/user/resolvers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index f61c594cf..e67047ecf 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -431,7 +431,7 @@ export const resolvers: Resolvers = { database.mutate("DELETE FROM subscription WHERE uuid_id = ?", [id]) database.mutate("DELETE FROM uuid WHERE id = ? and discriminator = 'user'", [id]) - UuidResolver.removeCacheEntry( {id}, context ) + await UuidResolver.removeCacheEntry( {id}, context ) await deleteKratosUser(id, authServices) return { success: true, query: {} } From 24339cc0f038e37561b1fc72f2025ae201b38f1c Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 13:26:22 +0200 Subject: [PATCH 54/69] test(deleteRegularUser): remove unused imports --- __tests__/schema/user/delete-regular-users.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/__tests__/schema/user/delete-regular-users.ts b/__tests__/schema/user/delete-regular-users.ts index 482b0bb19..bcb3a491b 100644 --- a/__tests__/schema/user/delete-regular-users.ts +++ b/__tests__/schema/user/delete-regular-users.ts @@ -1,9 +1,8 @@ import gql from 'graphql-tag' -import { HttpResponse } from 'msw' import * as R from 'ramda' import { user as baseUser } from '../../../__fixtures__' -import { createFakeIdentity, Client, given, nextUuid, Query } from '../../__utils__' +import { Client, nextUuid, Query } from '../../__utils__' let client: Client let mutation: Query From 3dc7e6d4b09a3088734e275b1a8722f4749279c1 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 13:30:43 +0200 Subject: [PATCH 55/69] test(deleteRegularUser): format --- __tests__/schema/user/delete-regular-users.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/__tests__/schema/user/delete-regular-users.ts b/__tests__/schema/user/delete-regular-users.ts index bcb3a491b..294022c37 100644 --- a/__tests__/schema/user/delete-regular-users.ts +++ b/__tests__/schema/user/delete-regular-users.ts @@ -61,9 +61,7 @@ test('fails if one of the given bot ids is not a user', async () => { }) test('fails if you try to delete user Deleted', async () => { - await mutation - .withInput({ userIds: 4 }) - .shouldFailWithError('BAD_USER_INPUT') + await mutation.withInput({ userIds: 4 }).shouldFailWithError('BAD_USER_INPUT') }) test('fails if user is not authenticated', async () => { From 72f9856a15752d942106c35cf98101b781dee916 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 13:30:55 +0200 Subject: [PATCH 56/69] fix(deleteRegularUser): await promise --- .../server/src/schema/uuid/user/resolvers.ts | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index e67047ecf..44318323e 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -416,22 +416,35 @@ export const resolvers: Resolvers = { ) } if (id === idUserDeleted) { - throw new ForbiddenError( - 'You must not delete the user Deleted.', - ) + throw new ForbiddenError('You must not delete the user Deleted.') } - 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 database.mutate( + 'UPDATE comment SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ) + await database.mutate( + 'UPDATE entity_revision SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ) + await database.mutate( + 'UPDATE event_log SET actor_id = ? WHERE actor_id = ?', + [idUserDeleted, id], + ) + await database.mutate( + 'UPDATE page_revision SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ) + await database.mutate('DELETE FROM notification WHERE user_id = ?', [id]) + await database.mutate('DELETE FROM role_user WHERE user_id = ?', [id]) + await database.mutate('DELETE FROM subscription WHERE user_id = ?', [id]) + await database.mutate('DELETE FROM subscription WHERE uuid_id = ?', [id]) + await database.mutate( + "DELETE FROM uuid WHERE id = ? and discriminator = 'user'", + [id], + ) + + await UuidResolver.removeCacheEntry({ id }, context) await deleteKratosUser(id, authServices) return { success: true, query: {} } From b369ce737b23da7ac09dd982fac872e838f190c6 Mon Sep 17 00:00:00 2001 From: Hugo Tiburtino Date: Wed, 8 May 2024 14:19:00 +0200 Subject: [PATCH 57/69] feat(kratos): extend SLO to provider vidis --- README.md | 2 ++ .../src/internals/server/kratos-middleware.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4c1fc306e..5afa9eec9 100644 --- a/README.md +++ b/README.md @@ -157,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/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') From 56b3deb40e4330ec524d24045599845d26cf1227 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 14:24:33 +0200 Subject: [PATCH 58/69] fix(deleteRegularUser): use transaction --- .../server/src/schema/uuid/user/resolvers.ts | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index 44318323e..ad3776975 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -419,32 +419,44 @@ export const resolvers: Resolvers = { throw new ForbiddenError('You must not delete the user Deleted.') } - await database.mutate( - 'UPDATE comment SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ) - await database.mutate( - 'UPDATE entity_revision SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ) - await database.mutate( - 'UPDATE event_log SET actor_id = ? WHERE actor_id = ?', - [idUserDeleted, id], - ) - await database.mutate( - 'UPDATE page_revision SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ) - await database.mutate('DELETE FROM notification WHERE user_id = ?', [id]) - await database.mutate('DELETE FROM role_user WHERE user_id = ?', [id]) - await database.mutate('DELETE FROM subscription WHERE user_id = ?', [id]) - await database.mutate('DELETE FROM subscription WHERE uuid_id = ?', [id]) - await database.mutate( - "DELETE FROM uuid WHERE id = ? and discriminator = 'user'", - [id], - ) + const transaction = await database.beginTransaction() + try { + await database.mutate( + 'UPDATE comment SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ) + await database.mutate( + 'UPDATE entity_revision SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ) + await database.mutate( + 'UPDATE event_log SET actor_id = ? WHERE actor_id = ?', + [idUserDeleted, id], + ) + await database.mutate( + 'UPDATE page_revision SET author_id = ? WHERE author_id = ?', + [idUserDeleted, id], + ) + await database.mutate('DELETE FROM notification WHERE user_id = ?', [ + id, + ]) + await database.mutate('DELETE FROM role_user WHERE user_id = ?', [id]) + await database.mutate('DELETE FROM subscription WHERE user_id = ?', [ + id, + ]) + await database.mutate('DELETE FROM subscription WHERE uuid_id = ?', [ + id, + ]) + await database.mutate( + "DELETE FROM uuid WHERE id = ? and discriminator = 'user'", + [id], + ) + await transaction.commit() + } finally { + await transaction.rollback() + } - await UuidResolver.removeCacheEntry({ id }, context) + await UuidResolver.removeCacheEntry({ id: user.id }, context) await deleteKratosUser(id, authServices) return { success: true, query: {} } From fa602eee3accde7b90a47fab1b6e6de999f6dc80 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 14:33:58 +0200 Subject: [PATCH 59/69] refactor(deleteRegularUser): revert to id instead of user.id --- packages/server/src/schema/uuid/user/resolvers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index ad3776975..d137e3936 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -456,7 +456,7 @@ export const resolvers: Resolvers = { await transaction.rollback() } - await UuidResolver.removeCacheEntry({ id: user.id }, context) + await UuidResolver.removeCacheEntry({ id }, context) await deleteKratosUser(id, authServices) return { success: true, query: {} } From 080e47bf9b1daab2d0204d9bc05545a52bf45d06 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Wed, 8 May 2024 15:05:16 +0200 Subject: [PATCH 60/69] feature(deleteRegularUser): only commit database transaction if removing cache and deleting Kratos user have been successful --- packages/server/src/schema/uuid/user/resolvers.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index d137e3936..4fa7cf16a 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -451,14 +451,16 @@ export const resolvers: Resolvers = { "DELETE FROM uuid WHERE id = ? and discriminator = 'user'", [id], ) + + await UuidResolver.removeCacheEntry({ id }, context) + + await deleteKratosUser(id, authServices) + await transaction.commit() } finally { await transaction.rollback() } - await UuidResolver.removeCacheEntry({ id }, context) - - await deleteKratosUser(id, authServices) return { success: true, query: {} } }, From f1d84563e840b534430c0559b6ab19069d27724d Mon Sep 17 00:00:00 2001 From: Hugo Tiburtino <45924645+hugotiburtino@users.noreply.github.com> Date: Wed, 8 May 2024 20:01:13 +0200 Subject: [PATCH 61/69] doc(readme): minor change Co-authored-by: AndreasHuber --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c1fc306e..d9b0fa3cf 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ 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. These dns's are 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:sso`. _Make sure you already run `yarn start:kratos` before._ From 2c70a5717dff248726db3f191082b7ee73d55d30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 May 2024 20:19:44 +0000 Subject: [PATCH 62/69] chore(deps): bump semver from 7.0.0 to 7.6.1 Bumps [semver](https://github.com/npm/node-semver) from 7.0.0 to 7.6.1. - [Release notes](https://github.com/npm/node-semver/releases) - [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md) - [Commits](https://github.com/npm/node-semver/compare/v7.0.0...v7.6.1) --- updated-dependencies: - dependency-name: semver dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../semver-npm-7.6.0-f4630729f6-7427f05b70.zip | Bin 40132 -> 0 bytes .../semver-npm-7.6.1-8d5ad7cc68-2c9c89b985.zip | Bin 0 -> 40738 bytes yarn.lock | 8 +++----- 3 files changed, 3 insertions(+), 5 deletions(-) delete mode 100644 .yarn/cache/semver-npm-7.6.0-f4630729f6-7427f05b70.zip create mode 100644 .yarn/cache/semver-npm-7.6.1-8d5ad7cc68-2c9c89b985.zip 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 a5494e10ac918d80866c5f05193c1b974f5d5a80..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 40132 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 x<{5}+^?Tso>tcYz11?HnW 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 literal 0 HcmV?d00001 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 From 1540366f13cf85f5c46de9bfe7244d6b69621d62 Mon Sep 17 00:00:00 2001 From: Hugo Tiburtino Date: Thu, 9 May 2024 00:48:37 +0200 Subject: [PATCH 63/69] chore(kratos): upgrade image --- docker-compose.kratos.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.kratos.yml b/docker-compose.kratos.yml index e94af5a56..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 From 3e1509e8a73d9e43e0a71f4105b122fe023ed89b Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Thu, 9 May 2024 19:54:25 +0200 Subject: [PATCH 64/69] chore: Set default cache type to "redis" for "yarn start" Having no caching was introduced to test the API together with the DB-Layer so that no request hits the cache. After merging DB-Layer into the API I find it more valuable to have caching enabled with "yarn start" to test how the system works actually in our infrastructure. --- .env | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 6da0145c1e1d075902972aba42ef25c02a53f4f4 Mon Sep 17 00:00:00 2001 From: Stephan Kulla Date: Thu, 9 May 2024 20:00:14 +0200 Subject: [PATCH 65/69] test(subject): Add `subjectQuery` --- __tests__/__utils__/query.ts | 16 +++++++++++ __tests__/schema/subject.ts | 51 +++++------------------------------- 2 files changed, 23 insertions(+), 44 deletions(-) diff --git a/__tests__/__utils__/query.ts b/__tests__/__utils__/query.ts index 6433594ca..fc676c087 100644 --- a/__tests__/__utils__/query.ts +++ b/__tests__/__utils__/query.ts @@ -34,3 +34,19 @@ export const taxonomyTermQuery = new Client().prepareQuery({ } `, }) + +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/subject.ts b/__tests__/schema/subject.ts index 225e8cded..c3e368cdb 100644 --- a/__tests__/schema/subject.ts +++ b/__tests__/schema/subject.ts @@ -1,55 +1,18 @@ import gql from 'graphql-tag' import { article, emptySubjects, taxonomyTermSubject } from '../../__fixtures__' -import { Client, given } from '../__utils__' -import { Instance } from '~/types' +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 () => { From ef63299d03e8baf452528671cbb400ff37716edc Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Fri, 10 May 2024 02:56:33 +0200 Subject: [PATCH 66/69] test(deleteRegularUser): put test back that might still make sense and put UuidQuery mock for user back to find out how it affects the Kratos test results --- __tests__/schema/user/delete-regular-users.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/__tests__/schema/user/delete-regular-users.ts b/__tests__/schema/user/delete-regular-users.ts index 294022c37..763aba03a 100644 --- a/__tests__/schema/user/delete-regular-users.ts +++ b/__tests__/schema/user/delete-regular-users.ts @@ -2,7 +2,7 @@ import gql from 'graphql-tag' import * as R from 'ramda' import { user as baseUser } from '../../../__fixtures__' -import { Client, nextUuid, Query } from '../../__utils__' +import { Client, given, nextUuid, Query } from '../../__utils__' let client: Client let mutation: Query @@ -26,6 +26,17 @@ beforeEach(() => { `, }) .withInput(R.pick(['id', 'username'], user)) + + given('UuidQuery').for(user) +}) + +test('runs successfully if mutation could be successfully executed', async () => { + expect(global.kratos.identities).toHaveLength(1) + + await mutation.shouldReturnData({ + user: { deleteRegularUser: { success: true } }, + }) + expect(global.kratos.identities).toHaveLength(0) }) test('fails if username does not match user', async () => { From 5b9c3195948384d9fee4860b255585d0f657f63d Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Fri, 10 May 2024 20:30:24 +0200 Subject: [PATCH 67/69] test(deleteRegularUser): comment test step that cannot work while the UUID query defaults to calling the database-layer --- __tests__/schema/user/delete-regular-users.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/__tests__/schema/user/delete-regular-users.ts b/__tests__/schema/user/delete-regular-users.ts index 763aba03a..f32391920 100644 --- a/__tests__/schema/user/delete-regular-users.ts +++ b/__tests__/schema/user/delete-regular-users.ts @@ -62,7 +62,8 @@ 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 if one of the given bot ids is not a user', async () => { From 7e3069a979ff77185240fd332f3fac99995642f3 Mon Sep 17 00:00:00 2001 From: Andreas Huber Date: Fri, 10 May 2024 20:38:28 +0200 Subject: [PATCH 68/69] feature(deleteRegularUser): await completion of all mutations instead of every individual mutation --- .../server/src/schema/uuid/user/resolvers.ts | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index 4fa7cf16a..4903d736d 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -421,36 +421,32 @@ export const resolvers: Resolvers = { const transaction = await database.beginTransaction() try { - await database.mutate( - 'UPDATE comment SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ) - await database.mutate( - 'UPDATE entity_revision SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ) - await database.mutate( - 'UPDATE event_log SET actor_id = ? WHERE actor_id = ?', - [idUserDeleted, id], - ) - await database.mutate( - 'UPDATE page_revision SET author_id = ? WHERE author_id = ?', - [idUserDeleted, id], - ) - await database.mutate('DELETE FROM notification WHERE user_id = ?', [ - id, - ]) - await database.mutate('DELETE FROM role_user WHERE user_id = ?', [id]) - await database.mutate('DELETE FROM subscription WHERE user_id = ?', [ - id, - ]) - await database.mutate('DELETE FROM subscription WHERE uuid_id = ?', [ - id, + 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 database.mutate( - "DELETE FROM uuid WHERE id = ? and discriminator = 'user'", - [id], - ) await UuidResolver.removeCacheEntry({ id }, context) From 4dcdf56023dda75589587d6cd8ddd4df90ee47c1 Mon Sep 17 00:00:00 2001 From: Hugo Tiburtino <45924645+hugotiburtino@users.noreply.github.com> Date: Sat, 11 May 2024 08:08:15 +0200 Subject: [PATCH 69/69] refactor(user): minor wording change at error msg --- packages/server/src/schema/uuid/user/resolvers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/schema/uuid/user/resolvers.ts b/packages/server/src/schema/uuid/user/resolvers.ts index 4903d736d..903368307 100644 --- a/packages/server/src/schema/uuid/user/resolvers.ts +++ b/packages/server/src/schema/uuid/user/resolvers.ts @@ -416,7 +416,7 @@ export const resolvers: Resolvers = { ) } if (id === idUserDeleted) { - throw new ForbiddenError('You must not delete the user Deleted.') + throw new ForbiddenError('You cannot delete the user Deleted.') } const transaction = await database.beginTransaction()