diff --git a/.github/workflows/reuse-quality.yml b/.github/workflows/reuse-quality.yml index ea61ff3..0d7c798 100644 --- a/.github/workflows/reuse-quality.yml +++ b/.github/workflows/reuse-quality.yml @@ -35,7 +35,7 @@ jobs: run: docker compose up -d --wait - name: Start dev API - run: NODE_ENV=development NODE_CONFIG_DIR=api/config node api/index.ts & + run: NODE_ENV=development NODE_CONFIG_DIR=./api/config node api/index.ts & - name: Wait for API to be ready run: | diff --git a/api/package.json b/api/package.json index f0a0530..927d20e 100644 --- a/api/package.json +++ b/api/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@data-fair/lib-common-types": "^1.24.0", - "@data-fair/lib-express": "^1.22.5", + "@data-fair/lib-express": "^1.27.0", "@data-fair/lib-node": "^2.12.1", "@data-fair/lib-utils": "^1.14.0", "@data-fair/lib-validation": "^1.0.2", diff --git a/api/src/events/router.ts b/api/src/events/router.ts index d41fa44..407f369 100644 --- a/api/src/events/router.ts +++ b/api/src/events/router.ts @@ -24,7 +24,7 @@ router.get('', async (req, res, next) => { query['resource.id'] = id } - const project = mongoProjection(req.query.select, ['_search', 'htmlBody']) + const project = mongoProjection(req.query.select, ['_search', '_needsSearch', 'htmlBody']) // implement a special pagination based on the fact that we always sort by date const sort: Sort = { date: -1, _id: -1 } diff --git a/api/src/events/search-worker.ts b/api/src/events/search-worker.ts new file mode 100644 index 0000000..6b2eafb --- /dev/null +++ b/api/src/events/search-worker.ts @@ -0,0 +1,61 @@ +// Rebuilds the search texts of the events flagged by the identity webhooks (_needsSearch), +// so that simple-directory gets its response without waiting for the text index. +// Runs in the api server, like the webhooks worker. + +import config from '#config' +import Debug from 'debug' +import mongo from '#mongo' +import { internalError } from '@data-fair/lib-node/observer.js' +import locks from '@data-fair/lib-node/locks.js' +import { buildSearchTexts } from './operations.ts' + +const debug = Debug('search-worker') +const batchSize = 1000 +const searchProjection = { topic: 1, title: 1, body: 1, sender: 1, originator: 1, _needsSearch: 1 } + +let loopPromise: Promise | null = null +let stopped = false +let acquiredLock = false + +const wait = () => new Promise(resolve => setTimeout(resolve, config.worker.loopInterval)) + +const loop = async () => { + // eslint-disable-next-line no-unmodified-loop-condition + while (!stopped) { + try { + if (!acquiredLock) { + acquiredLock = await locks.acquire('search-loop') + if (!acquiredLock) { await wait(); continue } + } + // only what buildSearchTexts reads: an htmlBody can be heavy + const events = await mongo.events.find({ _needsSearch: { $exists: true } }, { projection: searchProjection }).limit(batchSize).toArray() + if (!events.length) { await wait(); continue } + debug('rebuild the search texts of', events.length, 'events') + await mongo.events.bulkWrite(events.map(event => { + // a newer rename flagged the event again in the meantime: skipped, picked up by the next batch + const filter = { _id: event._id, _needsSearch: event._needsSearch } + try { + return { updateOne: { filter, update: { $set: { _search: buildSearchTexts(event, config.i18n.locales, config.i18n.defaultLocale) }, $unset: { _needsSearch: 1 } } } } + } catch (err) { + // like drainSearchIndex in data-fair: the event keeps its previous texts, but the flag is cleared + // or this event would come back first in every batch and block the others forever + internalError('search-loop-event', `failed to rebuild the search texts of event ${event._id} - ${(err as Error)?.stack || err}`) + return { updateOne: { filter, update: { $unset: { _needsSearch: 1 } } } } + } + }), { ordered: false }) + } catch (err) { + internalError('search-loop', err) + await wait() + } + } + if (acquiredLock) await locks.release('search-loop') +} + +export const start = () => { + loopPromise = loop() +} + +export const stop = async () => { + stopped = true + await loopPromise +} diff --git a/api/src/identities/router.ts b/api/src/identities/router.ts index d2d0f03..03dbb8b 100644 --- a/api/src/identities/router.ts +++ b/api/src/identities/router.ts @@ -3,59 +3,6 @@ import config from '#config' import { createIdentitiesRouter } from '@data-fair/lib-express/identities/index.js' -import mongo from '#mongo' +import { updateIdentity, deleteIdentity } from './service.ts' -export default createIdentitiesRouter( - config.secretKeys.identities, - // onUpdate - async (identity) => { - if (identity.type === 'user') { - await mongo.notifications.updateMany({ 'recipient.id': identity.id }, { $set: { 'recipient.name': identity.name } }) - await mongo.subscriptions.updateMany({ 'recipient.id': identity.id }, { $set: { 'recipient.name': identity.name } }) - } - await mongo.subscriptions.updateMany({ 'sender.type': identity.type, 'sender.id': identity.id }, { $set: { 'sender.name': identity.name } }) - await mongo.pushSubscriptions.updateMany({ 'owner.type': identity.type, 'owner.id': identity.id }, { $set: { 'owner.name': identity.name } }) - await mongo.webhookSubscriptions.updateMany({ 'sender.type': identity.type, 'sender.id': identity.id }, { $set: { 'sender.name': identity.name } }) - await mongo.webhookSubscriptions.updateMany({ 'owner.type': identity.type, 'owner.id': identity.id }, { $set: { 'owner.name': identity.name } }) - if (identity.departments) { - for (const department of identity.departments.filter(d => !!d.name)) { - await mongo.subscriptions.updateMany({ 'sender.type': identity.type, 'sender.id': identity.id, 'sender.department': department.id }, { $set: { 'sender.name': identity.name, 'sender.departmentName': department.name } }) - await mongo.pushSubscriptions.updateMany({ 'owner.type': identity.type, 'owner.id': identity.id, 'owner.department': department.id }, { $set: { 'owner.name': identity.name, 'owner.departmentName': department.name } }) - await mongo.webhookSubscriptions.updateMany({ 'sender.type': identity.type, 'sender.id': identity.id, 'sender.department': department.id }, { $set: { 'sender.name': identity.name, 'sender.departmentName': department.name } }) - await mongo.webhookSubscriptions.updateMany({ 'owner.type': identity.type, 'owner.id': identity.id, 'owner.department': department.id }, { $set: { 'owner.name': identity.name, 'owner.departmentName': department.name } }) - } - } - - if (identity.type === 'user' && identity.organizations) { - const privateSubscriptionFilter = { - 'recipient.id': identity.id, - visibility: { $ne: 'public' as const }, - 'sender.type': 'organization' - } - for await (const privateSubscription of mongo.subscriptions.find(privateSubscriptionFilter)) { - let userOrg = identity.organizations.find(o => o.id === privateSubscription.sender?.id && !o.department) - if (privateSubscription.sender?.department) { - userOrg = userOrg || identity.organizations.find(o => o.id === privateSubscription.sender?.id && o.department === privateSubscription.sender.department) - } - if (userOrg && privateSubscription.sender?.role && userOrg.role !== privateSubscription.sender.role && userOrg.role !== 'admin') { - userOrg = undefined - } - if (!userOrg) { - // console.log('remove private subscription that does not match user orgs anymore', identity, privateSubscription) - await mongo.subscriptions.deleteOne({ _id: privateSubscription._id }) - } - } - } - }, - // onDelete - async (identity) => { - if (identity.type === 'user') { - await mongo.notifications.deleteMany({ 'recipient.id': identity.id }) - await mongo.subscriptions.deleteMany({ 'recipient.id': identity.id }) - } - await mongo.subscriptions.deleteMany({ 'sender.type': identity.type, 'sender.id': identity.id }) - await mongo.pushSubscriptions.deleteMany({ 'owner.type': identity.type, 'owner.id': identity.id }) - await mongo.webhookSubscriptions.deleteMany({ 'owner.type': identity.type, 'owner.id': identity.id }) - await mongo.webhookSubscriptions.deleteMany({ 'sender.type': identity.type, 'sender.id': identity.id }) - } -) +export default createIdentitiesRouter(config.secretKeys.identities, updateIdentity, deleteIdentity) diff --git a/api/src/identities/service.ts b/api/src/identities/service.ts new file mode 100644 index 0000000..04337ab --- /dev/null +++ b/api/src/identities/service.ts @@ -0,0 +1,103 @@ +// Synchronize the copies of identity data (names on senders, recipients, owners, originators) +// with the users/organizations directory, and remove them when an identity is deleted. +// Everything is done with bulk updates: an organization can own tens of thousands of events and +// simple-directory waits for the response. Names are also part of the events search texts: the +// events are only flagged here (_needsSearch) and the search worker rebuilds the texts. + +import { randomUUID } from 'node:crypto' +import type { IdentityUpdate, IdentityDelete } from '@data-fair/lib-express/identities/index.js' +import mongo from '#mongo' + +export const updateIdentity = async (identity: IdentityUpdate) => { + const { type, id, name, departments } = identity + + if (type === 'user') { + await mongo.notifications.updateMany({ 'recipient.id': id }, { $set: { 'recipient.name': name } }) + await mongo.subscriptions.updateMany({ 'recipient.id': id }, { $set: { 'recipient.name': name } }) + } + // notifications are snapshots taken at delivery, their sender name is not rewritten (no index on sender) + await mongo.subscriptions.updateMany({ 'sender.type': type, 'sender.id': id }, { $set: { 'sender.name': name } }) + await mongo.pushSubscriptions.updateMany({ 'owner.type': type, 'owner.id': id }, { $set: { 'owner.name': name } }) + await mongo.webhookSubscriptions.updateMany({ 'sender.type': type, 'sender.id': id }, { $set: { 'sender.name': name } }) + await mongo.webhookSubscriptions.updateMany({ 'owner.type': type, 'owner.id': id }, { $set: { 'owner.name': name } }) + if (departments) { + for (const department of departments.filter(d => !!d.name)) { + await mongo.subscriptions.updateMany({ 'sender.type': type, 'sender.id': id, 'sender.department': department.id }, { $set: { 'sender.name': name, 'sender.departmentName': department.name } }) + await mongo.pushSubscriptions.updateMany({ 'owner.type': type, 'owner.id': id, 'owner.department': department.id }, { $set: { 'owner.name': name, 'owner.departmentName': department.name } }) + await mongo.webhookSubscriptions.updateMany({ 'sender.type': type, 'sender.id': id, 'sender.department': department.id }, { $set: { 'sender.name': name, 'sender.departmentName': department.name } }) + await mongo.webhookSubscriptions.updateMany({ 'owner.type': type, 'owner.id': id, 'owner.department': department.id }, { $set: { 'owner.name': name, 'owner.departmentName': department.name } }) + } + // the directory sends the complete list of departments: a department missing from it was + // deleted, what it owns keeps the id (still reachable by the organization admins) but not the name + const deletedDepartment = { $exists: true, $nin: departments.map(d => d.id) } + await mongo.subscriptions.updateMany({ 'sender.type': type, 'sender.id': id, 'sender.department': deletedDepartment }, { $unset: { 'sender.departmentName': 1 } }) + await mongo.pushSubscriptions.updateMany({ 'owner.type': type, 'owner.id': id, 'owner.department': deletedDepartment }, { $unset: { 'owner.departmentName': 1 } }) + await mongo.webhookSubscriptions.updateMany({ 'sender.type': type, 'sender.id': id, 'sender.department': deletedDepartment }, { $unset: { 'sender.departmentName': 1 } }) + await mongo.webhookSubscriptions.updateMany({ 'owner.type': type, 'owner.id': id, 'owner.department': deletedDepartment }, { $unset: { 'owner.departmentName': 1 } }) + } + + // events: as sender, and as the user or organization that triggered them + // only the events whose name actually changes: simple-directory also posts on every membership change. + // A fresh _needsSearch per update: the search worker only clears the value it read. + await mongo.events.updateMany({ 'sender.type': type, 'sender.id': id, 'sender.name': { $ne: name } }, { $set: { 'sender.name': name, _needsSearch: randomUUID() } }) + if (departments) { + for (const department of departments.filter(d => !!d.name)) { + await mongo.events.updateMany({ 'sender.type': type, 'sender.id': id, 'sender.department': department.id, 'sender.departmentName': { $ne: department.name } }, { $set: { 'sender.departmentName': department.name } }) + await mongo.events.updateMany({ 'originator.organization.id': id, 'originator.organization.department': department.id, 'originator.organization.departmentName': { $ne: department.name } }, { $set: { 'originator.organization.departmentName': department.name } }) + } + const deletedDepartment = { $exists: true, $nin: departments.map(d => d.id) } + await mongo.events.updateMany({ 'sender.type': type, 'sender.id': id, 'sender.department': deletedDepartment }, { $unset: { 'sender.departmentName': 1 } }) + await mongo.events.updateMany({ 'originator.organization.id': id, 'originator.organization.department': deletedDepartment }, { $unset: { 'originator.organization.departmentName': 1 } }) + } + if (type === 'user') { + await mongo.events.updateMany({ 'originator.user.id': id, 'originator.user.name': { $ne: name } }, { $set: { 'originator.user.name': name, _needsSearch: randomUUID() } }) + } else { + await mongo.events.updateMany({ 'originator.organization.id': id, 'originator.organization.name': { $ne: name } }, { $set: { 'originator.organization.name': name, _needsSearch: randomUUID() } }) + } + + if (type === 'user' && identity.organizations) { + const privateSubscriptionFilter = { + 'recipient.id': id, + visibility: { $ne: 'public' as const }, + 'sender.type': 'organization' + } + for await (const privateSubscription of mongo.subscriptions.find(privateSubscriptionFilter)) { + let userOrg = identity.organizations.find(o => o.id === privateSubscription.sender?.id && !o.department) + if (privateSubscription.sender?.department) { + userOrg = userOrg || identity.organizations.find(o => o.id === privateSubscription.sender?.id && o.department === privateSubscription.sender.department) + } + if (userOrg && privateSubscription.sender?.role && userOrg.role !== privateSubscription.sender.role && userOrg.role !== 'admin') { + userOrg = undefined + } + if (!userOrg) { + // remove private subscription that does not match user orgs anymore + await mongo.subscriptions.deleteOne({ _id: privateSubscription._id }) + } + } + } +} + +export const deleteIdentity = async (identity: IdentityDelete) => { + const { type, id } = identity + + if (type === 'user') { + await mongo.notifications.deleteMany({ 'recipient.id': id }) + await mongo.subscriptions.deleteMany({ 'recipient.id': id }) + await mongo.pointers.deleteMany({ 'recipient.id': id }) + } + await mongo.subscriptions.deleteMany({ 'sender.type': type, 'sender.id': id }) + await mongo.pushSubscriptions.deleteMany({ 'owner.type': type, 'owner.id': id }) + await mongo.webhookSubscriptions.deleteMany({ 'owner.type': type, 'owner.id': id }) + await mongo.webhookSubscriptions.deleteMany({ 'sender.type': type, 'sender.id': id }) + // pending or failed webhooks of the deleted subscriptions + await mongo.webhooks.deleteMany({ 'owner.type': type, 'owner.id': id }) + await mongo.webhooks.deleteMany({ 'sender.type': type, 'sender.id': id }) + + // the events of the identity are its own feed, nobody else can read them + await mongo.events.deleteMany({ 'sender.type': type, 'sender.id': id }) + // the events a user triggered on other feeds keep the trace of the action without the person: + // only the id remains (pseudonymized), an organization is not personal data and is left as is + if (type === 'user') { + await mongo.events.updateMany({ 'originator.user.id': id }, { $set: { _needsSearch: randomUUID() }, $unset: { 'originator.user.name': 1, 'originator.user.email': 1 } }) + } +} diff --git a/api/src/mongo.ts b/api/src/mongo.ts index 6f1e76b..58afb01 100644 --- a/api/src/mongo.ts +++ b/api/src/mongo.ts @@ -52,7 +52,12 @@ export class EventsMongo { 'main-keys': [ { 'sender.type': 1, 'sender.id': 1, '_search.text': 'text', date: -1 }, { default_language: config.i18n.defaultLocale } - ] + ], + // identity webhooks rewrite the events triggered by a user or an organization + 'originator-user': [{ 'originator.user.id': 1 }, { sparse: true }], + 'originator-organization': [{ 'originator.organization.id': 1 }, { sparse: true }], + // the events waiting for the search worker, empty most of the time + 'needs-search': [{ _needsSearch: 1 }, { partialFilterExpression: { _needsSearch: { $exists: true } } }] }, subscriptions: { 'main-keys': [ @@ -75,6 +80,8 @@ export class EventsMongo { }, webhooks: { 'main-keys': { 'owner.type': 1, 'owner.id': 1, 'subscription._id': 1, 'notification.date': 1 }, + // identity webhooks drop the webhooks of a deleted sender + 'sender-keys': { 'sender.type': 1, 'sender.id': 1 }, 'loop-keys': { status: 1, nextAttempt: 1 }, 'coalesce-keys': { 'subscription._id': 1, 'notification.topic.key': 1 } }, diff --git a/api/src/server.ts b/api/src/server.ts index ca8144d..f914826 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -12,6 +12,7 @@ import { createHttpTerminator } from 'http-terminator' import app from './app.ts' import config from '#config' import * as webhooksWorker from './webhooks/worker.ts' +import * as searchWorker from './events/search-worker.ts' import * as pushService from './push/service.ts' const server = createServer(app) @@ -41,6 +42,7 @@ export const start = async () => { await wsEmitter.init(mongo.db) await pushService.init() await webhooksWorker.start() + searchWorker.start() server.listen(config.port) await new Promise(resolve => server.once('listening', resolve)) @@ -51,6 +53,7 @@ export const start = async () => { export const stop = async () => { await httpTerminator.terminate() await webhooksWorker.stop() + await searchWorker.stop() await wsServer.stop() if (config.observer.active) await stopObserver() await locks.stop() diff --git a/api/types/index.ts b/api/types/index.ts index 36996c2..fef588e 100644 --- a/api/types/index.ts +++ b/api/types/index.ts @@ -10,4 +10,5 @@ export type { DeviceRegistration } from './device-registration/index.js' export type FullEvent = Event & Required> export type LocalizedEvent = Omit & { title: string, body?: string, htmlBody?: string } -export type SearchableEvent = FullEvent & { _search: { language: string, text: string }[] } +// _needsSearch: set by the identity webhooks when a name changed, cleared by the search worker +export type SearchableEvent = FullEvent & { _search: { language: string, text: string }[], _needsSearch?: string } diff --git a/docker-compose.yml b/docker-compose.yml index 316edb2..c29f10c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,8 @@ services: DEBUG: session ADMINS: '["superadmin@test.com"]' PUBLIC_URL: http://${DEV_HOST}:${NGINX_PORT}/simple-directory + # in dev simple-directory only notifies this service, directly (the shared identities router refuses calls through the proxy) + IDENTITIES_WEBHOOKS: '[{"base":"http://localhost:${DEV_API_PORT}/api/identities","key":"SECRET_IDENTITIES"}]' MAILDEV_ACTIVE: "true" STORAGE_TYPE: file ROLES_DEFAULTS: '["admin", "contrib", "user"]' diff --git a/package-lock.json b/package-lock.json index 032a799..952c665 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "api": { "dependencies": { "@data-fair/lib-common-types": "^1.24.0", - "@data-fair/lib-express": "^1.22.5", + "@data-fair/lib-express": "^1.27.0", "@data-fair/lib-node": "^2.12.1", "@data-fair/lib-utils": "^1.14.0", "@data-fair/lib-validation": "^1.0.2", @@ -12355,8 +12355,8 @@ "dependencies": { "@data-fair/frame": "^0.18.7", "@data-fair/lib-utils": "^1.14.0", - "@data-fair/lib-vue": "^1.26.0", - "@data-fair/lib-vuetify": "^2.0.0", + "@data-fair/lib-vue": "^1.31.1", + "@data-fair/lib-vuetify": "^2.5.1", "@data-fair/lib-vuetify-events": "*", "@intlify/unplugin-vue-i18n": "^11.0.7", "@mdi/js": "^7.4.47", diff --git a/package.json b/package.json index b4a2ab2..af213c7 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ }, "relativeDependencies": { "@data-fair/lib-express": "../lib/packages/express", - "@data-fair/lib-vue": "../lib/packages/vue" + "@data-fair/lib-vue": "../lib/packages/vue", + "@data-fair/lib-vuetify": "../lib/packages/vuetify" } } diff --git a/tests/identities-webhook.api.spec.ts b/tests/identities-webhook.api.spec.ts index b155bce..5d1cd58 100644 --- a/tests/identities-webhook.api.spec.ts +++ b/tests/identities-webhook.api.spec.ts @@ -7,6 +7,7 @@ const axIdentities = axios({ params: { key: 'SECRET_IDENTITIES' }, baseURL: devB const axPush = axios({ params: { key: 'SECRET_EVENTS' }, baseURL: devBaseURL }) const user1 = await axiosAuth('test-user1') const admin1 = await axiosAuth('test1-admin1') +admin1.setOrg('test1') test.describe('identities webhooks', () => { test.beforeEach(clean) @@ -74,9 +75,111 @@ test.describe('identities webhooks', () => { }) }) +const postEvents = async () => { + await axPush.post('/api/events', [{ + date: new Date().toISOString(), + topic: { key: 'topic1' }, + title: 'own feed event', + sender: { type: 'user', id: 'test-user1', name: 'Zéphyrine Dubois' }, + originator: { user: { id: 'test-user1', name: 'Zéphyrine Dubois' } } + }, { + date: new Date().toISOString(), + topic: { key: 'topic1' }, + title: 'organization feed event', + sender: { type: 'organization', id: 'test1', name: 'Test Organization 1' }, + originator: { user: { id: 'test-user1', name: 'Zéphyrine Dubois' }, organization: { id: 'test1', name: 'Test Organization 1' } } + }]) +} + +test.describe('identities update webhook on events', () => { + test.beforeEach(clean) + + test('should rename a department and forget the name of a deleted one', async () => { + const subscription = (await admin1.post('/api/subscriptions', { + topic: { key: 'topic1' }, + sender: { type: 'organization', id: 'test1', name: 'Test Organization 1', department: 'dep1', departmentName: 'Department 1' }, + visibility: 'private' + })).data + await axPush.post('/api/events', [{ + date: new Date().toISOString(), + topic: { key: 'topic1' }, + title: 'department feed event', + sender: { type: 'organization', id: 'test1', name: 'Test Organization 1', department: 'dep1', departmentName: 'Department 1' } + }]) + + await axIdentities.post('/api/identities/organization/test1', { name: 'Test Organization 1', departments: [{ id: 'dep1', name: 'Renamed Department' }] }) + let fresh = (await admin1.get('/api/subscriptions')).data.results.find((s: Subscription) => s._id === subscription._id) + expect(fresh.sender.departmentName).toBe('Renamed Department') + let events = (await admin1.get('/api/events')).data.results + expect(events[0].sender.departmentName).toBe('Renamed Department') + + // dep1 is missing from the complete list of departments: it was deleted, only its id remains + await axIdentities.post('/api/identities/organization/test1', { name: 'Test Organization 1', departments: [] }) + fresh = (await admin1.get('/api/subscriptions')).data.results.find((s: Subscription) => s._id === subscription._id) + expect(fresh.sender.department).toBe('dep1') + expect(fresh.sender.departmentName).toBeUndefined() + events = (await admin1.get('/api/events')).data.results + expect(events[0].sender.department).toBe('dep1') + expect(events[0].sender.departmentName).toBeUndefined() + }) + + test('should rename the sender and originator of events and let the search worker rebuild the search texts', async () => { + await postEvents() + await axIdentities.post('/api/identities/user/test-user1', { name: 'Aurélien Lefort' }) + + const ownEvents = (await user1.get('/api/events')).data.results + expect(ownEvents).toHaveLength(1) + expect(ownEvents[0].sender.name).toBe('Aurélien Lefort') + expect(ownEvents[0].originator.user.name).toBe('Aurélien Lefort') + // the search texts are rebuilt by the search worker, after the webhook responded + await expect.poll(async () => (await user1.get('/api/events?q=Lefort')).data.results.length, { timeout: 10000 }).toBe(1) + expect((await user1.get('/api/events?q=Dubois')).data.results).toHaveLength(0) + + let orgEvents = (await admin1.get('/api/events')).data.results + expect(orgEvents).toHaveLength(1) + expect(orgEvents[0].originator.user.name).toBe('Aurélien Lefort') + + await axIdentities.post('/api/identities/organization/test1', { name: 'Renamed Organization 1' }) + orgEvents = (await admin1.get('/api/events')).data.results + expect(orgEvents[0].sender.name).toBe('Renamed Organization 1') + expect(orgEvents[0].originator.organization.name).toBe('Renamed Organization 1') + await expect.poll(async () => (await admin1.get('/api/events?q=Renamed')).data.results.length, { timeout: 10000 }).toBe(1) + }) + + test('should end up with the last name when renames follow each other', async () => { + await postEvents() + await axIdentities.post('/api/identities/organization/test1', { name: 'Intermediate Name' }) + await axIdentities.post('/api/identities/organization/test1', { name: 'Final Name' }) + await expect.poll(async () => (await admin1.get('/api/events?q=Final')).data.results.length, { timeout: 10000 }).toBe(1) + expect((await admin1.get('/api/events?q=Intermediate')).data.results).toHaveLength(0) + }) +}) + test.describe('identities delete webhook', () => { test.beforeEach(clean) + test('should delete the events of a deleted user and pseudonymize the ones it triggered elsewhere', async () => { + await postEvents() + expect((await admin1.get('/api/events?q=Dubois')).data.results).toHaveLength(1) + await axIdentities.delete('/api/identities/user/test-user1') + + expect((await user1.get('/api/events')).data.results).toHaveLength(0) + const orgEvents = (await admin1.get('/api/events')).data.results + expect(orgEvents).toHaveLength(1) + expect(orgEvents[0].originator.user).toEqual({ id: 'test-user1' }) + expect(orgEvents[0].originator.organization.name).toBe('Test Organization 1') + // the name also leaves the search texts, once the search worker went through + await expect.poll(async () => (await admin1.get('/api/events?q=Dubois')).data.results.length, { timeout: 10000 }).toBe(0) + expect((await admin1.get('/api/events?q=test-user1')).data.results).toHaveLength(1) + }) + + test('should delete the events of a deleted organization', async () => { + await postEvents() + await axIdentities.delete('/api/identities/organization/test1') + expect((await admin1.get('/api/events')).data.results).toHaveLength(0) + expect((await user1.get('/api/events')).data.results).toHaveLength(1) + }) + test('should delete subscriptions and notifications of a deleted user before responding', async () => { await user1.post('/api/subscriptions', { topic: { key: 'topic1' }, diff --git a/ui/package.json b/ui/package.json index b373698..47175fa 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,8 +14,8 @@ "dependencies": { "@data-fair/frame": "^0.18.7", "@data-fair/lib-utils": "^1.14.0", - "@data-fair/lib-vue": "^1.26.0", - "@data-fair/lib-vuetify": "^2.0.0", + "@data-fair/lib-vue": "^1.31.1", + "@data-fair/lib-vuetify": "^2.5.1", "@data-fair/lib-vuetify-events": "*", "@intlify/unplugin-vue-i18n": "^11.0.7", "@mdi/js": "^7.4.47", diff --git a/ui/src/pages/embed/events.vue b/ui/src/pages/embed/events.vue index 7366e2c..ccd1fa8 100644 --- a/ui/src/pages/embed/events.vue +++ b/ui/src/pages/embed/events.vue @@ -76,7 +76,7 @@ />