From 4cf6a1104187011b6007ea517d65d4211eedcf27 Mon Sep 17 00:00:00 2001 From: Artem Savchenko Date: Wed, 8 Apr 2026 13:37:39 +0700 Subject: [PATCH] Hide meeting minutes from guests Signed-off-by: Artem Savchenko --- dev/tool/src/index.ts | 26 +- foundations/core/packages/core/src/classes.ts | 5 + .../src/guestCollaboratorClassRead.ts | 110 ++++++++ .../server/packages/middleware/src/index.ts | 1 + .../tests/guestCollaboratorClassRead.test.ts | 262 ++++++++++++++++++ .../server/packages/postgres/src/storage.ts | 10 +- models/core/src/core.ts | 1 + models/love/src/index.ts | 3 +- models/love/src/migration.ts | 16 +- .../components/MeetingMinutesSection.svelte | 4 +- server/server-pipeline/src/pipeline.ts | 2 + ws-tests/api-tests/src/__tests__/rest.test.ts | 58 ++++ ws-tests/api-tests/src/loveApiRefs.ts | 17 ++ ws-tests/prepare.sh | 5 + 14 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 foundations/server/packages/middleware/src/guestCollaboratorClassRead.ts create mode 100644 foundations/server/packages/middleware/src/tests/guestCollaboratorClassRead.test.ts create mode 100644 ws-tests/api-tests/src/loveApiRefs.ts diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index 56acb52fea..3f26bae7d8 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -331,10 +331,30 @@ export function devTool ( // }) // }) + function parseAccountRole (raw: unknown): AccountRole { + const rawRole = typeof raw === 'string' ? raw.trim() : String(raw ?? 'User').trim() + const normalized = rawRole.trim().toLowerCase() + + const match = Object.values(AccountRole).find( + (v) => v.toLowerCase() === normalized + ) + if (match !== undefined) return match + + switch (normalized) { + case 'readonly': + return AccountRole.ReadOnlyGuest + case 'docguest': + return AccountRole.DocGuest + default: + throw new Error(`Unknown role: ${rawRole}`) + } + } + program .command('assign-workspace ') .description('assign workspace') - .action(async (email: string, workspace: string, cmd) => { + .option('--role ', 'Workspace role (User, Guest, ReadOnlyGuest, DocGuest, Maintainer, Owner, Admin)', 'User') + .action(async (email: string, workspace: string, cmd: { role: string }) => { await withAccountDatabase(async (db) => { console.log(`assigning user ${email} to ${workspace}...`) try { @@ -343,10 +363,12 @@ export function devTool ( throw new Error(`Workspace ${workspace} not found`) } + const role = cmd.role != null ? parseAccountRole(cmd.role) : AccountRole.User + await assignWorkspace(toolCtx, db, null, getToolToken(), { email, workspaceUuid: ws.uuid, - role: AccountRole.User + role }) } catch (err: any) { console.error(err) diff --git a/foundations/core/packages/core/src/classes.ts b/foundations/core/packages/core/src/classes.ts index 2b8c9954cf..0b29a5257f 100644 --- a/foundations/core/packages/core/src/classes.ts +++ b/foundations/core/packages/core/src/classes.ts @@ -985,6 +985,11 @@ export interface ClassCollaborators extends Doc { fields: (keyof T)[] // PersonId | Ref | PersonId[] | Ref[] provideSecurity?: boolean // If true, will provide security for collaborators provideAttachedSecurity?: boolean // If true, will provide security for collaborators of attached doc + /** + * When true with provideSecurity, workspace guests may read instances of this class only as collaborators, + * not via space membership alone (see guest collaborator read middleware / Postgres addSecurity). + */ + guestReadCollaboratorOnly?: boolean } export interface Collaborator extends AttachedDoc { diff --git a/foundations/server/packages/middleware/src/guestCollaboratorClassRead.ts b/foundations/server/packages/middleware/src/guestCollaboratorClassRead.ts new file mode 100644 index 0000000000..25df1d8c2c --- /dev/null +++ b/foundations/server/packages/middleware/src/guestCollaboratorClassRead.ts @@ -0,0 +1,110 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { + AccountRole, + type Class, + type Collaborator, + type Doc, + type DocumentQuery, + type FindResult, + getClassCollaborators, + type MeasureContext, + type Ref, + type SessionData, + systemAccountUuid +} from '@hcengineering/core' +import { + BaseMiddleware, + type Middleware, + type PipelineContext, + type ServerFindOptions +} from '@hcengineering/server-core' + +/** Intersects a find query with _id ∈ allowed (empty → no matches). Ref[] matches DocumentQuery _id $in. */ +function mergeDocIdRestriction (query: DocumentQuery, allowed: Ref[]): DocumentQuery { + const allowedIds: DocumentQuery['_id'] = { $in: allowed.length === 0 ? [] : allowed } + const prevId = query._id + if (prevId === undefined) { + return { ...query, _id: allowedIds } + } + type WithAnd = DocumentQuery & { $and?: DocumentQuery[] } + const { _id: _drop, $and, ...rest } = query as WithAnd + const andParts: DocumentQuery[] = [ + ...($and ?? []), + { _id: prevId }, + { _id: allowedIds } + ] + // Spreading rest + $and is not inferred as DocumentQuery (mapped type + index signature). + const merged: DocumentQuery = { ...rest, $and: andParts } + return merged +} + +/** + * Restricts findAll for classes with ClassCollaborators.guestReadCollaboratorOnly so that + * Guest / ReadOnlyGuest only receive documents they are collaborators on (Mongo and any adapter + * without SQL collaborator OR-clauses). + */ +export class GuestCollaboratorClassReadMiddleware extends BaseMiddleware implements Middleware { + static async create ( + ctx: MeasureContext, + context: PipelineContext, + next: Middleware | undefined + ): Promise { + return new GuestCollaboratorClassReadMiddleware(context, next) + } + + override async findAll( + ctx: MeasureContext, + _class: Ref>, + query: DocumentQuery, + options?: ServerFindOptions + ): Promise> { + const session = ctx.contextData + if (session?.isTriggerCtx === true) { + return await this.provideFindAll(ctx, _class, query, options) + } + const account = session?.account + if (account === undefined || account.uuid === systemAccountUuid) { + return await this.provideFindAll(ctx, _class, query, options) + } + if (![AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(account.role)) { + return await this.provideFindAll(ctx, _class, query, options) + } + const collabSec = getClassCollaborators(this.context.modelDb, this.context.hierarchy, _class) + if (collabSec?.provideSecurity !== true || collabSec?.guestReadCollaboratorOnly !== true) { + return await this.provideFindAll(ctx, _class, query, options) + } + const rootClass = collabSec.attachedTo + const docClasses = [...this.context.hierarchy.getDescendants(rootClass), rootClass] + const collabQuery: DocumentQuery = { + collaborator: account.uuid, + attachedToClass: { $in: docClasses } + } + const collabs = await this.provideFindAll(ctx, core.class.Collaborator, collabQuery, { + projection: { attachedTo: 1 }, + limit: 10_000 + }) + const allowed = collabs.map((c) => c.attachedTo) as Ref[] + const newQuery = mergeDocIdRestriction(query, allowed) + if (collabs.length >= 10_000) { + ctx.warn('Guest collaborator id list truncated at 10000; find may miss rows', { + account: account.uuid, + _class + }) + } + return await this.provideFindAll(ctx, _class, newQuery, options) + } +} diff --git a/foundations/server/packages/middleware/src/index.ts b/foundations/server/packages/middleware/src/index.ts index 061b25ab56..d9c1852ec8 100644 --- a/foundations/server/packages/middleware/src/index.ts +++ b/foundations/server/packages/middleware/src/index.ts @@ -31,6 +31,7 @@ export * from './modified' export * from './private' export * from './queryJoin' export * from './guestPermissions' +export * from './guestCollaboratorClassRead' export * from './identifier' export * from './spacePermissions' export * from './spaceSecurity' diff --git a/foundations/server/packages/middleware/src/tests/guestCollaboratorClassRead.test.ts b/foundations/server/packages/middleware/src/tests/guestCollaboratorClassRead.test.ts new file mode 100644 index 0000000000..864cbfc128 --- /dev/null +++ b/foundations/server/packages/middleware/src/tests/guestCollaboratorClassRead.test.ts @@ -0,0 +1,262 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { + AccountRole, + type Account, + type Class, + type Collaborator, + type Doc, + type DomainResult, + generateId, + type Hierarchy, + MeasureMetricsContext, + type MeasureContext, + type PersonId, + type Ref, + type SearchResult, + type SessionData, + systemAccountUuid, + Timestamp, + toFindResult +} from '@hcengineering/core' +import type { Middleware, PipelineContext } from '@hcengineering/server-core' +import { GuestCollaboratorClassReadMiddleware } from '../guestCollaboratorClassRead' + +const MEETING_MINUTES_CLASS = 'test:love:class:MeetingMinutes' as Ref> +const DOC_CLASS = core.class.Doc + +function makeAccount (role: AccountRole, uuid?: ReturnType): Account { + return { + uuid: (uuid ?? generateId()) as Account['uuid'], + role, + primarySocialId: 'test-social' as PersonId, + socialIds: ['test-social' as PersonId], + fullSocialIds: [] + } +} + +function makeCtx (account: Account, extra?: Partial): MeasureContext { + const ctx = new MeasureMetricsContext('test', {}) as MeasureContext + ctx.contextData = { + account, + broadcast: { txes: [], queue: [], sessions: {} }, + ...extra + } as SessionData + return ctx +} + +function makeCollaboratorDoc (attachedTo: Ref, collaborator: Account['uuid']): Collaborator { + return { + _id: generateId(), + _class: core.class.Collaborator, + space: core.space.Workspace, + attachedTo, + attachedToClass: MEETING_MINUTES_CLASS, + collection: 'collaborators', + collaborator, + modifiedOn: Date.now(), + modifiedBy: core.account.System + } +} + +function makePipelineContext (): PipelineContext { + const collaboratorsMixin = { + _id: generateId(), + _class: core.class.ClassCollaborators, + space: core.space.Model, + attachedTo: MEETING_MINUTES_CLASS, + fields: ['createdBy'], + provideSecurity: true, + guestReadCollaboratorOnly: true, + modifiedOn: Date.now(), + modifiedBy: core.account.System + } as Doc + + const hierarchy = { + getAncestors: (c: Ref>) => [c, DOC_CLASS], + getDescendants: (_c: Ref>) => [] as Ref>[] + } as unknown as Hierarchy + + const modelDb = { + findAllSync: (cl: Ref>, query: { attachedTo?: { $in?: Ref>[] } }) => { + if (cl !== core.class.ClassCollaborators) return [] + const ids = query.attachedTo?.$in ?? [] + return ids.includes(MEETING_MINUTES_CLASS) ? [collaboratorsMixin] : [] + } + } as PipelineContext['modelDb'] + + return { + workspace: { uuid: generateId() as any, url: 'test', dataId: 'test' as any }, + hierarchy, + modelDb, + branding: null as any, + adapterManager: {} as any, + storageAdapter: {} as any, + contextVars: {}, + lastTx: '' as Timestamp, + lastHash: '', + broadcastEvent: async () => {} + } as PipelineContext +} + +function stubMiddleware (): Middleware { + return { + findAll: async () => toFindResult([]), + tx: async () => ({}), + groupBy: async () => new Map(), + searchFulltext: async () => ({ docs: [] }) as SearchResult, + handleBroadcast: async () => {}, + loadModel: async () => [], + domainRequest: async () => ({ value: undefined }) as DomainResult, + closeSession: async () => {}, + close: async () => {} + } satisfies Middleware +} + +describe('GuestCollaboratorClassReadMiddleware', () => { + const MM_ID = generateId() as Ref + + it('User: passes original query in a single findAll', async () => { + const captured: Array<{ cls: string; query: unknown }> = [] + const next: Middleware = { + ...stubMiddleware(), + findAll: async (ctx, _class, query, options) => { + captured.push({ cls: _class as string, query: { ...query } }) + return toFindResult([]) + } + } + const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next) + const ctx = makeCtx(makeAccount(AccountRole.User)) + await mw.findAll(ctx, MEETING_MINUTES_CLASS, { attachedTo: MM_ID }) + expect(captured).toHaveLength(1) + expect(captured[0].cls).toBe(MEETING_MINUTES_CLASS) + expect(captured[0].query).toEqual({ attachedTo: MM_ID }) + }) + + it('Guest: loads collaborators then restricts MeetingMinutes to collaborator attachedTo ids', async () => { + const guest = makeAccount(AccountRole.Guest) + const collabDoc = makeCollaboratorDoc(MM_ID, guest.uuid) + const captured: Array<{ cls: string; query: unknown }> = [] + const next: Middleware = { + ...stubMiddleware(), + findAll: async (c, _class, query) => { + captured.push({ cls: _class as string, query: JSON.parse(JSON.stringify(query)) }) + if (_class === core.class.Collaborator) { + return toFindResult([collabDoc]) + } + return toFindResult([]) + } + } + const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next) + const ctx = makeCtx(guest) + await mw.findAll(ctx, MEETING_MINUTES_CLASS, { space: core.space.Workspace }) + expect(captured).toHaveLength(2) + expect(captured[0].cls).toBe(core.class.Collaborator) + expect((captured[0].query as any).collaborator).toBe(guest.uuid) + expect(captured[1].cls).toBe(MEETING_MINUTES_CLASS) + expect((captured[1].query as any)._id).toEqual({ $in: [MM_ID] }) + }) + + it('Guest: empty collaborator list yields _id $in []', async () => { + const guest = makeAccount(AccountRole.Guest) + const captured: Array<{ cls: string; query: unknown }> = [] + const next: Middleware = { + ...stubMiddleware(), + findAll: async (c, _class, query) => { + captured.push({ cls: _class as string, query: JSON.parse(JSON.stringify(query)) }) + if (_class === core.class.Collaborator) { + return toFindResult([]) + } + return toFindResult([]) + } + } + const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next) + const ctx = makeCtx(guest) + await mw.findAll(ctx, MEETING_MINUTES_CLASS, {}) + expect(captured).toHaveLength(2) + const mmQuery = captured[1].query as { _id: { $in: unknown[] } } + expect(mmQuery._id).toEqual({ $in: [] }) + }) + + it('Guest: without guestReadCollaboratorOnly on class config, query is unchanged', async () => { + const bareContext = { + ...makePipelineContext(), + modelDb: { + findAllSync: () => [] + } as PipelineContext['modelDb'] + } as PipelineContext + + const captured: unknown[] = [] + const next: Middleware = { + ...stubMiddleware(), + findAll: async (c, _class, query) => { + captured.push(query) + return toFindResult([]) + } + } + const mw = new GuestCollaboratorClassReadMiddleware(bareContext, next) + const ctx = makeCtx(makeAccount(AccountRole.Guest)) + await mw.findAll(ctx, MEETING_MINUTES_CLASS, { space: core.space.Workspace }) + expect(captured).toHaveLength(1) + expect(captured[0]).toEqual({ space: core.space.Workspace }) + }) + + it('system account: no collaborator prefetch', async () => { + let calls = 0 + const next: Middleware = { + ...stubMiddleware(), + findAll: async () => { + calls++ + return toFindResult([]) + } + } + const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next) + const ctx = makeCtx({ + uuid: systemAccountUuid, + role: AccountRole.Owner, + primarySocialId: core.account.System, + socialIds: [core.account.System], + fullSocialIds: [] + }) + await mw.findAll(ctx, MEETING_MINUTES_CLASS, {}) + expect(calls).toBe(1) + }) + + it('Guest: merges existing _id constraint with $and', async () => { + const guest = makeAccount(AccountRole.Guest) + const otherId = generateId() as Ref + const collabDoc = makeCollaboratorDoc(MM_ID, guest.uuid) + const captured: unknown[] = [] + const next: Middleware = { + ...stubMiddleware(), + findAll: async (c, _class, query) => { + captured.push(JSON.parse(JSON.stringify(query))) + if (_class === core.class.Collaborator) { + return toFindResult([collabDoc]) + } + return toFindResult([]) + } + } + const mw = new GuestCollaboratorClassReadMiddleware(makePipelineContext(), next) + const ctx = makeCtx(guest) + await mw.findAll(ctx, MEETING_MINUTES_CLASS, { _id: otherId }) + const mmQuery = captured[1] as any + expect(mmQuery.$and).toBeDefined() + expect(mmQuery.$and).toEqual( + expect.arrayContaining([{ _id: otherId }, { _id: { $in: [MM_ID] } }]) + ) + }) +}) diff --git a/foundations/server/packages/postgres/src/storage.ts b/foundations/server/packages/postgres/src/storage.ts index c2fec29622..96b49f5b61 100644 --- a/foundations/server/packages/postgres/src/storage.ts +++ b/foundations/server/packages/postgres/src/storage.ts @@ -635,9 +635,15 @@ abstract class PostgresAdapterBase implements DbAdapter { const privateCheck = domain === DOMAIN_SPACE ? ' OR sec.private = false' : '' const archivedCheck = showArchived ? '' : ' AND sec.archived = false' const q = `(sec._id = '${core.space.Space}' OR sec."_class" = '${core.class.SystemSpace}' OR sec.members @> '{"${acc.uuid}"}'${privateCheck})${archivedCheck}` - const res = `EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_SPACE)} sec WHERE sec._id = ${domain}.${key} AND sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND ${q})` - const collabSec = getClassCollaborators(this.modelDb, this.hierarchy, _class) + const guestCollaboratorOnly = + [AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role) && + collabSec?.provideSecurity === true && + collabSec?.guestReadCollaboratorOnly === true + const res = guestCollaboratorOnly + ? 'false' + : `EXISTS (SELECT 1 FROM ${translateDomain(DOMAIN_SPACE)} sec WHERE sec._id = ${domain}.${key} AND sec."workspaceId" = ${vars.add(this.workspaceId, '::uuid')} AND ${q})` + let collabRes = '' if ([AccountRole.Guest, AccountRole.ReadOnlyGuest].includes(acc.role)) { if (collabSec?.provideSecurity === true) { diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 0866c91bb7..cd95196b74 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -436,6 +436,7 @@ export class TClassCollaborators extends TDoc implements ClassCollaborators fields!: (keyof Doc)[] provideSecurity?: boolean provideAttachedSecurity?: boolean + guestReadCollaboratorOnly?: boolean } @Model(core.class.Collaborator, core.class.Doc, DOMAIN_COLLABORATOR) diff --git a/models/love/src/index.ts b/models/love/src/index.ts index 343c2aa218..85f314722f 100644 --- a/models/love/src/index.ts +++ b/models/love/src/index.ts @@ -644,7 +644,8 @@ export function createModel (builder: Builder): void { builder.createDoc>(core.class.ClassCollaborators, core.space.Model, { attachedTo: love.class.MeetingMinutes, fields: ['createdBy'], - provideSecurity: true + provideSecurity: true, + guestReadCollaboratorOnly: true }) builder.mixin(love.class.Room, core.class.Class, core.mixin.IndexConfiguration, { diff --git a/models/love/src/migration.ts b/models/love/src/migration.ts index ca4779c808..c0d4020efd 100644 --- a/models/love/src/migration.ts +++ b/models/love/src/migration.ts @@ -14,7 +14,7 @@ // import contact from '@hcengineering/contact' -import { TxOperations, type Ref, type Space } from '@hcengineering/core' +import { DOMAIN_MODEL, TxOperations, type Ref, type Space } from '@hcengineering/core' import drive from '@hcengineering/drive' import { MeetingStatus, @@ -179,6 +179,20 @@ export const loveOperation: MigrateOperation = { func: async (client) => { await client.reindex(DOMAIN_MEETING_MINUTES, [love.class.MeetingMinutes]) } + }, + { + state: 'meeting-minutes-guest-collaborator-read', + mode: 'upgrade', + func: async (client) => { + await client.update( + DOMAIN_MODEL, + { + _class: core.class.ClassCollaborators, + attachedTo: love.class.MeetingMinutes + }, + { guestReadCollaboratorOnly: true } + ) + } } ]) }, diff --git a/plugins/love-resources/src/components/MeetingMinutesSection.svelte b/plugins/love-resources/src/components/MeetingMinutesSection.svelte index a07fdea3a3..41082974e6 100644 --- a/plugins/love-resources/src/components/MeetingMinutesSection.svelte +++ b/plugins/love-resources/src/components/MeetingMinutesSection.svelte @@ -33,8 +33,10 @@ let preference: ViewletPreference | undefined let loading = true + /** Full members see all minutes in context; guests see only rows the server allows (collaborator-only reads). */ let canViewMinutes: boolean = false - $: canViewMinutes = hasAccountRole(me, AccountRole.User) + $: canViewMinutes = + hasAccountRole(me, AccountRole.User) || me.role === AccountRole.Guest || me.role === AccountRole.ReadOnlyGuest {#if canViewMinutes} diff --git a/server/server-pipeline/src/pipeline.ts b/server/server-pipeline/src/pipeline.ts index 588d08d67e..8395ee45d9 100644 --- a/server/server-pipeline/src/pipeline.ts +++ b/server/server-pipeline/src/pipeline.ts @@ -28,6 +28,7 @@ import { DomainTxMiddleware, FindSecurityMiddleware, FullTextMiddleware, + GuestCollaboratorClassReadMiddleware, GuestPermissionsMiddleware, IdentityMiddleware, LiveQueryMiddleware, @@ -150,6 +151,7 @@ export function createServerPipeline ( PrivateMiddleware.create, (ctx: MeasureContext, context: PipelineContext, next?: Middleware) => SpaceSecurityMiddleware.create(opt.adapterSecurity ?? false, ctx, context, next), + GuestCollaboratorClassReadMiddleware.create, SpacePermissionsMiddleware.create, GuestPermissionsMiddleware.create, ConfigurationMiddleware.create, diff --git a/ws-tests/api-tests/src/__tests__/rest.test.ts b/ws-tests/api-tests/src/__tests__/rest.test.ts index 0c7bd1c65e..a3dbcb3848 100644 --- a/ws-tests/api-tests/src/__tests__/rest.test.ts +++ b/ws-tests/api-tests/src/__tests__/rest.test.ts @@ -23,6 +23,7 @@ import { type WorkspaceToken } from '@hcengineering/api-client' import core, { + AccountRole, buildSocialIdString, concatLink, generateId, @@ -45,6 +46,8 @@ import { type AccountClient, getClient as getAccountClient } from '@hcengineerin import chunter from '@hcengineering/chunter' import contact, { ensureEmployee, type SocialIdentityRef, type Person } from '@hcengineering/contact' import { generateToken } from '@hcengineering/server-token' + +import { loveClass, LoveMeetingStatus } from '../loveApiRefs' import WebSocket from 'ws' describe('rest-api-server', () => { @@ -338,6 +341,61 @@ describe('rest-api-server', () => { }) }, 20000) }) + + /** + * Requires a workspace account with AccountRole.Guest (invite / guest link) in the same workspace as `user1`. + * Set API_TESTS_GUEST_EMAIL and API_TESTS_GUEST_PASSWORD to enable. + */ + describe('guest meeting-minutes read', () => { + const guestEmail = 'guest1' + const guestPassword = '1234' + + it( + 'guest findAll does not return meeting minutes without collaborator', + async () => { + const userConn = connect() + const rooms = await userConn.findAll(loveClass.Room, {}, { limit: 1 }) + if (rooms.length === 0) { + throw new Error('No love Room in workspace — cannot seed MeetingMinutes') + } + const room = rooms[0] + + const tx = await connectTx() + const title = `api-test-mm-${generateId()}` + const mmId = await tx.createDoc(loveClass.MeetingMinutes, core.space.Workspace, { + title, + description: null, + attachedTo: room._id, + attachedToClass: loveClass.Room, + collection: 'meetings', + status: LoveMeetingStatus.Finished + }) + + try { + const userFound = await userConn.findAll(loveClass.MeetingMinutes, { _id: mmId }) + expect(userFound.length).toBe(1) + + const guestWs = await getWorkspaceToken( + 'http://huly.local:8083', + { + email: guestEmail, + password: guestPassword, + workspace: wsName + }, + serverConfig + ) + expect(guestWs.info.role).toBe(AccountRole.Guest) + + const guestConn = createRestClient(guestWs.endpoint, guestWs.workspaceId, guestWs.token) + const guestFound = await guestConn.findAll(loveClass.MeetingMinutes, { _id: mmId }) + expect(guestFound.length).toBe(0) + } finally { + await tx.removeDoc(loveClass.MeetingMinutes, core.space.Workspace, mmId) + } + }, + 60000 + ) + }) }) async function checkFindPerformance (conn: RestClient): Promise { diff --git a/ws-tests/api-tests/src/loveApiRefs.ts b/ws-tests/api-tests/src/loveApiRefs.ts new file mode 100644 index 0000000000..ce6243f56f --- /dev/null +++ b/ws-tests/api-tests/src/loveApiRefs.ts @@ -0,0 +1,17 @@ +// +// Class refs for the love plugin — same id shape the model builder emits (`${pluginId}:class:${name}`). +// Use this in API tests without a dependency on `@hcengineering/love`. +// + +import type { Class, Doc, Ref } from '@hcengineering/core' + +export const loveClass = { + Room: 'love:class:Room' as Ref>, + MeetingMinutes: 'love:class:MeetingMinutes' as Ref> +} + +/** @see `@hcengineering/love` MeetingStatus */ +export enum LoveMeetingStatus { + Active = 0, + Finished = 1 +} diff --git a/ws-tests/prepare.sh b/ws-tests/prepare.sh index 54fcc4bc7c..7ba9d2247b 100755 --- a/ws-tests/prepare.sh +++ b/ws-tests/prepare.sh @@ -32,6 +32,7 @@ echo "Creating user accounts..." ./tool.sh create-account admin -f Super -l Admin -p 1234 ./tool.sh create-account user1 -f John -l Appleseed -p 1234 ./tool.sh create-account user2 -f Kainin -l Dirak -p 1234 +./tool.sh create-account guest1 -f Guest -l One -p 1234 echo "Creating workspace api-tests..." ./tool.sh create-workspace api-tests email:user1 @@ -43,4 +44,8 @@ echo "Assigning user1 to workspaces..." ./tool.sh assign-workspace user1 api-tests ./tool.sh assign-workspace user1 api-tests-cr +echo "Assigning guest1 to api-tests as Guest..." +./tool.sh assign-workspace guest1 api-tests --role Guest +./tool.sh assign-workspace guest1 api-tests-cr --role Guest + rm -rf ./sanity/.auth