From 27a20b8a54c0a60d4d43606fe7c765114107d7e0 Mon Sep 17 00:00:00 2001 From: holmes Date: Fri, 19 Jun 2026 12:54:12 -0700 Subject: [PATCH 01/21] Validate channel metadata authorization --- .../storage/channels/channels.service.spec.ts | 168 ++++++++++++++++++ .../nest/storage/channels/channels.service.ts | 119 +++++++++++++ 2 files changed, 287 insertions(+) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index d531eeb798..a441794e26 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -14,6 +14,7 @@ import { import path from 'path' import { type PeerId } from '@libp2p/interface' +import { type LogEntry } from '@orbitdb/core' import waitForExpect from 'wait-for-expect' import { TestModule } from '../../common/test.module' import { createArbitraryFile, libp2pInstanceParams } from '../../common/utils' @@ -33,6 +34,11 @@ import { createLogger } from '../../common/logger' import { ChannelsService } from './channels.service' import { SigChainService } from '../../auth/sigchain.service' import { CID } from 'multiformats/cid' +import { SigChain } from '../../auth/sigchain' +import { RoleName } from '../../auth/services/roles/roles' +import { EncryptedAndSignedPayload, EncryptionScopeType } from '../../auth/services/crypto/types' +import { InviteService } from '../../auth/services/invites/invite.service' +import { UserService } from '../../auth/services/members/user.service' const logger = createLogger('channelsService:test') @@ -107,6 +113,51 @@ describe('ChannelsService', () => { }) }) + const createNonAdminMemberChain = (username: string): SigChain => { + const adminChain = sigChainService.getActiveChain() + const invite = adminChain.invites.createUserInvite() + const salt = `${username}-metadata-validation-salt` + + adminChain.lockbox.createInviteLockboxes(invite.seed, salt, RoleName.MEMBER) + + const invitedChain = SigChain.createFromInvite(username, invite.seed) + adminChain.invites.admitMemberFromInvite( + InviteService.generateProof(invite.seed), + invitedChain.user.userName, + invitedChain.user.userId, + UserService.redactUser(invitedChain.user).keys + ) + + const joinedChain = SigChain.joinForTesting( + { + user: invitedChain.user, + device: invitedChain.device, + }, + adminChain.save(), + adminChain.team!.teamKeyring() + ) + joinedChain.roles.addSelf(RoleName.MEMBER, invite.seed, salt) + + expect(joinedChain.roles.amIAdmin()).toBe(false) + expect(joinedChain.roles.amIMemberOfRole(RoleName.MEMBER)).toBe(true) + + return joinedChain + } + + const channelPutEntry = ( + key: string, + value: EncryptedAndSignedPayload, + hash: string = 'test-channel-metadata-entry' + ): LogEntry => + ({ + hash, + payload: { + op: 'PUT', + key, + value, + }, + }) as unknown as LogEntry + afterEach(async () => { await storageService.stop() await libp2pService.close() @@ -302,6 +353,123 @@ describe('ChannelsService', () => { }) }) + describe('Channel metadata validation', () => { + it('accepts legitimate public channel metadata encrypted to the member role', async () => { + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + + await expect(channelsService.validateEntry(channelPutEntry(publicChannel.id, encryptedEntry))).resolves.toBe(true) + }) + + it('accepts legitimate private channel metadata encrypted to the channel role', async () => { + const activeChain = sigChainService.getActiveChain() + const privateChannel: PublicChannel = { + id: 'legitimate-private-channel-id', + name: 'legitimate-private-channel', + description: 'legitimate private channel metadata', + owner: aliceUserId, + timestamp: Date.now(), + public: false, + teamId: community.teamId!, + } + privateChannel.roleName = activeChain.channels.create(privateChannel.id) + const encryptedEntry = channelsService.encryptChannelEntry(privateChannel) + + await expect(channelsService.validateEntry(channelPutEntry(privateChannel.id, encryptedEntry))).resolves.toBe( + true + ) + }) + + it('rejects forged private channel metadata encrypted to the broad member role', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const forgedPrivateChannel: PublicChannel = { + id: 'private-channel-id', + name: 'private-channel', + description: 'forged private channel metadata', + owner: malloryChain.user.userId, + timestamp: Date.now(), + public: false, + roleName: RoleName.MEMBER, + teamId: community.teamId!, + } + const forgedEntry = malloryChain.crypto.encryptAndSign(forgedPrivateChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expect( + channelsService.validateEntry( + channelPutEntry(forgedPrivateChannel.id, forgedEntry, 'forged-private-channel-metadata') + ) + ).resolves.toBe(false) + }) + + it('rejects private channel metadata encrypted outside the channel role', async () => { + const activeChain = sigChainService.getActiveChain() + const privateChannel: PublicChannel = { + id: 'team-scoped-private-channel-id', + name: 'team-scoped-private-channel', + description: 'private channel metadata encrypted to team scope', + owner: aliceUserId, + timestamp: Date.now(), + public: false, + teamId: community.teamId!, + } + privateChannel.roleName = activeChain.channels.create(privateChannel.id) + const teamScopedEntry = activeChain.crypto.encryptAndSign(privateChannel, { + type: EncryptionScopeType.TEAM, + }) + + await expect( + channelsService.validateEntry( + channelPutEntry(privateChannel.id, teamScopedEntry, 'team-scoped-private-channel-metadata') + ) + ).resolves.toBe(false) + }) + + it('rejects public metadata forged for an existing private channel id', async () => { + const activeChain = sigChainService.getActiveChain() + const malloryChain = createNonAdminMemberChain('mallory') + const privateChannelId = 'downgraded-private-channel-id' + activeChain.channels.create(privateChannelId) + + const forgedPublicChannel: PublicChannel = { + id: privateChannelId, + name: 'downgraded-private-channel', + description: 'forged public metadata for a private channel', + owner: malloryChain.user.userId, + timestamp: Date.now(), + public: true, + teamId: community.teamId!, + } + const forgedEntry = malloryChain.crypto.encryptAndSign(forgedPublicChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expect( + channelsService.validateEntry( + channelPutEntry(forgedPublicChannel.id, forgedEntry, 'downgraded-private-channel-metadata') + ) + ).resolves.toBe(false) + }) + + it('rejects channel metadata stored under a different key', async () => { + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + + await expect(channelsService.validateEntry(channelPutEntry('wrong-channel-id', encryptedEntry))).resolves.toBe( + false + ) + }) + }) + describe('Files deletion', () => { let realFilePath: string let messages: { diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 927edab4e0..eb256565cf 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -246,6 +246,122 @@ export class ChannelsService extends EventEmitter { } } + private validateChannelEntryMetadata( + entry: LogEntry, + encPayload: EncryptedAndSignedPayload, + decEntry: PublicChannel + ): boolean { + const key = entry.payload.key + const sigAuthor = encPayload.signature.author.name + const chain = this.sigchainService.getActiveChain(false) + + if (chain == null) { + this.logger.error('Cannot validate channel entry without an active chain:', entry.hash) + return false + } + + if (!key || key !== decEntry.id) { + this.logger.error('Failed to validate channel entry: key must match decrypted channel id:', entry.hash, { + key, + channelId: decEntry.id, + }) + return false + } + + if (!encPayload.userId || !sigAuthor || encPayload.userId !== sigAuthor) { + this.logger.error('Failed to validate channel entry: payload userId must match signature author:', entry.hash, { + userId: encPayload.userId, + sigAuthor, + }) + return false + } + + if (encPayload.teamId !== chain.team!.id) { + this.logger.error('Failed to validate channel entry: payload teamId must match active chain:', entry.hash, { + payloadTeamId: encPayload.teamId, + activeTeamId: chain.team!.id, + }) + return false + } + + if (!(decEntry.public ?? true)) { + return this.validatePrivateChannelEntry(entry, encPayload, decEntry, sigAuthor) + } + + if (decEntry.roleName != null) { + this.logger.error('Failed to validate public channel entry: public channels cannot declare a role:', entry.hash, { + roleName: decEntry.roleName, + }) + return false + } + + const expectedPrivateRoleName = chain.channels.generateChannelRoleName(decEntry.id) + if (chain.roles.getAllRoles().some(role => role.roleName === expectedPrivateRoleName)) { + this.logger.error( + 'Failed to validate public channel entry: channel id already has a private channel role:', + entry.hash, + { + channelId: decEntry.id, + roleName: expectedPrivateRoleName, + } + ) + return false + } + + return this.validateChannelEncryptionScope(entry, encPayload, RoleName.MEMBER) + } + + private validatePrivateChannelEntry( + entry: LogEntry, + encPayload: EncryptedAndSignedPayload, + decEntry: PublicChannel, + sigAuthor: string + ): boolean { + if (decEntry.owner !== sigAuthor) { + this.logger.error('Failed to validate private channel entry: owner must match signature author:', entry.hash, { + owner: decEntry.owner, + sigAuthor, + }) + return false + } + + const chain = this.sigchainService.getActiveChain() + const expectedRoleName = chain.channels.generateChannelRoleName(decEntry.id) + if (decEntry.roleName !== expectedRoleName) { + this.logger.error('Failed to validate private channel entry: roleName must match channel id:', entry.hash, { + roleName: decEntry.roleName, + expectedRoleName, + }) + return false + } + + if (!chain.roles.memberHasRole(sigAuthor, expectedRoleName)) { + this.logger.error('Failed to validate private channel entry: signer must have the channel role:', entry.hash, { + sigAuthor, + roleName: expectedRoleName, + }) + return false + } + + return this.validateChannelEncryptionScope(entry, encPayload, expectedRoleName) + } + + private validateChannelEncryptionScope( + entry: LogEntry, + encPayload: EncryptedAndSignedPayload, + expectedRoleName: string + ): boolean { + const scope = encPayload.encrypted.scope + if (scope.type !== EncryptionScopeType.ROLE || scope.name !== expectedRoleName) { + this.logger.error('Failed to validate channel entry: encryption scope must match channel role:', entry.hash, { + scope, + expectedRoleName, + }) + return false + } + return true + } + /** * Validates a log entry in the OrbitDB store. * @param entry The log entry to validate. @@ -261,6 +377,9 @@ export class ChannelsService extends EventEmitter { this.logger.error('Decrypted channel entry is not a valid channel:', entry.hash, decEntry) return false } + if (!this.validateChannelEntryMetadata(entry, encPayload, decEntry)) { + return false + } } if (entry.payload.op === 'DEL') { if (!entry.payload.key) { From c99ca226c1abc461c1ccec3733a1a296326a7595 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 19 Jun 2026 19:51:12 -0400 Subject: [PATCH 02/21] add validation for DEL to avoid DEL then spoof --- .../storage/channels/channels.service.spec.ts | 57 ++++++++++++++++- .../nest/storage/channels/channels.service.ts | 64 ++++++++++++++++++- 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index a441794e26..af279e1fcf 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -14,7 +14,7 @@ import { import path from 'path' import { type PeerId } from '@libp2p/interface' -import { type LogEntry } from '@orbitdb/core' +import { Entry, type LogEntry } from '@orbitdb/core' import waitForExpect from 'wait-for-expect' import { TestModule } from '../../common/test.module' import { createArbitraryFile, libp2pInstanceParams } from '../../common/utils' @@ -39,6 +39,7 @@ import { RoleName } from '../../auth/services/roles/roles' import { EncryptedAndSignedPayload, EncryptionScopeType } from '../../auth/services/crypto/types' import { InviteService } from '../../auth/services/invites/invite.service' import { UserService } from '../../auth/services/members/user.service' +import { OrbitDbService } from '../orbitDb/orbitDb.service' const logger = createLogger('channelsService:test') @@ -52,6 +53,7 @@ describe('ChannelsService', () => { let libp2pService: Libp2pService let localDbService: LocalDbService let channelsService: ChannelsService + let orbitDbService: OrbitDbService let sigChainService: SigChainService let peerId: PeerId @@ -79,6 +81,7 @@ describe('ChannelsService', () => { storageService = await module.resolve(StorageService) channelsService = await module.resolve(ChannelsService) + orbitDbService = await module.resolve(OrbitDbService) localDbService = await module.resolve(LocalDbService) libp2pService = await module.resolve(Libp2pService) ipfsService = await module.resolve(IpfsService) @@ -158,6 +161,35 @@ describe('ChannelsService', () => { }, }) as unknown as LogEntry + const channelDelEntry = ( + key: string, + identity: string = 'test-channel-delete-identity', + hash: string = 'test-channel-metadata-delete-entry' + ): LogEntry => + ({ + hash, + identity, + payload: { + op: 'DEL', + key, + }, + }) as unknown as LogEntry + + const mockChannelEntryIdentity = (userId: string): (() => void) => { + const identities = orbitDbService.identities + expect(identities).toBeDefined() + const teamId = sigChainService.getActiveChain().team!.id + const getIdentitySpy = jest.spyOn(identities!, 'getIdentity').mockResolvedValue({ id: userId, teamId } as any) + const verifyIdentitySpy = jest.spyOn(identities!, 'verifyIdentity').mockResolvedValue(true) + const entryVerifySpy = jest.spyOn(Entry, 'verify').mockResolvedValue(true) + + return () => { + getIdentitySpy.mockRestore() + verifyIdentitySpy.mockRestore() + entryVerifySpy.mockRestore() + } + } + afterEach(async () => { await storageService.stop() await libp2pService.close() @@ -468,6 +500,29 @@ describe('ChannelsService', () => { false ) }) + + it('accepts channel metadata deletion from a sigchain admin', async () => { + const restoreIdentityMocks = mockChannelEntryIdentity(aliceUserId) + + try { + await expect(channelsService.validateEntry(channelDelEntry('channel-id-to-delete'))).resolves.toBe(true) + } finally { + restoreIdentityMocks() + } + }) + + it('rejects channel metadata deletion from a non-admin member', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const restoreIdentityMocks = mockChannelEntryIdentity(malloryChain.user.userId) + + try { + await expect( + channelsService.validateEntry(channelDelEntry('channel-id-to-delete', 'mallory-channel-delete-identity')) + ).resolves.toBe(false) + } finally { + restoreIdentityMocks() + } + }) }) describe('Files deletion', () => { diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index eb256565cf..d35e6de9f8 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common' -import { IPFSAccessController, type LogEntry } from '@orbitdb/core' +import { Entry, IPFSAccessController, type LogEntry } from '@orbitdb/core' import { EventEmitter } from 'events' import { ChannelMessage, @@ -362,6 +362,65 @@ export class ChannelsService extends EventEmitter { return true } + private async validateChannelDeleteEntry(entry: LogEntry): Promise { + const key = entry.payload.key + if (!key) { + this.logger.error('Delete channel entry is missing key:', entry.hash) + return false + } + + if (!entry.identity) { + this.logger.error('Failed to validate delete channel entry: entry identity is missing:', entry.hash) + return false + } + + const identities = this.orbitDbService.identities + if (identities == null) { + this.logger.error('Failed to validate delete channel entry: OrbitDB identities are not initialized:', entry.hash) + return false + } + + const chain = this.sigchainService.getActiveChain(false) + if (chain == null) { + this.logger.error('Cannot validate delete channel entry without an active chain:', entry.hash) + return false + } + + const writerIdentity = await identities.getIdentity(entry.identity) + const identityVerified = await identities.verifyIdentity(writerIdentity) + if (!identityVerified) { + this.logger.error('Failed to validate delete channel entry: entry identity verification failed:', entry.hash) + return false + } + + const entryVerified = await Entry.verify(identities as any, entry) + if (!entryVerified) { + this.logger.error('Failed to validate delete channel entry: entry signature verification failed:', entry.hash) + return false + } + + if (writerIdentity.teamId !== chain.team!.id) { + this.logger.error( + 'Failed to validate delete channel entry: entry identity team must match active chain:', + entry.hash, + { + entryTeamId: writerIdentity.teamId, + activeTeamId: chain.team!.id, + } + ) + return false + } + + if (!chain.roles.memberIsAdmin(writerIdentity.id)) { + this.logger.error('Failed to validate delete channel entry: writer must be a sigchain admin:', entry.hash, { + writerId: writerIdentity.id, + }) + return false + } + + return true + } + /** * Validates a log entry in the OrbitDB store. * @param entry The log entry to validate. @@ -382,8 +441,7 @@ export class ChannelsService extends EventEmitter { } } if (entry.payload.op === 'DEL') { - if (!entry.payload.key) { - this.logger.error('Delete channel entry is missing key:', entry.hash) + if (!(await this.validateChannelDeleteEntry(entry))) { return false } } From 503558c073496368728a97d1d592daf10598234b Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 19 Jun 2026 20:00:02 -0400 Subject: [PATCH 03/21] verify owner, enc signature author, and entry writer id are the same --- .../storage/channels/channels.service.spec.ts | 114 ++++++++++++------ .../nest/storage/channels/channels.service.ts | 96 +++++++++++---- 2 files changed, 148 insertions(+), 62 deletions(-) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index af279e1fcf..ee01fbbcbe 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -150,10 +150,12 @@ describe('ChannelsService', () => { const channelPutEntry = ( key: string, value: EncryptedAndSignedPayload, - hash: string = 'test-channel-metadata-entry' + hash: string = 'test-channel-metadata-entry', + identity: string = 'test-channel-put-identity' ): LogEntry => ({ hash, + identity, payload: { op: 'PUT', key, @@ -190,6 +192,20 @@ describe('ChannelsService', () => { } } + const expectChannelEntryValidation = async ( + entry: LogEntry, + writerUserId: string, + expected: boolean + ): Promise => { + const restoreIdentityMocks = mockChannelEntryIdentity(writerUserId) + + try { + await expect(channelsService.validateEntry(entry)).resolves.toBe(expected) + } finally { + restoreIdentityMocks() + } + } + afterEach(async () => { await storageService.stop() await libp2pService.close() @@ -393,7 +409,40 @@ describe('ChannelsService', () => { }) const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) - await expect(channelsService.validateEntry(channelPutEntry(publicChannel.id, encryptedEntry))).resolves.toBe(true) + await expectChannelEntryValidation(channelPutEntry(publicChannel.id, encryptedEntry), aliceUserId, true) + }) + + it('rejects public channel metadata when owner does not match the encrypted signature author', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const forgedPublicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const forgedEntry = malloryChain.crypto.encryptAndSign(forgedPublicChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expectChannelEntryValidation( + channelPutEntry(forgedPublicChannel.id, forgedEntry, 'owner-encrypted-signature-mismatch'), + malloryChain.user.userId, + false + ) + }) + + it('rejects public channel metadata when owner does not match the entry signature author', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + + await expectChannelEntryValidation( + channelPutEntry(publicChannel.id, encryptedEntry, 'owner-entry-signature-mismatch'), + malloryChain.user.userId, + false + ) }) it('accepts legitimate private channel metadata encrypted to the channel role', async () => { @@ -410,9 +459,7 @@ describe('ChannelsService', () => { privateChannel.roleName = activeChain.channels.create(privateChannel.id) const encryptedEntry = channelsService.encryptChannelEntry(privateChannel) - await expect(channelsService.validateEntry(channelPutEntry(privateChannel.id, encryptedEntry))).resolves.toBe( - true - ) + await expectChannelEntryValidation(channelPutEntry(privateChannel.id, encryptedEntry), aliceUserId, true) }) it('rejects forged private channel metadata encrypted to the broad member role', async () => { @@ -432,11 +479,11 @@ describe('ChannelsService', () => { name: RoleName.MEMBER, }) - await expect( - channelsService.validateEntry( - channelPutEntry(forgedPrivateChannel.id, forgedEntry, 'forged-private-channel-metadata') - ) - ).resolves.toBe(false) + await expectChannelEntryValidation( + channelPutEntry(forgedPrivateChannel.id, forgedEntry, 'forged-private-channel-metadata'), + malloryChain.user.userId, + false + ) }) it('rejects private channel metadata encrypted outside the channel role', async () => { @@ -455,11 +502,11 @@ describe('ChannelsService', () => { type: EncryptionScopeType.TEAM, }) - await expect( - channelsService.validateEntry( - channelPutEntry(privateChannel.id, teamScopedEntry, 'team-scoped-private-channel-metadata') - ) - ).resolves.toBe(false) + await expectChannelEntryValidation( + channelPutEntry(privateChannel.id, teamScopedEntry, 'team-scoped-private-channel-metadata'), + aliceUserId, + false + ) }) it('rejects public metadata forged for an existing private channel id', async () => { @@ -482,11 +529,11 @@ describe('ChannelsService', () => { name: RoleName.MEMBER, }) - await expect( - channelsService.validateEntry( - channelPutEntry(forgedPublicChannel.id, forgedEntry, 'downgraded-private-channel-metadata') - ) - ).resolves.toBe(false) + await expectChannelEntryValidation( + channelPutEntry(forgedPublicChannel.id, forgedEntry, 'downgraded-private-channel-metadata'), + malloryChain.user.userId, + false + ) }) it('rejects channel metadata stored under a different key', async () => { @@ -496,32 +543,21 @@ describe('ChannelsService', () => { }) const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) - await expect(channelsService.validateEntry(channelPutEntry('wrong-channel-id', encryptedEntry))).resolves.toBe( - false - ) + await expectChannelEntryValidation(channelPutEntry('wrong-channel-id', encryptedEntry), aliceUserId, false) }) it('accepts channel metadata deletion from a sigchain admin', async () => { - const restoreIdentityMocks = mockChannelEntryIdentity(aliceUserId) - - try { - await expect(channelsService.validateEntry(channelDelEntry('channel-id-to-delete'))).resolves.toBe(true) - } finally { - restoreIdentityMocks() - } + await expectChannelEntryValidation(channelDelEntry('channel-id-to-delete'), aliceUserId, true) }) it('rejects channel metadata deletion from a non-admin member', async () => { const malloryChain = createNonAdminMemberChain('mallory') - const restoreIdentityMocks = mockChannelEntryIdentity(malloryChain.user.userId) - - try { - await expect( - channelsService.validateEntry(channelDelEntry('channel-id-to-delete', 'mallory-channel-delete-identity')) - ).resolves.toBe(false) - } finally { - restoreIdentityMocks() - } + + await expectChannelEntryValidation( + channelDelEntry('channel-id-to-delete', 'mallory-channel-delete-identity'), + malloryChain.user.userId, + false + ) }) }) diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index d35e6de9f8..3d7c53a981 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -246,11 +246,11 @@ export class ChannelsService extends EventEmitter { } } - private validateChannelEntryMetadata( + private async validateChannelEntryMetadata( entry: LogEntry, encPayload: EncryptedAndSignedPayload, decEntry: PublicChannel - ): boolean { + ): Promise { const key = entry.payload.key const sigAuthor = encPayload.signature.author.name const chain = this.sigchainService.getActiveChain(false) @@ -276,6 +276,32 @@ export class ChannelsService extends EventEmitter { return false } + const writerIdentity = await this.getVerifiedChannelEntryWriter(entry, 'PUT') + if (writerIdentity == null) { + return false + } + + if (writerIdentity.teamId !== chain.team!.id) { + this.logger.error('Failed to validate channel entry: entry identity team must match active chain:', entry.hash, { + entryTeamId: writerIdentity.teamId, + activeTeamId: chain.team!.id, + }) + return false + } + + if (decEntry.owner !== sigAuthor || decEntry.owner !== writerIdentity.id) { + this.logger.error( + 'Failed to validate channel entry: owner must match encrypted payload signature author and entry signature author:', + entry.hash, + { + owner: decEntry.owner, + encryptedSignatureAuthor: sigAuthor, + entrySignatureAuthor: writerIdentity.id, + } + ) + return false + } + if (encPayload.teamId !== chain.team!.id) { this.logger.error('Failed to validate channel entry: payload teamId must match active chain:', entry.hash, { payloadTeamId: encPayload.teamId, @@ -362,40 +388,64 @@ export class ChannelsService extends EventEmitter { return true } - private async validateChannelDeleteEntry(entry: LogEntry): Promise { - const key = entry.payload.key - if (!key) { - this.logger.error('Delete channel entry is missing key:', entry.hash) - return false - } - + private async getVerifiedChannelEntryWriter( + entry: LogEntry, + operation: 'PUT' | 'DEL' + ): Promise<{ id: string; teamId: string } | undefined> { if (!entry.identity) { - this.logger.error('Failed to validate delete channel entry: entry identity is missing:', entry.hash) - return false + this.logger.error(`Failed to validate channel ${operation} entry: entry identity is missing:`, entry.hash) + return undefined } const identities = this.orbitDbService.identities if (identities == null) { - this.logger.error('Failed to validate delete channel entry: OrbitDB identities are not initialized:', entry.hash) - return false - } - - const chain = this.sigchainService.getActiveChain(false) - if (chain == null) { - this.logger.error('Cannot validate delete channel entry without an active chain:', entry.hash) - return false + this.logger.error( + `Failed to validate channel ${operation} entry: OrbitDB identities are not initialized:`, + entry.hash + ) + return undefined } const writerIdentity = await identities.getIdentity(entry.identity) const identityVerified = await identities.verifyIdentity(writerIdentity) if (!identityVerified) { - this.logger.error('Failed to validate delete channel entry: entry identity verification failed:', entry.hash) - return false + this.logger.error( + `Failed to validate channel ${operation} entry: entry identity verification failed:`, + entry.hash + ) + return undefined } const entryVerified = await Entry.verify(identities as any, entry) if (!entryVerified) { - this.logger.error('Failed to validate delete channel entry: entry signature verification failed:', entry.hash) + this.logger.error( + `Failed to validate channel ${operation} entry: entry signature verification failed:`, + entry.hash + ) + return undefined + } + + return { + id: writerIdentity.id, + teamId: writerIdentity.teamId, + } + } + + private async validateChannelDeleteEntry(entry: LogEntry): Promise { + const key = entry.payload.key + if (!key) { + this.logger.error('Delete channel entry is missing key:', entry.hash) + return false + } + + const chain = this.sigchainService.getActiveChain(false) + if (chain == null) { + this.logger.error('Cannot validate delete channel entry without an active chain:', entry.hash) + return false + } + + const writerIdentity = await this.getVerifiedChannelEntryWriter(entry, 'DEL') + if (writerIdentity == null) { return false } @@ -436,7 +486,7 @@ export class ChannelsService extends EventEmitter { this.logger.error('Decrypted channel entry is not a valid channel:', entry.hash, decEntry) return false } - if (!this.validateChannelEntryMetadata(entry, encPayload, decEntry)) { + if (!(await this.validateChannelEntryMetadata(entry, encPayload, decEntry))) { return false } } From 8b4fa452d2d0258d399c821c5e5b0266362b719d Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 19 Jun 2026 20:57:25 -0400 Subject: [PATCH 04/21] optimistically prevent malicious updates when already initialized --- .../storage/channels/channels.service.spec.ts | 74 +++++++++++++ .../nest/storage/channels/channels.service.ts | 101 ++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index ee01fbbcbe..3d736d89e7 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -40,6 +40,7 @@ import { EncryptedAndSignedPayload, EncryptionScopeType } from '../../auth/servi import { InviteService } from '../../auth/services/invites/invite.service' import { UserService } from '../../auth/services/members/user.service' import { OrbitDbService } from '../orbitDb/orbitDb.service' +import { generateChannelId } from '@quiet/common' const logger = createLogger('channelsService:test') @@ -536,6 +537,79 @@ describe('ChannelsService', () => { ) }) + it('rejects metadata whose bound id does not commit to the writer (no prior state needed)', async () => { + // Alice owns a channel whose id cryptographically commits to her. Mallory tries to take it over + // by writing under the same id. This must be rejected purely from the id, with NOTHING in the + // store yet, which is what makes the check safe during initial sync / index rebuild. + const boundId = generateChannelId('takeover-target', aliceUserId) + const malloryChain = createNonAdminMemberChain('mallory') + const hijackChannel: PublicChannel = { + id: boundId, + name: 'hijacked', + description: 'hijacked channel metadata', + owner: malloryChain.user.userId, + timestamp: Date.now(), + public: true, + teamId: community.teamId!, + } + const hijackEntry = malloryChain.crypto.encryptAndSign(hijackChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expectChannelEntryValidation( + channelPutEntry(boundId, hijackEntry, 'bound-channel-takeover-metadata'), + malloryChain.user.userId, + false + ) + }) + + it('accepts metadata whose bound id commits to the writer', async () => { + const boundId = generateChannelId('owned-by-alice', aliceUserId) + const boundChannel: PublicChannel = { + id: boundId, + name: 'owned-by-alice', + description: 'legitimate bound channel metadata', + owner: aliceUserId, + timestamp: Date.now(), + public: true, + teamId: community.teamId!, + } + const encryptedEntry = channelsService.encryptChannelEntry(boundChannel) + + await expectChannelEntryValidation(channelPutEntry(boundId, encryptedEntry), aliceUserId, true) + }) + + it('rejects legacy-id metadata that overwrites an existing channel owned by another member', async () => { + // Legacy (unbound) ids fall back to the stateful check, which requires the original entry to be + // present/indexed first. + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + // Alice legitimately creates the channel + await channelsService.setChannel(publicChannel) + + // Mallory tries to take it over by writing a fresh, self-signed entry under the same id + const malloryChain = createNonAdminMemberChain('mallory') + const hijackChannel: PublicChannel = { + ...publicChannel, + owner: malloryChain.user.userId, + name: 'hijacked', + description: 'hijacked channel metadata', + } + const hijackEntry = malloryChain.crypto.encryptAndSign(hijackChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expectChannelEntryValidation( + channelPutEntry(publicChannel.id, hijackEntry, 'channel-takeover-metadata'), + malloryChain.user.userId, + false + ) + }) + it('rejects channel metadata stored under a different key', async () => { const publicChannel = await factory.build('PublicChannel', { owner: aliceUserId, diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 3d7c53a981..f00aaf96b8 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -41,6 +41,7 @@ import { EncryptedAndSignedPayload, EncryptionScope, EncryptionScopeType } from import { RoleName } from '../../auth/services/roles/roles' import { DateTime } from 'luxon' import { isChannel } from '../../validation/validators' +import { isBoundChannelId, verifyChannelIdOwner } from '@quiet/common' import { NotAMemberError } from './channels.errors' import { SigchainEvents } from '../../auth/types' @@ -310,6 +311,10 @@ export class ChannelsService extends EventEmitter { return false } + if (!(await this.validateChannelOwnership(entry, decEntry, writerIdentity.id))) { + return false + } + if (!(decEntry.public ?? true)) { return this.validatePrivateChannelEntry(entry, encPayload, decEntry, sigAuthor) } @@ -431,6 +436,96 @@ export class ChannelsService extends EventEmitter { } } + /** + * Enforce that a channel's metadata can only be written by its owner. + * + * For bound channel ids (`${name}_${nonce}_${commitment}`) the owner is committed into the id, so + * we can verify ownership statelessly. This holds regardless of replication/index-rebuild order, + * which the stateful fallback below cannot guarantee. + * + * For legacy ids (created before owner binding) there is no commitment to check, so we fall back + * to comparing against the stored entry. That fallback only reliably protects the live case (it + * depends on the existing entry already being indexed), but it is the best we can do for channels + * that predate the migration. + * + * @param entry The log entry being validated + * @param decEntry The decrypted channel metadata from the new entry + * @param writerId The verified id of the entry writer (already pinned to the encrypted signature author and owner) + * @returns True if the writer is authorized to write this channel's metadata + */ + private async validateChannelOwnership( + entry: LogEntry, + decEntry: PublicChannel, + writerId: string + ): Promise { + if (isBoundChannelId(decEntry.id)) { + if (!verifyChannelIdOwner(decEntry.id, writerId)) { + this.logger.error('Failed to validate channel entry: channel id is not bound to the writer:', entry.hash, { + channelId: decEntry.id, + writerId, + }) + return false + } + return true + } + + return this.validateLegacyChannelMetadataUpdate(entry, decEntry, writerId) + } + + /** + * Legacy (pre-owner-binding) fallback. Channel metadata is write-once: the only legitimate PUT for + * a given channel id is its creation. If an entry already exists for this key, ensure the writer is + * the original owner and that the security-relevant fields (owner, public flag, role) are + * unchanged. NOTE: this depends on the existing entry already being indexed, so it does not protect + * against a takeover observed during an initial full replication/index rebuild. + * + * @param entry The log entry being validated + * @param decEntry The decrypted channel metadata from the new entry + * @param writerId The verified id of the entry writer (already pinned to the encrypted signature author and owner) + * @returns True if this is a fresh channel or a valid update by the original owner + */ + private async validateLegacyChannelMetadataUpdate( + entry: LogEntry, + decEntry: PublicChannel, + writerId: string + ): Promise { + const stored = await this.channels!.get(entry.payload.key!) + if (stored == null) { + return true + } + + // A stored entry that we cannot decrypt (e.g. a private channel we don't belong to) must not be + // overwritten. decryptChannelEntry throws here and validateEntry fails closed. + const storedChannel = this.decryptChannelEntry(stored as EncryptedAndSignedPayload, entry.payload.key!) + + if (storedChannel.owner !== writerId) { + this.logger.error( + 'Failed to validate channel entry: only the original owner may modify channel metadata:', + entry.hash, + { storedOwner: storedChannel.owner, writerId } + ) + return false + } + + if ((storedChannel.public ?? true) !== (decEntry.public ?? true)) { + this.logger.error('Failed to validate channel entry: channel public flag is immutable:', entry.hash, { + storedPublic: storedChannel.public, + newPublic: decEntry.public, + }) + return false + } + + if ((storedChannel.roleName ?? null) !== (decEntry.roleName ?? null)) { + this.logger.error('Failed to validate channel entry: channel role is immutable:', entry.hash, { + storedRoleName: storedChannel.roleName, + newRoleName: decEntry.roleName, + }) + return false + } + + return true + } + private async validateChannelDeleteEntry(entry: LogEntry): Promise { const key = entry.payload.key if (!key) { @@ -692,6 +787,12 @@ export class ChannelsService extends EventEmitter { public: payload.public ?? true, teamId: payload.teamId, } + // Self-defense: the channel id must commit to us (the owner) or our own validateEntry would + // reject the resulting entry network-wide. Fail fast before creating a role/store. + if (!verifyChannelIdOwner(channelData.id, channelData.owner)) { + this.logger.error('Refusing to create channel: id is not bound to the owner:', channelData.id) + return { status: ChannelOperationStatus.FAILED } + } let roleName: string | undefined = undefined if (!(channelData.public ?? true)) { roleName = this.sigchainService.getActiveChain().channels.create(channelData.id) From dc250b834f00e25c4b07840d43b5b88ab283f00a Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 24 Jun 2026 18:22:31 -0400 Subject: [PATCH 05/21] Implement channel metadata access control and migration logic --- .../channels.service.migration.unit.spec.ts | 132 ++++++++++++++ .../storage/channels/channels.service.spec.ts | 100 +++++------ .../nest/storage/channels/channels.service.ts | 156 +++++++++++------ .../ChannelMetadataAccessController.ts | 165 ++++++++++++++++++ ...annelMetadataAccessController.unit.spec.ts | 126 +++++++++++++ .../nest/storage/orbitDb/orbitDb.service.ts | 13 +- .../nest/storage/orbitDb/orbitdb.module.ts | 3 + 7 files changed, 590 insertions(+), 105 deletions(-) create mode 100644 packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts create mode 100644 packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts create mode 100644 packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts diff --git a/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts new file mode 100644 index 0000000000..5f0bc853cb --- /dev/null +++ b/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, jest } from '@jest/globals' + +import { ChannelsService } from './channels.service' + +const createChannelsService = (orbitDbService: any, isAdmin = false) => + new ChannelsService( + '/tmp/orbitdb', + '/tmp/ipfs', + {} as any, + orbitDbService, + {} as any, + { + getActiveChain: jest.fn().mockReturnValue({ + user: { userId: 'local-user-id' }, + roles: { + memberIsAdmin: jest.fn((userId: string) => userId === 'local-user-id' && isAdmin), + }, + }), + } as any, + { + createAccessControllerFunc: jest.fn(() => 'channel-metadata-access-controller'), + } as any + ) + +describe('ChannelsService channel metadata access-controller migration', () => { + it('keeps a non-empty legacy channel metadata store for non-admin users until the new store is populated', async () => { + const legacyStore = { + address: '/orbitdb/legacy-channel-metadata', + all: jest.fn().mockResolvedValue([{ key: 'general', value: {} }] as never), + close: jest.fn(), + } + const newStore = { + address: '/orbitdb/new-channel-metadata', + all: jest.fn().mockResolvedValue([] as never), + close: jest.fn().mockResolvedValue(undefined as never), + } + const orbitDbService = { + open: jest + .fn() + .mockResolvedValueOnce(legacyStore as never) + .mockResolvedValueOnce(newStore as never), + } + const channelsService = createChannelsService(orbitDbService) + + const result = await (channelsService as any).openMigratedChannelsDb() + + expect(result).toBe(legacyStore) + expect(orbitDbService.open).toHaveBeenCalledTimes(2) + expect(legacyStore.close).not.toHaveBeenCalled() + expect(newStore.close).toHaveBeenCalled() + }) + + it('closes an empty legacy channel metadata store and opens the new store', async () => { + const legacyStore = { + address: '/orbitdb/legacy-channel-metadata', + all: jest.fn().mockResolvedValue([] as never), + close: jest.fn().mockResolvedValue(undefined as never), + } + const newStore = { + address: '/orbitdb/new-channel-metadata', + all: jest.fn().mockResolvedValue([] as never), + } + const orbitDbService = { + open: jest + .fn() + .mockResolvedValueOnce(legacyStore as never) + .mockResolvedValueOnce(newStore as never), + } + const channelsService = createChannelsService(orbitDbService) + + const result = await (channelsService as any).openMigratedChannelsDb() + + expect(result).toBe(newStore) + expect(orbitDbService.open).toHaveBeenCalledTimes(2) + expect(legacyStore.close).toHaveBeenCalled() + }) + + it('uses a populated new channel metadata store when one exists', async () => { + const legacyStore = { + address: '/orbitdb/legacy-channel-metadata', + all: jest.fn().mockResolvedValue([{ key: 'general', value: {} }] as never), + close: jest.fn().mockResolvedValue(undefined as never), + } + const newStore = { + address: '/orbitdb/new-channel-metadata', + all: jest.fn().mockResolvedValue([{ key: 'general', value: {} }] as never), + close: jest.fn(), + } + const orbitDbService = { + open: jest + .fn() + .mockResolvedValueOnce(legacyStore as never) + .mockResolvedValueOnce(newStore as never), + } + const channelsService = createChannelsService(orbitDbService) + + const result = await (channelsService as any).openMigratedChannelsDb() + + expect(result).toBe(newStore) + expect(legacyStore.close).toHaveBeenCalled() + expect(newStore.close).not.toHaveBeenCalled() + }) + + it('lets admin users populate the new channel metadata store from legacy entries', async () => { + const legacyEntry = { key: 'general', value: {} } + const legacyStore = { + address: '/orbitdb/legacy-channel-metadata', + all: jest.fn().mockResolvedValue([legacyEntry] as never), + close: jest.fn().mockResolvedValue(undefined as never), + } + const newStore = { + address: '/orbitdb/new-channel-metadata', + all: jest.fn().mockResolvedValue([] as never), + put: jest.fn().mockResolvedValue(undefined as never), + close: jest.fn(), + } + const orbitDbService = { + open: jest + .fn() + .mockResolvedValueOnce(legacyStore as never) + .mockResolvedValueOnce(newStore as never), + } + const channelsService = createChannelsService(orbitDbService, true) + + const result = await (channelsService as any).openMigratedChannelsDb() + + expect(result).toBe(newStore) + expect(newStore.put).toHaveBeenCalledWith(legacyEntry.key, legacyEntry.value) + expect(legacyStore.close).toHaveBeenCalled() + expect(newStore.close).not.toHaveBeenCalled() + }) +}) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 3d736d89e7..9efaf9e0d8 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -40,7 +40,7 @@ import { EncryptedAndSignedPayload, EncryptionScopeType } from '../../auth/servi import { InviteService } from '../../auth/services/invites/invite.service' import { UserService } from '../../auth/services/members/user.service' import { OrbitDbService } from '../orbitDb/orbitDb.service' -import { generateChannelId } from '@quiet/common' +import { SigchainEvents } from '../../auth/types' const logger = createLogger('channelsService:test') @@ -217,6 +217,22 @@ describe('ChannelsService', () => { }) describe('Channels', () => { + it('rebroadcasts channel metadata after sigchain updates', async () => { + expect(channelsService.channels).toBeDefined() + + const retryIndexingSpy = jest + .spyOn(channelsService.channels!, 'retryIndexingUnindexedEntries') + .mockResolvedValue() + const broadcastCurrentChannelsSpy = jest.spyOn(channelsService, 'broadcastCurrentChannels').mockResolvedValue() + + sigChainService.emit(SigchainEvents.UPDATED) + + await waitForExpect(() => { + expect(retryIndexingSpy).toHaveBeenCalled() + expect(broadcastCurrentChannelsSpy).toHaveBeenCalled() + }) + }) + it('deletes channel as owner', async () => { logger.info('Deleting channel as owner') await channelsService.subscribeToChannel(channel) @@ -446,6 +462,24 @@ describe('ChannelsService', () => { ) }) + it('accepts admin-republished public channel metadata owned by another member', async () => { + const malloryChain = createNonAdminMemberChain('mallory') + const publicChannel = await factory.build('PublicChannel', { + owner: malloryChain.user.userId, + teamId: community.teamId!, + }) + const encryptedEntry = malloryChain.crypto.encryptAndSign(publicChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) + + await expectChannelEntryValidation( + channelPutEntry(publicChannel.id, encryptedEntry, 'admin-republished-channel-metadata'), + aliceUserId, + true + ) + }) + it('accepts legitimate private channel metadata encrypted to the channel role', async () => { const activeChain = sigChainService.getActiveChain() const privateChannel: PublicChannel = { @@ -489,23 +523,24 @@ describe('ChannelsService', () => { it('rejects private channel metadata encrypted outside the channel role', async () => { const activeChain = sigChainService.getActiveChain() + const malloryChain = createNonAdminMemberChain('mallory') const privateChannel: PublicChannel = { id: 'team-scoped-private-channel-id', name: 'team-scoped-private-channel', description: 'private channel metadata encrypted to team scope', - owner: aliceUserId, + owner: malloryChain.user.userId, timestamp: Date.now(), public: false, teamId: community.teamId!, } privateChannel.roleName = activeChain.channels.create(privateChannel.id) - const teamScopedEntry = activeChain.crypto.encryptAndSign(privateChannel, { + const teamScopedEntry = malloryChain.crypto.encryptAndSign(privateChannel, { type: EncryptionScopeType.TEAM, }) await expectChannelEntryValidation( channelPutEntry(privateChannel.id, teamScopedEntry, 'team-scoped-private-channel-metadata'), - aliceUserId, + malloryChain.user.userId, false ) }) @@ -537,49 +572,6 @@ describe('ChannelsService', () => { ) }) - it('rejects metadata whose bound id does not commit to the writer (no prior state needed)', async () => { - // Alice owns a channel whose id cryptographically commits to her. Mallory tries to take it over - // by writing under the same id. This must be rejected purely from the id, with NOTHING in the - // store yet, which is what makes the check safe during initial sync / index rebuild. - const boundId = generateChannelId('takeover-target', aliceUserId) - const malloryChain = createNonAdminMemberChain('mallory') - const hijackChannel: PublicChannel = { - id: boundId, - name: 'hijacked', - description: 'hijacked channel metadata', - owner: malloryChain.user.userId, - timestamp: Date.now(), - public: true, - teamId: community.teamId!, - } - const hijackEntry = malloryChain.crypto.encryptAndSign(hijackChannel, { - type: EncryptionScopeType.ROLE, - name: RoleName.MEMBER, - }) - - await expectChannelEntryValidation( - channelPutEntry(boundId, hijackEntry, 'bound-channel-takeover-metadata'), - malloryChain.user.userId, - false - ) - }) - - it('accepts metadata whose bound id commits to the writer', async () => { - const boundId = generateChannelId('owned-by-alice', aliceUserId) - const boundChannel: PublicChannel = { - id: boundId, - name: 'owned-by-alice', - description: 'legitimate bound channel metadata', - owner: aliceUserId, - timestamp: Date.now(), - public: true, - teamId: community.teamId!, - } - const encryptedEntry = channelsService.encryptChannelEntry(boundChannel) - - await expectChannelEntryValidation(channelPutEntry(boundId, encryptedEntry), aliceUserId, true) - }) - it('rejects legacy-id metadata that overwrites an existing channel owned by another member', async () => { // Legacy (unbound) ids fall back to the stateful check, which requires the original entry to be // present/indexed first. @@ -611,13 +603,21 @@ describe('ChannelsService', () => { }) it('rejects channel metadata stored under a different key', async () => { + const malloryChain = createNonAdminMemberChain('mallory') const publicChannel = await factory.build('PublicChannel', { - owner: aliceUserId, + owner: malloryChain.user.userId, teamId: community.teamId!, }) - const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + const encryptedEntry = malloryChain.crypto.encryptAndSign(publicChannel, { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + }) - await expectChannelEntryValidation(channelPutEntry('wrong-channel-id', encryptedEntry), aliceUserId, false) + await expectChannelEntryValidation( + channelPutEntry('wrong-channel-id', encryptedEntry), + malloryChain.user.userId, + false + ) }) it('accepts channel metadata deletion from a sigchain admin', async () => { diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index f00aaf96b8..9f4bf56c1c 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -41,9 +41,9 @@ import { EncryptedAndSignedPayload, EncryptionScope, EncryptionScopeType } from import { RoleName } from '../../auth/services/roles/roles' import { DateTime } from 'luxon' import { isChannel } from '../../validation/validators' -import { isBoundChannelId, verifyChannelIdOwner } from '@quiet/common' import { NotAMemberError } from './channels.errors' import { SigchainEvents } from '../../auth/types' +import { ChannelMetadataAccessController } from './orbitdb/ChannelMetadataAccessController' /** * Manages storage-level logic for all channels in Quiet @@ -63,12 +63,8 @@ export class ChannelsService extends EventEmitter { } try { - const currentChannelsCount = (await this.getChannels()).length await this.channels.retryIndexingUnindexedEntries() - const newChannelsCount = (await this.getChannels()).length - if (currentChannelsCount !== newChannelsCount) { - await this.broadcastCurrentChannels() - } + await this.broadcastCurrentChannels() } catch (e) { this.logger.warn('Error when attempting to reindex on sigchain update', e) } @@ -85,7 +81,8 @@ export class ChannelsService extends EventEmitter { private readonly filesManager: IpfsFileManagerService, private readonly orbitDbService: OrbitDbService, private readonly moduleRef: ModuleRef, - private readonly sigchainService: SigChainService + private readonly sigchainService: SigChainService, + private readonly channelMetadataAccessController: ChannelMetadataAccessController ) { super() this._handleEventDownloadProgress = this._handleEventDownloadProgress.bind(this) @@ -167,14 +164,7 @@ export class ChannelsService extends EventEmitter { */ public async createChannelsDb(): Promise { this.logger.info('Creating channels database') - this.channels = await this.orbitDbService.open>( - CHANNEL_METADATA_STORE_NAME, - { - sync: false, - Database: KeyValueIndexedValidated(this.validateEntry.bind(this)), - AccessController: IPFSAccessController({ write: ['*'] }), - } - ) + this.channels = await this.openMigratedChannelsDb() this.channels.events.on('update', (entry: LogEntry) => { const channelId = entry.payload?.value?.channelId @@ -201,6 +191,72 @@ export class ChannelsService extends EventEmitter { } } + private async openMigratedChannelsDb(): Promise> { + const legacyChannels = await this.orbitDbService.open>( + CHANNEL_METADATA_STORE_NAME, + { + sync: false, + Database: KeyValueIndexedValidated(this.validateEntry.bind(this)), + AccessController: IPFSAccessController({ write: ['*'] }), + } + ) + const newChannels = await this.orbitDbService.open>( + CHANNEL_METADATA_STORE_NAME, + { + sync: false, + Database: KeyValueIndexedValidated(this.validateEntry.bind(this)), + AccessController: this.channelMetadataAccessController.createAccessControllerFunc({ + write: ['*'], + sigchainService: this.sigchainService, + }), + } + ) + const legacyEntries = await legacyChannels.all() + const newChannelsCount = (await newChannels.all()).length + + if (newChannelsCount > 0) { + await legacyChannels.close() + return newChannels + } + + if (legacyEntries.length === 0) { + await legacyChannels.close() + return newChannels + } + + if (this.currentUserIsAdmin()) { + this.logger.info('Migrating legacy channel metadata into custom access-controller database', { + legacyAddress: legacyChannels.address, + newAddress: newChannels.address, + channels: legacyEntries.length, + }) + + this.channels = newChannels + for (const entry of legacyEntries) { + await newChannels.put(entry.key, entry.value) + } + + await legacyChannels.close() + return newChannels + } + + this.logger.info('Using legacy channel metadata database until an admin populates the new database', { + legacyAddress: legacyChannels.address, + newAddress: newChannels.address, + channels: legacyEntries.length, + }) + await newChannels.close() + return legacyChannels + } + + private currentUserIsAdmin(): boolean { + const chain = this.sigchainService.getActiveChain(false) + if (chain == null) { + return false + } + return chain.roles.memberIsAdmin(chain.user.userId) + } + public encryptChannelEntry(payload: PublicChannel): EncryptedAndSignedPayload { try { const chain = this.sigchainService.getActiveChain() @@ -436,48 +492,20 @@ export class ChannelsService extends EventEmitter { } } - /** - * Enforce that a channel's metadata can only be written by its owner. - * - * For bound channel ids (`${name}_${nonce}_${commitment}`) the owner is committed into the id, so - * we can verify ownership statelessly. This holds regardless of replication/index-rebuild order, - * which the stateful fallback below cannot guarantee. - * - * For legacy ids (created before owner binding) there is no commitment to check, so we fall back - * to comparing against the stored entry. That fallback only reliably protects the live case (it - * depends on the existing entry already being indexed), but it is the best we can do for channels - * that predate the migration. - * - * @param entry The log entry being validated - * @param decEntry The decrypted channel metadata from the new entry - * @param writerId The verified id of the entry writer (already pinned to the encrypted signature author and owner) - * @returns True if the writer is authorized to write this channel's metadata - */ private async validateChannelOwnership( entry: LogEntry, decEntry: PublicChannel, writerId: string ): Promise { - if (isBoundChannelId(decEntry.id)) { - if (!verifyChannelIdOwner(decEntry.id, writerId)) { - this.logger.error('Failed to validate channel entry: channel id is not bound to the writer:', entry.hash, { - channelId: decEntry.id, - writerId, - }) - return false - } - return true - } - return this.validateLegacyChannelMetadataUpdate(entry, decEntry, writerId) } /** - * Legacy (pre-owner-binding) fallback. Channel metadata is write-once: the only legitimate PUT for - * a given channel id is its creation. If an entry already exists for this key, ensure the writer is - * the original owner and that the security-relevant fields (owner, public flag, role) are - * unchanged. NOTE: this depends on the existing entry already being indexed, so it does not protect - * against a takeover observed during an initial full replication/index rebuild. + * Channel metadata is write-once: the only legitimate PUT for a given channel id is its creation. + * If an entry already exists for this key, ensure the writer is the original owner and that the + * security-relevant fields (owner, public flag, role) are unchanged. NOTE: this depends on the + * existing entry already being indexed, so it does not protect against a takeover observed during + * an initial full replication/index rebuild. * * @param entry The log entry being validated * @param decEntry The decrypted channel metadata from the new entry @@ -574,6 +602,10 @@ export class ChannelsService extends EventEmitter { public async validateEntry(entry: LogEntry): Promise { // TODO: unpin invalidated entries? try { + if (await this.isAdminChannelMetadataWriter(entry)) { + return true + } + if (entry.payload.op === 'PUT') { const encPayload = entry.payload.value! const decEntry = this.decryptChannelEntry(encPayload) @@ -601,6 +633,28 @@ export class ChannelsService extends EventEmitter { return true } + private async isAdminChannelMetadataWriter(entry: LogEntry): Promise { + if (entry.payload.op !== 'PUT' && entry.payload.op !== 'DEL') { + return false + } + + const chain = this.sigchainService.getActiveChain(false) + if (chain == null) { + return false + } + + const writerIdentity = await this.getVerifiedChannelEntryWriter(entry, entry.payload.op) + if (writerIdentity == null) { + return false + } + + if (writerIdentity.teamId !== chain.team!.id) { + return false + } + + return chain.roles.memberIsAdmin(writerIdentity.id) + } + /** * Broadcasts current channels to any listeners */ @@ -787,12 +841,6 @@ export class ChannelsService extends EventEmitter { public: payload.public ?? true, teamId: payload.teamId, } - // Self-defense: the channel id must commit to us (the owner) or our own validateEntry would - // reject the resulting entry network-wide. Fail fast before creating a role/store. - if (!verifyChannelIdOwner(channelData.id, channelData.owner)) { - this.logger.error('Refusing to create channel: id is not bound to the owner:', channelData.id) - return { status: ChannelOperationStatus.FAILED } - } let roleName: string | undefined = undefined if (!(channelData.public ?? true)) { roleName = this.sigchainService.getActiveChain().channels.create(channelData.id) diff --git a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts new file mode 100644 index 0000000000..92be69d0e4 --- /dev/null +++ b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts @@ -0,0 +1,165 @@ +/** + * OrbitDB access controller for channel metadata + */ + +import { + AccessController, + type CanAppendFunc, + type IdentitiesType, + type LogEntry, + type OrbitDBType, +} from '@orbitdb/core' +import { Injectable } from '@nestjs/common' +import * as Block from 'multiformats/block' +import * as dagCbor from '@ipld/dag-cbor' +import { sha256 } from 'multiformats/hashes/sha2' +import { base58btc } from 'multiformats/bases/base58' +import { ComposedStorage, IPFSBlockStorage, LRUStorage, type Storage } from '@orbitdb/core' +import { RoleName } from '../../../auth/services/roles/roles' +import { SigChainService } from '../../../auth/sigchain.service' +import { EncryptedAndSignedPayload } from '../../../auth/services/crypto/types' +import { createLogger } from '../../../common/logger' +import { QuietLogger } from '@quiet/logger' +import { posixJoin } from '../../orbitDb/util' + +const TYPE = 'channelmetadataaccess' +const codec = dagCbor +const hasher = sha256 +const hashStringEncoding = base58btc + +const AccessControlList = async ({ storage, params }: { storage: Storage; params: Record }) => { + const manifest = { + type: TYPE, + ...params, + } + const { cid, bytes } = await Block.encode({ value: manifest, codec, hasher }) + const hash = cid.toString(hashStringEncoding) + await storage.put(hash, bytes) + return hash +} + +const getAccessControllerManifestHash = (address: string): string => { + const hash = address.split('/').filter(Boolean).pop() + if (hash == null) { + throw new Error(`Invalid access controller address: ${address}`) + } + return hash +} + +interface ChannelMetadataAccessControllerConfig { + write: string[] + sigchainService: SigChainService +} + +interface ChannelMetadataWriterIdentity { + id: string + teamId?: string +} + +@Injectable() +export class ChannelMetadataAccessController { + protected readonly logger: QuietLogger + + constructor(protected sigchainService: SigChainService) { + this.logger = createLogger(`storage:channels:metadata:orbitdb:access-control:${TYPE}`) + } + + public createAccessControllerFunc(config: ChannelMetadataAccessControllerConfig): typeof AccessController { + const accessController = (options?: any) => { + if (options?.orbitdb != null) { + return this._createAccessControllerFuncImpl(config)(options) + } + return this._createAccessControllerFuncImpl({ ...config, ...options }) + } + ;(accessController as any).type = TYPE + return accessController as typeof AccessController + } + + private _createAccessControllerFuncImpl(config: ChannelMetadataAccessControllerConfig): typeof AccessController { + return async ({ + orbitdb, + identities, + address, + }: { + orbitdb: OrbitDBType + identities: IdentitiesType + address?: string + name?: string + }) => { + const storage = await ComposedStorage( + await LRUStorage({ size: 1000 }), + await IPFSBlockStorage({ ipfs: orbitdb.ipfs, pin: true }) + ) + let write = config.write || [orbitdb.identity.id] + + if (address) { + const manifestBytes = await storage.get(getAccessControllerManifestHash(address)) + const { value } = await Block.decode({ bytes: manifestBytes, codec, hasher }) + // FIXME: Figure out typings + // @ts-ignore + write = value.write + } else { + address = await AccessControlList({ storage, params: { write } }) + address = posixJoin('/', TYPE, address) + } + + return { + type: TYPE, + address, + write, + canAppend: this.canAppend({ ...config, write }, identities) as any, + } + } + } + + protected canAppend(config: ChannelMetadataAccessControllerConfig, identities: IdentitiesType): CanAppendFunc { + return async (entry: LogEntry): Promise => { + const writerIdentity = (await identities.getIdentity(entry.identity)) as ChannelMetadataWriterIdentity + if (!writerIdentity) { + return false + } + + if (!config.write.includes(writerIdentity.id) && !config.write.includes('*')) { + return false + } + + if (!(await identities.verifyIdentity(writerIdentity as any))) { + return false + } + + const chain = config.sigchainService.getActiveChain(false) + if (chain == null) { + this.logger.warn(`Cannot verify channel metadata writer without an active chain`) + return false + } + + if (writerIdentity.teamId != null && writerIdentity.teamId !== chain.team!.id) { + this.logger.warn(`Channel metadata writer identity is from a different team`, { + entryTeamId: writerIdentity.teamId, + activeTeamId: chain.team!.id, + }) + return false + } + + if (chain.roles.memberIsAdmin(writerIdentity.id)) { + return true + } + + if (!chain.roles.memberHasRole(writerIdentity.id, RoleName.MEMBER)) { + this.logger.warn(`Channel metadata writer is not a team member`, { + writerId: writerIdentity.id, + }) + return false + } + + if (entry.payload.op === 'DEL') { + this.logger.warn(`Channel metadata deletes require an admin writer`, { + writerId: writerIdentity.id, + }) + return false + } + + return true + } + } +} diff --git a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts new file mode 100644 index 0000000000..11c7e2b6c8 --- /dev/null +++ b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, jest } from '@jest/globals' +import { base58btc } from 'multiformats/bases/base58' +import { type LogEntry } from '@orbitdb/core' + +import { ChannelMetadataAccessController } from './ChannelMetadataAccessController' +import { RoleName } from '../../../auth/services/roles/roles' +import { EncryptedAndSignedPayload } from '../../../auth/services/crypto/types' + +const emptyAsyncIterable = async function* () {} + +const createInMemoryIpfs = () => { + const blocks = new Map() + const cidKey = (cid: any): string => cid.toString(base58btc) + + return { + blockstore: { + put: async (cid: any, bytes: Uint8Array) => { + blocks.set(cidKey(cid), bytes) + }, + get: async (cid: any) => blocks.get(cidKey(cid)), + }, + pins: { + isPinned: async () => false, + add: () => emptyAsyncIterable(), + }, + } +} + +const createSigchainService = ({ + teamId = 'team-id', + member = true, + admin = false, +}: { + teamId?: string + member?: boolean + admin?: boolean +}) => + ({ + getActiveChain: jest.fn().mockReturnValue({ + team: { id: teamId }, + roles: { + memberHasRole: jest.fn( + (memberId: string, roleName: string) => memberId === 'writer-id' && roleName === RoleName.MEMBER && member + ), + memberIsAdmin: jest.fn((memberId: string) => memberId === 'writer-id' && admin), + }, + }), + }) as any + +const createEntry = (op: 'PUT' | 'DEL'): LogEntry => + ({ + identity: 'writer-identity-hash', + payload: { + op, + key: 'channel-id', + value: op === 'PUT' ? {} : undefined, + }, + }) as unknown as LogEntry + +const createAccess = async (sigchainService: any) => { + const controller = new ChannelMetadataAccessController(sigchainService) + const factory = controller.createAccessControllerFunc({ write: ['*'], sigchainService }) + return (factory as any)({ + orbitdb: { + identity: { id: 'local-orbitdb-identity' }, + ipfs: createInMemoryIpfs(), + }, + identities: { + getIdentity: jest.fn().mockResolvedValue({ id: 'writer-id', teamId: 'team-id' } as never), + verifyIdentity: jest.fn().mockResolvedValue(true as never), + }, + }) +} + +describe('ChannelMetadataAccessController', () => { + it('loads the ACL manifest from a persisted typed access-controller address', async () => { + const sigchainService = createSigchainService({}) + const controller = new ChannelMetadataAccessController(sigchainService) + const factory = controller.createAccessControllerFunc({ write: ['writer-id'], sigchainService }) + const orbitdb = { + identity: { id: 'local-orbitdb-identity' }, + ipfs: createInMemoryIpfs(), + } + const identities = {} + + const created = await (factory as any)({ orbitdb, identities }) + expect(created.address).toMatch(/^\/channelmetadataaccess\/z/) + expect(created.write).toEqual(['writer-id']) + + await expect((factory as any)({ orbitdb, identities, address: created.address })).resolves.toMatchObject({ + address: created.address, + write: ['writer-id'], + }) + }) + + it('allows channel metadata PUT entries from team members', async () => { + const access = await createAccess(createSigchainService({ member: true })) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(true) + }) + + it('rejects channel metadata PUT entries from non-members', async () => { + const access = await createAccess(createSigchainService({ member: false })) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) + }) + + it('rejects channel metadata DEL entries from non-admin members', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: false })) + + await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(false) + }) + + it('allows channel metadata DEL entries from admins', async () => { + const access = await createAccess(createSigchainService({ member: true, admin: true })) + + await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(true) + }) + + it('allows channel metadata entries from admins even without the member role', async () => { + const access = await createAccess(createSigchainService({ member: false, admin: true })) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(true) + await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(true) + }) +}) diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 6d24f3a5b6..8192224244 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -7,6 +7,7 @@ import { ORBIT_DB_DIR } from '../../const' import { createLogger } from '../../common/logger' import { logEntryToLogUpdate, posixJoin } from './util' import { MessagesAccessController } from '../channels/messages/orbitdb/MessagesAccessController' +import { ChannelMetadataAccessController } from '../channels/orbitdb/ChannelMetadataAccessController' import { createOrbitDB, type OrbitDBType, @@ -44,7 +45,8 @@ export class OrbitDbService { private readonly localDbService: LocalDbService, private readonly sigChainService: SigChainService, private readonly lfaIdentities: LFAIdentities, - private readonly messagesAccessController: MessagesAccessController + private readonly messagesAccessController: MessagesAccessController, + private readonly channelMetadataAccessController: ChannelMetadataAccessController ) { OrbitDbService.events.on('update', (entry: LogEntry) => { if (entry.identity == this.orbitDbInstance?.identity.hash) { @@ -75,6 +77,12 @@ export class OrbitDbService { sigchainService: this.sigChainService, }) as any ) + orbitDbUseAccessController( + this.channelMetadataAccessController.createAccessControllerFunc({ + write: ['*'], + sigchainService: this.sigChainService, + }) as any + ) /** * This overrides the built-in identity system to use our custom LFA-based identity service @@ -150,6 +158,9 @@ export class OrbitDbService { const store = await this.orbitDbInstance.open(address, options) const storeAddress = (store as { address: string }).address this.stores[storeAddress] = store + store.events?.on?.('close', () => { + delete this.stores[storeAddress] + }) this.logger.info(`Opened OrbitDB store ${address} at address: ${storeAddress}`) await this.joinPendingHeads(storeAddress) diff --git a/packages/backend/src/nest/storage/orbitDb/orbitdb.module.ts b/packages/backend/src/nest/storage/orbitDb/orbitdb.module.ts index 4cf40728ce..48f138a577 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitdb.module.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitdb.module.ts @@ -15,6 +15,7 @@ import { PrivateChannelMessagesService } from '../channels/messages/private-chan import { NotificationTokensStore } from '../notifications/notificationTokens.store' import { MessagesAccessController } from '../channels/messages/orbitdb/MessagesAccessController' import { PrivateMessagesAccessController } from '../channels/messages/orbitdb/PrivateMessagesAccessController' +import { ChannelMetadataAccessController } from '../channels/orbitdb/ChannelMetadataAccessController' @Module({ imports: [ @@ -36,6 +37,7 @@ import { PrivateMessagesAccessController } from '../channels/messages/orbitdb/Pr LFAIdentities, MessagesAccessController, PrivateMessagesAccessController, + ChannelMetadataAccessController, ], exports: [ OrbitDbService, @@ -49,6 +51,7 @@ import { PrivateMessagesAccessController } from '../channels/messages/orbitdb/Pr LFAIdentities, MessagesAccessController, PrivateMessagesAccessController, + ChannelMetadataAccessController, ], }) export class OrbitDbModule {} From 529b8a9a43babcfd01de12d09929b2b9ac922b17 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 26 Jun 2026 14:36:42 -0400 Subject: [PATCH 06/21] Revert "chore: disable private channels in UI (#3318)" This reverts commit 90322af3b27bfdcf6191a455ed2cedccd9e69597. --- CHANGELOG.md | 1 - .../CreateChannel/CreateChannelComponent.tsx | 5 +-- .../components/SearchModal/SearchModal.tsx | 9 +---- .../renderer/components/Sidebar/Sidebar.tsx | 3 +- .../src/rtl-tests/channel.add.test.tsx | 4 +- .../src/rtl-tests/channel.switch.test.tsx | 8 +--- packages/e2e-tests/src/selectors.ts | 4 +- ...ultipleClients.privateChannels.qss.test.ts | 4 +- .../multipleClients.privateChannels.test.ts | 4 +- .../CreateChannel/CreateChannel.component.tsx | 5 +-- .../ChannelList/ChannelList.screen.tsx | 37 +++++++++---------- 11 files changed, 28 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4cac2ab5d..ca8a88c50d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ * Don't send deletion message for private channels [#3273](https://github.com/TryQuiet/quiet/issues/3273) * Ensure notification registration waits for auth handshake [#3289](https://github.com/TryQuiet/quiet/issues/3289) * Fixed a race condition that can cause stale data to remain after leaving community [#3253](https://github.com/TryQuiet/quiet/issues/3253) -* Temporarily disable private channels ### Chores diff --git a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannelComponent.tsx b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannelComponent.tsx index 6e4618b829..53da8d82f2 100644 --- a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannelComponent.tsx +++ b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannelComponent.tsx @@ -151,9 +151,6 @@ export interface CreateChannelProps { clearErrorsDispatch: () => void } -// Private channels are hidden from the UI for now -const SHOW_PRIVATE_CHANNEL_TOGGLE = false - export const CreateChannelComponent: React.FC = ({ open, channelCreationError, @@ -276,7 +273,7 @@ export const CreateChannelComponent: React.FC = ({ )} - {SHOW_PRIVATE_CHANNEL_TOGGLE && canCreatePrivateChannel && ( + {canCreatePrivateChannel && ( <> { const searchChannelModal = useModal(ModalName.searchChannelModal) - // Hide private channels from search results - const dynamicSearchedChannelsSelector = useSelector( - publicChannels.selectors.dynamicSearchedChannels(channelInput) - ).filter(channel => channel.public !== false) + const dynamicSearchedChannelsSelector = useSelector(publicChannels.selectors.dynamicSearchedChannels(channelInput)) const unreadChannelsSelector = useSelector(publicChannels.selectors.unreadChannels) - const publicChannelsSelector = useSelector(publicChannels.selectors.publicChannels).filter( - channel => channel.public !== false - ) + const publicChannelsSelector = useSelector(publicChannels.selectors.publicChannels) const setCurrentChannel = useCallback( (id: string) => { diff --git a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx index ad4dc28cf1..2013ce6ce0 100644 --- a/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/Sidebar.tsx @@ -52,8 +52,7 @@ const Sidebar = () => { } const channelsPanelProps: ChannelsPanelProps = { - // Hide private channels from the UI - channels: publicChannelsSelector.filter(channel => channel.public !== false), + channels: publicChannelsSelector, userProfiles: userProfileSelector, connectedPeers: connectedPeers, unreadChannels: unreadChannels, diff --git a/packages/desktop/src/rtl-tests/channel.add.test.tsx b/packages/desktop/src/rtl-tests/channel.add.test.tsx index d33ad6e465..6bb3b75e4e 100644 --- a/packages/desktop/src/rtl-tests/channel.add.test.tsx +++ b/packages/desktop/src/rtl-tests/channel.add.test.tsx @@ -28,9 +28,7 @@ const logger = createLogger('channel:add') jest.setTimeout(20_000) -// TODO: Re-enable when private channels are unhidden in the UI. These tests assert the private -// channel toggle is visible in the create-channel modal, which is currently hidden. -describe.skip('Add new channel', () => { +describe('Add new channel', () => { let socket: MockedSocket let socketFactory: FactoryGirl diff --git a/packages/desktop/src/rtl-tests/channel.switch.test.tsx b/packages/desktop/src/rtl-tests/channel.switch.test.tsx index 160ece1235..8ddeba6cdc 100644 --- a/packages/desktop/src/rtl-tests/channel.switch.test.tsx +++ b/packages/desktop/src/rtl-tests/channel.switch.test.tsx @@ -99,9 +99,7 @@ describe('Switch channels', () => { } }) - // TODO: Re-enable when private channels are unhidden in the UI. Uses a private "pets" channel, - // which is now hidden from the sidebar. - it.skip('Opens another channel', async () => { + it('Opens another channel', async () => { const generalChannelMessage = await factory.create('TestMessage', { // @ts-expect-error message: generateMessageFactoryContentWithId(generalId, alice.userId), @@ -155,9 +153,7 @@ describe('Switch channels', () => { expect(message).toBeVisible() }) - // TODO: Re-enable when private channels are unhidden in the UI. Asserts the private "pets" channel - // highlight, which is now hidden from the sidebar. - it.skip('Highlights channel with unread messages and removes the highlight when entered', async () => { + it('Highlights channel with unread messages and removes the highlight when entered', async () => { const messagesIds = ['memes', 'pets'] const messages: ChannelMessage[] = [] diff --git a/packages/e2e-tests/src/selectors.ts b/packages/e2e-tests/src/selectors.ts index d23413a356..0199c5191c 100644 --- a/packages/e2e-tests/src/selectors.ts +++ b/packages/e2e-tests/src/selectors.ts @@ -2252,9 +2252,7 @@ export class Sidebar { return channel } - // NOTE: expectToggle defaults to false because the private channel toggle is currently hidden from - // the UI. Creating private channels (isPublic=false) is therefore not possible via the UI. - async addNewChannel(name: string, isPublic: boolean = true, expectToggle: boolean = false): Promise { + async addNewChannel(name: string, isPublic: boolean = true, expectToggle: boolean = true): Promise { const button = await this.driver.wait( until.elementLocated(By.xpath('//button[@data-testid="addChannelButton"]')), 5_000, diff --git a/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts b/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts index 20330a137f..f76cd5a88f 100644 --- a/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts @@ -23,9 +23,7 @@ import { ChildProcess } from 'child_process' const logger = createLogger('multipleClients:privateChannels:qss') jest.setTimeout(1200000) // 20 minutes -// TODO: Re-enable when private channels are unhidden in the UI. Private channels (and the create -// toggle) are currently hidden, so they cannot be created or viewed via the UI. -describe.skip('Multiple Clients (QSS - Private Channels)', () => { +describe('Multiple Clients (QSS - Private Channels)', () => { let generalChannelOwner: Channel let generalChannelUser1: Channel let generalChannelUser2: Channel diff --git a/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts b/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts index b1739b52dd..be3b4a028e 100644 --- a/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts @@ -29,9 +29,7 @@ import { deleteChannelMessage } from '@quiet/common' const logger = createLogger('multipleClients:privateChannels') jest.setTimeout(1200000) // 20 minutes -// TODO: Re-enable when private channels are unhidden in the UI. Private channels (and the create -// toggle) are currently hidden, so they cannot be created or viewed via the UI. -describe.skip('Multiple Clients (Private Channels)', () => { +describe('Multiple Clients (Private Channels)', () => { let generalChannelOwner: Channel let generalChannelUser1: Channel let generalChannelUser2: Channel diff --git a/packages/mobile/src/components/CreateChannel/CreateChannel.component.tsx b/packages/mobile/src/components/CreateChannel/CreateChannel.component.tsx index b3fd0fa8de..1b226ded7c 100644 --- a/packages/mobile/src/components/CreateChannel/CreateChannel.component.tsx +++ b/packages/mobile/src/components/CreateChannel/CreateChannel.component.tsx @@ -22,9 +22,6 @@ export interface CreateChannelProps { handleBackButton: () => void } -// Private channels are hidden from the UI for now -const SHOW_PRIVATE_CHANNEL_TOGGLE = false - export const CreateChannel: FC = ({ canCreateChannel, canCreatePrivateChannel, @@ -145,7 +142,7 @@ export const CreateChannel: FC = ({ )} - {SHOW_PRIVATE_CHANNEL_TOGGLE && canCreatePrivateChannel && ( + {canCreatePrivateChannel && ( { const channelsStatusSorted = useSelector(publicChannels.selectors.channelsStatusSorted) - // Hide private channels from the UI - const tiles = channelsStatusSorted - .filter(status => (status.public ?? true) !== false) - .map(status => { - const newestMessage = status.newestMessage - - const message = newestMessage?.message || '...' - const date = newestMessage?.createdAt ? formatTileDate(newestMessage.createdAt) : undefined - - const tile: ChannelTileProps = { - name: getChannelNameFromChannelId(status.id), - isPublic: status.public ?? true, - id: status.id, - message, - date, - unread: status.unread, - redirect, - } + const tiles = channelsStatusSorted.map(status => { + const newestMessage = status.newestMessage + + const message = newestMessage?.message || '...' + const date = newestMessage?.createdAt ? formatTileDate(newestMessage.createdAt) : undefined + + const tile: ChannelTileProps = { + name: getChannelNameFromChannelId(status.id), + isPublic: status.public ?? true, + id: status.id, + message, + date, + unread: status.unread, + redirect, + } - return tile - }) + return tile + }) const communityContextMenu = useContextMenu(MenuName.Community) From ce328435c38ec223925bae08bd42c4a95f6e8a67 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 26 Jun 2026 14:56:36 -0400 Subject: [PATCH 07/21] fix: store private channel metadata separately --- .../channels.service.migration.unit.spec.ts | 75 ++++- .../storage/channels/channels.service.spec.ts | 89 +++++- .../nest/storage/channels/channels.service.ts | 286 +++++++++++++----- packages/types/src/channel.ts | 1 + 4 files changed, 370 insertions(+), 81 deletions(-) diff --git a/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts index 5f0bc853cb..3aea673746 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts @@ -14,6 +14,10 @@ const createChannelsService = (orbitDbService: any, isAdmin = false) => user: { userId: 'local-user-id' }, roles: { memberIsAdmin: jest.fn((userId: string) => userId === 'local-user-id' && isAdmin), + amIMemberOfRole: jest.fn(() => true), + }, + crypto: { + decryptAndVerify: jest.fn((encrypted: any) => ({ contents: encrypted.contents })), }, }), } as any, @@ -102,7 +106,24 @@ describe('ChannelsService channel metadata access-controller migration', () => { }) it('lets admin users populate the new channel metadata store from legacy entries', async () => { - const legacyEntry = { key: 'general', value: {} } + const legacyChannel = { + id: 'general', + name: 'general', + description: '', + owner: 'local-user-id', + timestamp: 1, + public: true, + } + const legacyEntry = { + key: 'general', + value: { + encrypted: { + scope: { type: 'TEAM' }, + contents: legacyChannel, + }, + signature: {}, + }, + } const legacyStore = { address: '/orbitdb/legacy-channel-metadata', all: jest.fn().mockResolvedValue([legacyEntry] as never), @@ -129,4 +150,56 @@ describe('ChannelsService channel metadata access-controller migration', () => { expect(legacyStore.close).toHaveBeenCalled() expect(newStore.close).not.toHaveBeenCalled() }) + + it('moves decryptable private channel metadata from the legacy store into the private store', async () => { + const privateChannel = { + id: 'secret', + name: 'secret', + description: '', + owner: 'local-user-id', + timestamp: 1, + public: false, + roleName: 'private_secret', + } + const legacyEntry = { + key: 'secret', + value: { + encrypted: { + scope: { type: 'TEAM' }, + contents: privateChannel, + }, + signature: {}, + }, + } + const legacyStore = { + address: '/orbitdb/legacy-channel-metadata', + all: jest.fn().mockResolvedValue([legacyEntry] as never), + close: jest.fn().mockResolvedValue(undefined as never), + } + const newStore = { + address: '/orbitdb/new-channel-metadata', + all: jest.fn().mockResolvedValue([] as never), + put: jest.fn().mockResolvedValue(undefined as never), + close: jest.fn(), + } + const privateStore = { + put: jest.fn().mockResolvedValue(undefined as never), + } + const orbitDbService = { + open: jest + .fn() + .mockResolvedValueOnce(legacyStore as never) + .mockResolvedValueOnce(newStore as never), + } + const channelsService = createChannelsService(orbitDbService, true) + const channelsServiceWithPrivateStore = channelsService as any + channelsServiceWithPrivateStore.privateChannels = privateStore + + const result = await (channelsService as any).openMigratedChannelsDb() + + expect(result).toBe(newStore) + expect(newStore.put).not.toHaveBeenCalled() + expect(privateStore.put).toHaveBeenCalledWith(legacyEntry.key, legacyEntry.value) + expect(legacyStore.close).toHaveBeenCalled() + }) }) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 9efaf9e0d8..f2f9af80c0 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -196,12 +196,22 @@ describe('ChannelsService', () => { const expectChannelEntryValidation = async ( entry: LogEntry, writerUserId: string, - expected: boolean + expected: boolean, + validator: 'legacy' | 'public' | 'private' = 'public' ): Promise => { const restoreIdentityMocks = mockChannelEntryIdentity(writerUserId) try { - await expect(channelsService.validateEntry(entry)).resolves.toBe(expected) + switch (validator) { + case 'public': + await expect(channelsService.validatePublicChannelMetadataEntry(entry)).resolves.toBe(expected) + break + case 'private': + await expect(channelsService.validatePrivateChannelMetadataEntry(entry)).resolves.toBe(expected) + break + case 'legacy': + await expect(channelsService.validateLegacyChannelMetadataEntry(entry)).resolves.toBe(expected) + } } finally { restoreIdentityMocks() } @@ -362,6 +372,25 @@ describe('ChannelsService', () => { await channelsService.subscribeToChannel(channel1) }) + it('stores private channel metadata outside the public channel metadata store', async () => { + const privateChannel: PublicChannel = { + id: 'private-metadata-store-channel-id', + name: 'private-metadata-store-channel', + description: 'private channel metadata store split', + owner: aliceUserId, + timestamp: Date.now(), + public: false, + teamId: community.teamId!, + } + privateChannel.roleName = sigChainService.getActiveChain().channels.create(privateChannel.id) + + await channelsService.createChannel(privateChannel) + + await expect(channelsService.channels!.get(privateChannel.id)).resolves.toBeUndefined() + await expect(channelsService.privateChannels!.get(privateChannel.id)).resolves.toBeDefined() + await expect(channelsService.getChannel(privateChannel.id)).resolves.toEqual(privateChannel) + }) + // skipping because we don't have a strong way to prevent a user from deleting a channel yet it.skip('delete channel as standard user', async () => { logger.info('Deleting channel as standard user') @@ -494,7 +523,55 @@ describe('ChannelsService', () => { privateChannel.roleName = activeChain.channels.create(privateChannel.id) const encryptedEntry = channelsService.encryptChannelEntry(privateChannel) - await expectChannelEntryValidation(channelPutEntry(privateChannel.id, encryptedEntry), aliceUserId, true) + await expectChannelEntryValidation( + channelPutEntry(privateChannel.id, encryptedEntry), + aliceUserId, + true, + 'private' + ) + }) + + it('rejects private channel metadata in the public metadata store', async () => { + const activeChain = sigChainService.getActiveChain() + const privateChannel: PublicChannel = { + id: 'public-store-private-channel-id', + name: 'public-store-private-channel', + description: 'private channel metadata in public store', + owner: aliceUserId, + timestamp: Date.now(), + public: false, + teamId: community.teamId!, + } + privateChannel.roleName = activeChain.channels.create(privateChannel.id) + const encryptedEntry = channelsService.encryptChannelEntry(privateChannel) + + await expectChannelEntryValidation( + channelPutEntry(privateChannel.id, encryptedEntry), + aliceUserId, + false, + 'public' + ) + await expectChannelEntryValidation( + channelPutEntry(privateChannel.id, encryptedEntry), + aliceUserId, + true, + 'private' + ) + }) + + it('rejects public channel metadata in the private metadata store', async () => { + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const encryptedEntry = channelsService.encryptChannelEntry(publicChannel) + + await expectChannelEntryValidation( + channelPutEntry(publicChannel.id, encryptedEntry), + aliceUserId, + false, + 'private' + ) }) it('rejects forged private channel metadata encrypted to the broad member role', async () => { @@ -517,7 +594,8 @@ describe('ChannelsService', () => { await expectChannelEntryValidation( channelPutEntry(forgedPrivateChannel.id, forgedEntry, 'forged-private-channel-metadata'), malloryChain.user.userId, - false + false, + 'private' ) }) @@ -541,7 +619,8 @@ describe('ChannelsService', () => { await expectChannelEntryValidation( channelPutEntry(privateChannel.id, teamScopedEntry, 'team-scoped-private-channel-metadata'), malloryChain.user.userId, - false + false, + 'private' ) }) diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 9f4bf56c1c..7748b870c0 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -23,6 +23,7 @@ import { DownloadStatus, RemoveDownloadStatus, CHANNEL_METADATA_STORE_NAME, + PRIVATE_CHANNEL_METADATA_STORE_NAME, ChannelOperationStatus, } from '@quiet/types' import fs from 'fs' @@ -55,15 +56,17 @@ export class ChannelsService extends EventEmitter { // Channel metadata store public channels: KeyValueIndexedValidatedType | undefined + public privateChannels: KeyValueIndexedValidatedType | undefined private fileManagerEventsAttached = false private sigchainListenerAttached = false private readonly handleSigchainUpdated = async (): Promise => { - if (!this.channels) { + if (!this.channels || !this.privateChannels) { return } try { await this.channels.retryIndexingUnindexedEntries() + await this.privateChannels.retryIndexingUnindexedEntries() await this.broadcastCurrentChannels() } catch (e) { this.logger.warn('Error when attempting to reindex on sigchain update', e) @@ -117,10 +120,11 @@ export class ChannelsService extends EventEmitter { } public updateMetadata(metadata: Record): void { - if (this.channels == null) { + if (this.channels == null || this.privateChannels == null) { throw new Error('Channels database must be initialized before updating metadata!') } OrbitDbService.updateMetadata(this.channels, metadata) + OrbitDbService.updateMetadata(this.privateChannels, metadata) for (const repo of this.channelsRepos.values()) { repo.store.updateMetadata(metadata) } @@ -144,6 +148,7 @@ export class ChannelsService extends EventEmitter { */ public async startSync(): Promise { await this.channels?.sync.start() + await this.privateChannels?.sync.start() this.logger.info(`Started syncing channels management database`) } @@ -152,6 +157,7 @@ export class ChannelsService extends EventEmitter { */ public async stopSync(): Promise { await this.channels?.sync.stop() + await this.privateChannels?.sync.stop() } // Channels Database Management @@ -164,16 +170,11 @@ export class ChannelsService extends EventEmitter { */ public async createChannelsDb(): Promise { this.logger.info('Creating channels database') + this.privateChannels = await this.openPrivateChannelsDb() this.channels = await this.openMigratedChannelsDb() - this.channels.events.on('update', (entry: LogEntry) => { - const channelId = entry.payload?.value?.channelId - const operation = entry.payload.op - this.logger.info('channels database updated', channelId, operation) - - this.emit(SocketEvents.CONNECTION_PROCESS_INFO, ConnectionProcessInfo.CHANNELS_STORED) - this.broadcastCurrentChannels() - }) + this.attachChannelMetadataUpdateHandler(this.channels) + this.attachChannelMetadataUpdateHandler(this.privateChannels) if (!this.sigchainListenerAttached) { this.sigchainService.on(SigchainEvents.UPDATED, this.handleSigchainUpdated) @@ -191,12 +192,25 @@ export class ChannelsService extends EventEmitter { } } + private attachChannelMetadataUpdateHandler( + store: KeyValueIndexedValidatedType | undefined + ): void { + store?.events.on('update', (entry: LogEntry) => { + const channelId = entry.payload?.value?.channelId + const operation = entry.payload.op + this.logger.info('channels database updated', channelId, operation) + + this.emit(SocketEvents.CONNECTION_PROCESS_INFO, ConnectionProcessInfo.CHANNELS_STORED) + this.broadcastCurrentChannels() + }) + } + private async openMigratedChannelsDb(): Promise> { const legacyChannels = await this.orbitDbService.open>( CHANNEL_METADATA_STORE_NAME, { sync: false, - Database: KeyValueIndexedValidated(this.validateEntry.bind(this)), + Database: KeyValueIndexedValidated(this.validateLegacyChannelMetadataEntry.bind(this)), AccessController: IPFSAccessController({ write: ['*'] }), } ) @@ -204,7 +218,7 @@ export class ChannelsService extends EventEmitter { CHANNEL_METADATA_STORE_NAME, { sync: false, - Database: KeyValueIndexedValidated(this.validateEntry.bind(this)), + Database: KeyValueIndexedValidated(this.validatePublicChannelMetadataEntry.bind(this)), AccessController: this.channelMetadataAccessController.createAccessControllerFunc({ write: ['*'], sigchainService: this.sigchainService, @@ -233,7 +247,15 @@ export class ChannelsService extends EventEmitter { this.channels = newChannels for (const entry of legacyEntries) { - await newChannels.put(entry.key, entry.value) + const channel = this.tryDecryptLegacyChannelEntry(entry.key, entry.value) + if (channel == null) { + continue + } + if (channel.public === false) { + await this.privateChannels!.put(entry.key, entry.value) + } else { + await newChannels.put(entry.key, entry.value) + } } await legacyChannels.close() @@ -249,6 +271,20 @@ export class ChannelsService extends EventEmitter { return legacyChannels } + private async openPrivateChannelsDb(): Promise> { + return await this.orbitDbService.open>( + PRIVATE_CHANNEL_METADATA_STORE_NAME, + { + sync: false, + Database: KeyValueIndexedValidated(this.validatePrivateChannelMetadataEntry.bind(this)), + AccessController: this.channelMetadataAccessController.createAccessControllerFunc({ + write: ['*'], + sigchainService: this.sigchainService, + }), + } + ) + } + private currentUserIsAdmin(): boolean { const chain = this.sigchainService.getActiveChain(false) if (chain == null) { @@ -303,10 +339,25 @@ export class ChannelsService extends EventEmitter { } } + private tryDecryptLegacyChannelEntry(key: string, value: EncryptedAndSignedPayload): PublicChannel | undefined { + try { + return this.decryptChannelEntry(value, key) + } catch (e) { + if (e instanceof NotAMemberError || e.message.startsWith('Not a member of this channel')) { + this.logger.warn(`Skipping legacy private channel metadata migration because local user is not a member`, key) + } else { + this.logger.warn(`Skipping legacy channel metadata migration because the entry could not be decrypted`, key, e) + } + return undefined + } + } + private async validateChannelEntryMetadata( entry: LogEntry, encPayload: EncryptedAndSignedPayload, - decEntry: PublicChannel + decEntry: PublicChannel, + expectedPublic: boolean | undefined, + metadataStore: KeyValueIndexedValidatedType | undefined ): Promise { const key = entry.payload.key const sigAuthor = encPayload.signature.author.name @@ -346,14 +397,13 @@ export class ChannelsService extends EventEmitter { return false } - if (decEntry.owner !== sigAuthor || decEntry.owner !== writerIdentity.id) { + if (decEntry.owner !== sigAuthor) { this.logger.error( - 'Failed to validate channel entry: owner must match encrypted payload signature author and entry signature author:', + 'Failed to validate channel entry: owner must match encrypted payload signature author:', entry.hash, { owner: decEntry.owner, encryptedSignatureAuthor: sigAuthor, - entrySignatureAuthor: writerIdentity.id, } ) return false @@ -367,7 +417,20 @@ export class ChannelsService extends EventEmitter { return false } - if (!(await this.validateChannelOwnership(entry, decEntry, writerIdentity.id))) { + if (!this.validateChannelPrivacy(entry, decEntry, expectedPublic)) { + return false + } + + const writerIsAdmin = await this.writerIsAdmin(writerIdentity.id) + if (decEntry.owner !== writerIdentity.id && !writerIsAdmin) { + this.logger.error('Failed to validate channel entry: owner must match entry signature author:', entry.hash, { + owner: decEntry.owner, + entrySignatureAuthor: writerIdentity.id, + }) + return false + } + + if (!writerIsAdmin && !(await this.validateChannelOwnership(entry, decEntry, writerIdentity.id, metadataStore))) { return false } @@ -495,9 +558,18 @@ export class ChannelsService extends EventEmitter { private async validateChannelOwnership( entry: LogEntry, decEntry: PublicChannel, - writerId: string + writerId: string, + metadataStore: KeyValueIndexedValidatedType | undefined ): Promise { - return this.validateLegacyChannelMetadataUpdate(entry, decEntry, writerId) + if (metadataStore == null) { + this.logger.warn( + 'Skipping existing channel ownership check because metadata store is not assigned yet:', + entry.hash + ) + return true + } + + return this.validateLegacyChannelMetadataUpdate(entry, decEntry, writerId, metadataStore) } /** @@ -515,15 +587,16 @@ export class ChannelsService extends EventEmitter { private async validateLegacyChannelMetadataUpdate( entry: LogEntry, decEntry: PublicChannel, - writerId: string + writerId: string, + metadataStore: KeyValueIndexedValidatedType ): Promise { - const stored = await this.channels!.get(entry.payload.key!) + const stored = await metadataStore.get(entry.payload.key!) if (stored == null) { return true } // A stored entry that we cannot decrypt (e.g. a private channel we don't belong to) must not be - // overwritten. decryptChannelEntry throws here and validateEntry fails closed. + // overwritten. decryptChannelEntry throws here and channel metadata validation fails closed. const storedChannel = this.decryptChannelEntry(stored as EncryptedAndSignedPayload, entry.payload.key!) if (storedChannel.owner !== writerId) { @@ -554,6 +627,33 @@ export class ChannelsService extends EventEmitter { return true } + private validateChannelPrivacy( + entry: LogEntry, + decEntry: PublicChannel, + expectedPublic: boolean | undefined + ): boolean { + if (expectedPublic == null) { + return true + } + + const isPrivateChannel = !(decEntry.public ?? true) + if (expectedPublic && isPrivateChannel) { + this.logger.error('Failed to validate channel entry: private metadata cannot be stored publicly:', entry.hash, { + channelId: decEntry.id, + }) + return false + } + + if (!expectedPublic && !isPrivateChannel) { + this.logger.error('Failed to validate channel entry: public metadata cannot be stored privately:', entry.hash, { + channelId: decEntry.id, + }) + return false + } + + return true + } + private async validateChannelDeleteEntry(entry: LogEntry): Promise { const key = entry.payload.key if (!key) { @@ -594,18 +694,24 @@ export class ChannelsService extends EventEmitter { return true } - /** - * Validates a log entry in the OrbitDB store. - * @param entry The log entry to validate. - * @returns True if valid, false otherwise. - */ - public async validateEntry(entry: LogEntry): Promise { - // TODO: unpin invalidated entries? - try { - if (await this.isAdminChannelMetadataWriter(entry)) { - return true - } + public async validateLegacyChannelMetadataEntry(entry: LogEntry): Promise { + return this.validateChannelMetadataEntry(entry, undefined, this.channels) + } + + public async validatePublicChannelMetadataEntry(entry: LogEntry): Promise { + return this.validateChannelMetadataEntry(entry, true, this.channels) + } + + public async validatePrivateChannelMetadataEntry(entry: LogEntry): Promise { + return this.validateChannelMetadataEntry(entry, false, this.privateChannels) + } + private async validateChannelMetadataEntry( + entry: LogEntry, + expectedPublic: boolean | undefined, + metadataStore: KeyValueIndexedValidatedType | undefined + ): Promise { + try { if (entry.payload.op === 'PUT') { const encPayload = entry.payload.value! const decEntry = this.decryptChannelEntry(encPayload) @@ -613,7 +719,7 @@ export class ChannelsService extends EventEmitter { this.logger.error('Decrypted channel entry is not a valid channel:', entry.hash, decEntry) return false } - if (!(await this.validateChannelEntryMetadata(entry, encPayload, decEntry))) { + if (!(await this.validateChannelEntryMetadata(entry, encPayload, decEntry, expectedPublic, metadataStore))) { return false } } @@ -633,26 +739,13 @@ export class ChannelsService extends EventEmitter { return true } - private async isAdminChannelMetadataWriter(entry: LogEntry): Promise { - if (entry.payload.op !== 'PUT' && entry.payload.op !== 'DEL') { - return false - } - + private async writerIsAdmin(writerId: string): Promise { const chain = this.sigchainService.getActiveChain(false) if (chain == null) { return false } - const writerIdentity = await this.getVerifiedChannelEntryWriter(entry, entry.payload.op) - if (writerIdentity == null) { - return false - } - - if (writerIdentity.teamId !== chain.team!.id) { - return false - } - - return chain.roles.memberIsAdmin(writerIdentity.id) + return chain.roles.memberIsAdmin(writerId) } /** @@ -686,11 +779,25 @@ export class ChannelsService extends EventEmitter { * @throws Error */ public async setChannel(channel: PublicChannel): Promise { - if (!this.channels) { + if (!this.channels || !this.privateChannels) { throw new Error('Channels have not been initialized!') } const encryptedChannel = this.encryptChannelEntry(channel) - await this.channels.put(channel.id, encryptedChannel) + await this.getMetadataStoreForChannel(channel).put(channel.id, encryptedChannel) + } + + private getMetadataStoreForChannel(channel: PublicChannel): KeyValueIndexedValidatedType { + if (channel.public === false) { + if (this.privateChannels == null) { + throw new Error('Private channels have not been initialized!') + } + return this.privateChannels + } + + if (this.channels == null) { + throw new Error('Channels have not been initialized!') + } + return this.channels } /** @@ -702,25 +809,39 @@ export class ChannelsService extends EventEmitter { */ public async getChannel(id: string): Promise { this.logger.debug('Getting channel', id) - if (!this.channels) { + if (!this.channels || !this.privateChannels) { throw new Error('Channels have not been initialized!') } - const channelEncrypted = await this.channels.get(id) - if (channelEncrypted == null) { - return undefined - } - // need to rehydrate the UInt8Array bc json value encoding in KeyValueIndexedValidated does not maintain type - try { - return this.decryptChannelEntry(channelEncrypted as EncryptedAndSignedPayload, id) - } catch (e) { - if (e instanceof NotAMemberError || e.message.startsWith('Not a member of this channel')) { - this.logger.warn(`Failed to decrypt and validate private channel entry during getChannel, ignoring...`, id) - } else { - this.logger.error('Failed to decrypt channel entry', e) + + for (const store of this.getReadableMetadataStores()) { + const channelEncrypted = await store.get(id) + if (channelEncrypted == null) { + continue + } + // need to rehydrate the UInt8Array bc json value encoding in KeyValueIndexedValidated does not maintain type + try { + return this.decryptChannelEntry(channelEncrypted as EncryptedAndSignedPayload, id) + } catch (e) { + if (e instanceof NotAMemberError || e.message.startsWith('Not a member of this channel')) { + this.logger.warn(`Failed to decrypt and validate private channel entry during getChannel, ignoring...`, id) + } else { + this.logger.error('Failed to decrypt channel entry', e) + } } } } + private getReadableMetadataStores(): KeyValueIndexedValidatedType[] { + const stores: KeyValueIndexedValidatedType[] = [] + if (this.channels != null) { + stores.push(this.channels) + } + if (this.privateChannels != null) { + stores.push(this.privateChannels) + } + return stores + } + /** * Read entries for all keys in the channels management database * @@ -729,14 +850,16 @@ export class ChannelsService extends EventEmitter { */ public async getChannels(): Promise { this.logger.debug('Getting channels') - if (!this.channels) { + if (!this.channels || !this.privateChannels) { throw new Error('Channels have not been initialized!') } - return (await this.channels.all()) - .map(x => { + const channelsById = new Map() + for (const store of this.getReadableMetadataStores()) { + const entries = await store.all() + for (const x of entries) { try { this.logger.debug('Decrypting channel entry', x.key) - return this.decryptChannelEntry(x.value, x.key) + channelsById.set(x.key, this.decryptChannelEntry(x.value, x.key)) } catch (e) { if (e instanceof NotAMemberError || e.message.startsWith('Not a member of this channel')) { this.logger.warn( @@ -746,10 +869,10 @@ export class ChannelsService extends EventEmitter { } else { this.logger.error('Failed to decrypt channel entry', e) } - return undefined } - }) - .filter((x): x is PublicChannel => x !== undefined) + } + } + return [...channelsById.values()] } /** @@ -759,7 +882,7 @@ export class ChannelsService extends EventEmitter { * @throws Error */ public async getPrivateChannelsByRolename(): Promise> { - if (!this.channels) { + if (!this.channels || !this.privateChannels) { throw new Error('Channels have not been initialized!') } const channels = await this.getChannels() @@ -805,7 +928,7 @@ export class ChannelsService extends EventEmitter { this.logger.info(`Channel ${channelId} already exists`) } - this.channelsRepos.set(channelId, { store, eventsAttached: false, public: true }) + this.channelsRepos.set(channelId, { store, eventsAttached: false, public: channelData.public ?? true }) this.logger.info(`Set ${channelId} to local channels`) this.logger.info(`Created channel ${channelId}`) @@ -948,7 +1071,7 @@ export class ChannelsService extends EventEmitter { // NOTE: this doesn't prevent other users from deleting channels they don't own if they modify the client // TODO: invalidate removals from non-owners if (iAmAdmin || iOwnThisChannel) { - await this.channels!.del(channelId) + await this.deleteChannelMetadata(channelId) } else { this.logger.error(`User is not the owner of the channel ${channelId}`) return { channelId, deleted: false } as DeleteChannelResponse @@ -972,6 +1095,15 @@ export class ChannelsService extends EventEmitter { return { channelId, deleted: true } as DeleteChannelResponse } + private async deleteChannelMetadata(channelId: string): Promise { + if (this.channels == null || this.privateChannels == null) { + throw new Error('Channels have not been initialized!') + } + + await this.channels.del(channelId) + await this.privateChannels.del(channelId) + } + public async addMembersToPrivateChannel(payload: AddMembersChannelPayload): Promise { const { channelId, channelName, memberIds } = payload this.logger.info(`Adding ${memberIds.length} members to private channel`, channelId, channelName) @@ -1201,6 +1333,7 @@ export class ChannelsService extends EventEmitter { try { this.logger.info('Closing channels DB') await this.channels?.close() + await this.privateChannels?.close() this.logger.info('Closed channels DB') } catch (e) { this.logger.error('Error closing channels db', e) @@ -1251,11 +1384,13 @@ export class ChannelsService extends EventEmitter { this.logger.info('Cleaning channels DB') try { await this.channels?.sync?.stop?.() + await this.privateChannels?.sync?.stop?.() } catch (e) { // If the sync is not started, this will throw an error } try { await this.channels?.drop?.() + await this.privateChannels?.drop?.() } catch (e) { this.logger.error('Error dropping channels DB', e) } @@ -1268,6 +1403,7 @@ export class ChannelsService extends EventEmitter { } } this.channels = undefined + this.privateChannels = undefined this.channelsRepos = new Map() } } diff --git a/packages/types/src/channel.ts b/packages/types/src/channel.ts index be3c0a3f71..f7ceea0987 100644 --- a/packages/types/src/channel.ts +++ b/packages/types/src/channel.ts @@ -7,6 +7,7 @@ export const PROFILE_PHOTO_CHANNEL_ID = '__profile-photo__' export const INITIAL_CURRENT_CHANNEL_ID = 'initialcurrentChannelId' export const CHANNEL_METADATA_STORE_NAME = 'public-channels' +export const PRIVATE_CHANNEL_METADATA_STORE_NAME = 'private-channels' export interface PublicChannel { id: string From 3e5c7e7b0308398c4f24dd0788b5791de924b2f9 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 26 Jun 2026 16:26:48 -0400 Subject: [PATCH 08/21] make puts immutable --- .../storage/channels/channels.service.spec.ts | 23 +++++ .../ChannelMetadataAccessController.ts | 67 +++++++++++++- ...annelMetadataAccessController.unit.spec.ts | 87 ++++++++++++++++++- .../storage/orbitDb/keyValueWithStorage.ts | 6 ++ 4 files changed, 179 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index f2f9af80c0..740e25ae6f 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -391,6 +391,29 @@ describe('ChannelsService', () => { await expect(channelsService.getChannel(privateChannel.id)).resolves.toEqual(privateChannel) }) + it('does not append a second metadata PUT for an existing channel id', async () => { + const publicChannel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + await channelsService.setChannel(publicChannel) + + await channelsService.setChannel({ + ...publicChannel, + name: 'renamed-channel', + }) + + const metadataLog = channelsService.channels!.log as unknown as { + values: () => Promise>> + } + const metadataEntries = await metadataLog.values() + const channelPuts = metadataEntries.filter( + entry => entry.payload.op === 'PUT' && entry.payload.key === publicChannel.id + ) + expect(channelPuts).toHaveLength(1) + await expect(channelsService.getChannel(publicChannel.id)).resolves.toEqual(publicChannel) + }) + // skipping because we don't have a strong way to prevent a user from deleting a channel yet it.skip('delete channel as standard user', async () => { logger.info('Deleting channel as standard user') diff --git a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts index 92be69d0e4..66e6a781b4 100644 --- a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts +++ b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.ts @@ -7,6 +7,7 @@ import { type CanAppendFunc, type IdentitiesType, type LogEntry, + type LogType, type OrbitDBType, } from '@orbitdb/core' import { Injectable } from '@nestjs/common' @@ -103,16 +104,25 @@ export class ChannelMetadataAccessController { address = posixJoin('/', TYPE, address) } + let log: LogType | undefined + return { type: TYPE, address, write, - canAppend: this.canAppend({ ...config, write }, identities) as any, + setLogContext: (nextLog: LogType) => { + log = nextLog + }, + canAppend: this.canAppend({ ...config, write }, identities, () => log) as any, } } } - protected canAppend(config: ChannelMetadataAccessControllerConfig, identities: IdentitiesType): CanAppendFunc { + protected canAppend( + config: ChannelMetadataAccessControllerConfig, + identities: IdentitiesType, + getLog: () => LogType | undefined + ): CanAppendFunc { return async (entry: LogEntry): Promise => { const writerIdentity = (await identities.getIdentity(entry.identity)) as ChannelMetadataWriterIdentity if (!writerIdentity) { @@ -141,6 +151,10 @@ export class ChannelMetadataAccessController { return false } + if (entry.payload.op === 'PUT' && !(await this.canAppendPutForKey(entry, getLog(), writerIdentity.id))) { + return false + } + if (chain.roles.memberIsAdmin(writerIdentity.id)) { return true } @@ -162,4 +176,53 @@ export class ChannelMetadataAccessController { return true } } + + private async canAppendPutForKey( + entry: LogEntry, + log: LogType | undefined, + writerId: string + ): Promise { + const channelId = entry.payload.key + if (channelId == null) { + this.logger.warn(`Channel metadata PUT rejected because the entry key is missing`, { + entryHash: entry.hash, + }) + return false + } + + if (log == null) { + this.logger.warn(`Channel metadata PUT rejected because log state is unavailable`, { + channelId, + entryHash: entry.hash, + }) + return false + } + + try { + for await (const existingEntry of log.traverse(null, async () => false)) { + if ( + existingEntry.hash !== entry.hash && + existingEntry.payload.op === 'PUT' && + existingEntry.payload.key === channelId + ) { + this.logger.warn(`Channel metadata PUT rejected because the channel id already has a PUT entry`, { + writerId, + channelId, + entryHash: entry.hash, + existingEntryHash: existingEntry.hash, + }) + return false + } + } + } catch (e) { + this.logger.warn(`Channel metadata PUT rejected because log state could not be checked`, { + channelId, + entryHash: entry.hash, + error: e, + }) + return false + } + + return true + } } diff --git a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts index 11c7e2b6c8..3e420ebcde 100644 --- a/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts +++ b/packages/backend/src/nest/storage/channels/orbitdb/ChannelMetadataAccessController.unit.spec.ts @@ -47,16 +47,31 @@ const createSigchainService = ({ }), }) as any -const createEntry = (op: 'PUT' | 'DEL'): LogEntry => +const createEntry = ( + op: 'PUT' | 'DEL', + key = 'channel-id', + hash = `${op.toLowerCase()}-${key}` +): LogEntry => ({ + hash, identity: 'writer-identity-hash', payload: { op, - key: 'channel-id', + key, value: op === 'PUT' ? {} : undefined, }, }) as unknown as LogEntry +const attachLogContext = (access: any, entries: LogEntry[] = []) => { + access.setLogContext({ + traverse: async function* () { + for (const entry of entries) { + yield entry + } + }, + }) +} + const createAccess = async (sigchainService: any) => { const controller = new ChannelMetadataAccessController(sigchainService) const factory = controller.createAccessControllerFunc({ write: ['*'], sigchainService }) @@ -95,16 +110,83 @@ describe('ChannelMetadataAccessController', () => { it('allows channel metadata PUT entries from team members', async () => { const access = await createAccess(createSigchainService({ member: true })) + attachLogContext(access) await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(true) }) it('rejects channel metadata PUT entries from non-members', async () => { const access = await createAccess(createSigchainService({ member: false })) + attachLogContext(access) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) + }) + + it('rejects channel metadata PUT entries when log state is unavailable', async () => { + const access = await createAccess(createSigchainService({ member: true })) await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) }) + it('rejects channel metadata PUT entries when the entry key is missing', async () => { + const access = await createAccess(createSigchainService({ member: true })) + attachLogContext(access) + const entry = createEntry('PUT') + ;(entry.payload as any).key = undefined + + await expect(access.canAppend(entry)).resolves.toBe(false) + }) + + it('rejects channel metadata PUT entries when log traversal throws', async () => { + const access = await createAccess(createSigchainService({ member: true })) + access.setLogContext({ + traverse: () => ({ + [Symbol.asyncIterator]: () => ({ + next: () => Promise.reject(new Error('log read failed')), + }), + }), + }) + + await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(false) + }) + + it('rejects channel metadata PUT entries when a previous PUT exists for the same key', async () => { + const access = await createAccess(createSigchainService({ member: true })) + const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') + const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') + attachLogContext(access, [previousEntry]) + + await expect(access.canAppend(nextEntry)).resolves.toBe(false) + }) + + it('allows channel metadata PUT entries when previous PUTs are for different keys', async () => { + const access = await createAccess(createSigchainService({ member: true })) + const previousEntry = createEntry('PUT', 'other-channel-id') + const nextEntry = createEntry('PUT', 'channel-id') + attachLogContext(access, [previousEntry]) + + await expect(access.canAppend(nextEntry)).resolves.toBe(true) + }) + + it('rejects channel metadata PUT entries after a prior PUT even when the latest entry is a DEL', async () => { + const access = await createAccess(createSigchainService({ member: true })) + const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') + const deleteEntry = createEntry('DEL', 'channel-id', 'deleted-channel') + const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') + attachLogContext(access, [deleteEntry, previousEntry]) + + await expect(access.canAppend(nextEntry)).resolves.toBe(false) + }) + + it('rejects duplicate channel metadata PUT entries from admins', async () => { + const access = await createAccess(createSigchainService({ member: false, admin: true })) + const previousEntry = createEntry('PUT', 'channel-id', 'previous-channel-put') + const nextEntry = createEntry('PUT', 'channel-id', 'next-channel-put') + attachLogContext(access, [previousEntry]) + + await expect(access.canAppend(nextEntry)).resolves.toBe(false) + }) + it('rejects channel metadata DEL entries from non-admin members', async () => { const access = await createAccess(createSigchainService({ member: true, admin: false })) @@ -119,6 +201,7 @@ describe('ChannelMetadataAccessController', () => { it('allows channel metadata entries from admins even without the member role', async () => { const access = await createAccess(createSigchainService({ member: false, admin: true })) + attachLogContext(access) await expect(access.canAppend(createEntry('PUT'))).resolves.toBe(true) await expect(access.canAppend(createEntry('DEL'))).resolves.toBe(true) diff --git a/packages/backend/src/nest/storage/orbitDb/keyValueWithStorage.ts b/packages/backend/src/nest/storage/orbitDb/keyValueWithStorage.ts index 7c771e3a41..2ec49a02c6 100644 --- a/packages/backend/src/nest/storage/orbitDb/keyValueWithStorage.ts +++ b/packages/backend/src/nest/storage/orbitDb/keyValueWithStorage.ts @@ -5,6 +5,10 @@ import { OrbitDbService } from './orbitDb.service' const logger = createLogger('orbitdb:keyValueWithStorage') +type LogAwareAccessController = AccessControllerType & { + setLogContext?: (log: LogType) => void +} + export const KeyValueWithStorage = (pinIpfs = true) => async ({ @@ -57,6 +61,8 @@ export const KeyValueWithStorage = events: OrbitDbService.events, }) + ;(access as LogAwareAccessController).setLogContext?.(db.log) + db.events.on('error', error => { logger.error(`Error on OrbitDB DB ${db.address}`, error) }) From e3c0ef91f8de89ad749a4ac9d33ef6dd2d7b5dcb Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 26 Jun 2026 18:24:52 -0400 Subject: [PATCH 09/21] rm migration; optimize e2e test --- .../channels.service.migration.unit.spec.ts | 205 ------------------ .../storage/channels/channels.service.spec.ts | 8 +- .../nest/storage/channels/channels.service.ts | 93 +------- packages/e2e-tests/src/selectors.ts | 30 ++- ...ultipleClients.privateChannels.qss.test.ts | 47 ++-- 5 files changed, 50 insertions(+), 333 deletions(-) delete mode 100644 packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts diff --git a/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts deleted file mode 100644 index 3aea673746..0000000000 --- a/packages/backend/src/nest/storage/channels/channels.service.migration.unit.spec.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it, jest } from '@jest/globals' - -import { ChannelsService } from './channels.service' - -const createChannelsService = (orbitDbService: any, isAdmin = false) => - new ChannelsService( - '/tmp/orbitdb', - '/tmp/ipfs', - {} as any, - orbitDbService, - {} as any, - { - getActiveChain: jest.fn().mockReturnValue({ - user: { userId: 'local-user-id' }, - roles: { - memberIsAdmin: jest.fn((userId: string) => userId === 'local-user-id' && isAdmin), - amIMemberOfRole: jest.fn(() => true), - }, - crypto: { - decryptAndVerify: jest.fn((encrypted: any) => ({ contents: encrypted.contents })), - }, - }), - } as any, - { - createAccessControllerFunc: jest.fn(() => 'channel-metadata-access-controller'), - } as any - ) - -describe('ChannelsService channel metadata access-controller migration', () => { - it('keeps a non-empty legacy channel metadata store for non-admin users until the new store is populated', async () => { - const legacyStore = { - address: '/orbitdb/legacy-channel-metadata', - all: jest.fn().mockResolvedValue([{ key: 'general', value: {} }] as never), - close: jest.fn(), - } - const newStore = { - address: '/orbitdb/new-channel-metadata', - all: jest.fn().mockResolvedValue([] as never), - close: jest.fn().mockResolvedValue(undefined as never), - } - const orbitDbService = { - open: jest - .fn() - .mockResolvedValueOnce(legacyStore as never) - .mockResolvedValueOnce(newStore as never), - } - const channelsService = createChannelsService(orbitDbService) - - const result = await (channelsService as any).openMigratedChannelsDb() - - expect(result).toBe(legacyStore) - expect(orbitDbService.open).toHaveBeenCalledTimes(2) - expect(legacyStore.close).not.toHaveBeenCalled() - expect(newStore.close).toHaveBeenCalled() - }) - - it('closes an empty legacy channel metadata store and opens the new store', async () => { - const legacyStore = { - address: '/orbitdb/legacy-channel-metadata', - all: jest.fn().mockResolvedValue([] as never), - close: jest.fn().mockResolvedValue(undefined as never), - } - const newStore = { - address: '/orbitdb/new-channel-metadata', - all: jest.fn().mockResolvedValue([] as never), - } - const orbitDbService = { - open: jest - .fn() - .mockResolvedValueOnce(legacyStore as never) - .mockResolvedValueOnce(newStore as never), - } - const channelsService = createChannelsService(orbitDbService) - - const result = await (channelsService as any).openMigratedChannelsDb() - - expect(result).toBe(newStore) - expect(orbitDbService.open).toHaveBeenCalledTimes(2) - expect(legacyStore.close).toHaveBeenCalled() - }) - - it('uses a populated new channel metadata store when one exists', async () => { - const legacyStore = { - address: '/orbitdb/legacy-channel-metadata', - all: jest.fn().mockResolvedValue([{ key: 'general', value: {} }] as never), - close: jest.fn().mockResolvedValue(undefined as never), - } - const newStore = { - address: '/orbitdb/new-channel-metadata', - all: jest.fn().mockResolvedValue([{ key: 'general', value: {} }] as never), - close: jest.fn(), - } - const orbitDbService = { - open: jest - .fn() - .mockResolvedValueOnce(legacyStore as never) - .mockResolvedValueOnce(newStore as never), - } - const channelsService = createChannelsService(orbitDbService) - - const result = await (channelsService as any).openMigratedChannelsDb() - - expect(result).toBe(newStore) - expect(legacyStore.close).toHaveBeenCalled() - expect(newStore.close).not.toHaveBeenCalled() - }) - - it('lets admin users populate the new channel metadata store from legacy entries', async () => { - const legacyChannel = { - id: 'general', - name: 'general', - description: '', - owner: 'local-user-id', - timestamp: 1, - public: true, - } - const legacyEntry = { - key: 'general', - value: { - encrypted: { - scope: { type: 'TEAM' }, - contents: legacyChannel, - }, - signature: {}, - }, - } - const legacyStore = { - address: '/orbitdb/legacy-channel-metadata', - all: jest.fn().mockResolvedValue([legacyEntry] as never), - close: jest.fn().mockResolvedValue(undefined as never), - } - const newStore = { - address: '/orbitdb/new-channel-metadata', - all: jest.fn().mockResolvedValue([] as never), - put: jest.fn().mockResolvedValue(undefined as never), - close: jest.fn(), - } - const orbitDbService = { - open: jest - .fn() - .mockResolvedValueOnce(legacyStore as never) - .mockResolvedValueOnce(newStore as never), - } - const channelsService = createChannelsService(orbitDbService, true) - - const result = await (channelsService as any).openMigratedChannelsDb() - - expect(result).toBe(newStore) - expect(newStore.put).toHaveBeenCalledWith(legacyEntry.key, legacyEntry.value) - expect(legacyStore.close).toHaveBeenCalled() - expect(newStore.close).not.toHaveBeenCalled() - }) - - it('moves decryptable private channel metadata from the legacy store into the private store', async () => { - const privateChannel = { - id: 'secret', - name: 'secret', - description: '', - owner: 'local-user-id', - timestamp: 1, - public: false, - roleName: 'private_secret', - } - const legacyEntry = { - key: 'secret', - value: { - encrypted: { - scope: { type: 'TEAM' }, - contents: privateChannel, - }, - signature: {}, - }, - } - const legacyStore = { - address: '/orbitdb/legacy-channel-metadata', - all: jest.fn().mockResolvedValue([legacyEntry] as never), - close: jest.fn().mockResolvedValue(undefined as never), - } - const newStore = { - address: '/orbitdb/new-channel-metadata', - all: jest.fn().mockResolvedValue([] as never), - put: jest.fn().mockResolvedValue(undefined as never), - close: jest.fn(), - } - const privateStore = { - put: jest.fn().mockResolvedValue(undefined as never), - } - const orbitDbService = { - open: jest - .fn() - .mockResolvedValueOnce(legacyStore as never) - .mockResolvedValueOnce(newStore as never), - } - const channelsService = createChannelsService(orbitDbService, true) - const channelsServiceWithPrivateStore = channelsService as any - channelsServiceWithPrivateStore.privateChannels = privateStore - - const result = await (channelsService as any).openMigratedChannelsDb() - - expect(result).toBe(newStore) - expect(newStore.put).not.toHaveBeenCalled() - expect(privateStore.put).toHaveBeenCalledWith(legacyEntry.key, legacyEntry.value) - expect(legacyStore.close).toHaveBeenCalled() - }) -}) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 740e25ae6f..399f14628f 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -197,7 +197,7 @@ describe('ChannelsService', () => { entry: LogEntry, writerUserId: string, expected: boolean, - validator: 'legacy' | 'public' | 'private' = 'public' + validator: 'public' | 'private' = 'public' ): Promise => { const restoreIdentityMocks = mockChannelEntryIdentity(writerUserId) @@ -209,8 +209,6 @@ describe('ChannelsService', () => { case 'private': await expect(channelsService.validatePrivateChannelMetadataEntry(entry)).resolves.toBe(expected) break - case 'legacy': - await expect(channelsService.validateLegacyChannelMetadataEntry(entry)).resolves.toBe(expected) } } finally { restoreIdentityMocks() @@ -674,9 +672,7 @@ describe('ChannelsService', () => { ) }) - it('rejects legacy-id metadata that overwrites an existing channel owned by another member', async () => { - // Legacy (unbound) ids fall back to the stateful check, which requires the original entry to be - // present/indexed first. + it('rejects metadata that overwrites an existing channel owned by another member', async () => { const publicChannel = await factory.build('PublicChannel', { owner: aliceUserId, teamId: community.teamId!, diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 7748b870c0..98b7ab4f9d 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common' -import { Entry, IPFSAccessController, type LogEntry } from '@orbitdb/core' +import { Entry, type LogEntry } from '@orbitdb/core' import { EventEmitter } from 'events' import { ChannelMessage, @@ -170,8 +170,8 @@ export class ChannelsService extends EventEmitter { */ public async createChannelsDb(): Promise { this.logger.info('Creating channels database') + this.channels = await this.openChannelsDb() this.privateChannels = await this.openPrivateChannelsDb() - this.channels = await this.openMigratedChannelsDb() this.attachChannelMetadataUpdateHandler(this.channels) this.attachChannelMetadataUpdateHandler(this.privateChannels) @@ -205,16 +205,8 @@ export class ChannelsService extends EventEmitter { }) } - private async openMigratedChannelsDb(): Promise> { - const legacyChannels = await this.orbitDbService.open>( - CHANNEL_METADATA_STORE_NAME, - { - sync: false, - Database: KeyValueIndexedValidated(this.validateLegacyChannelMetadataEntry.bind(this)), - AccessController: IPFSAccessController({ write: ['*'] }), - } - ) - const newChannels = await this.orbitDbService.open>( + private async openChannelsDb(): Promise> { + return await this.orbitDbService.open>( CHANNEL_METADATA_STORE_NAME, { sync: false, @@ -225,50 +217,6 @@ export class ChannelsService extends EventEmitter { }), } ) - const legacyEntries = await legacyChannels.all() - const newChannelsCount = (await newChannels.all()).length - - if (newChannelsCount > 0) { - await legacyChannels.close() - return newChannels - } - - if (legacyEntries.length === 0) { - await legacyChannels.close() - return newChannels - } - - if (this.currentUserIsAdmin()) { - this.logger.info('Migrating legacy channel metadata into custom access-controller database', { - legacyAddress: legacyChannels.address, - newAddress: newChannels.address, - channels: legacyEntries.length, - }) - - this.channels = newChannels - for (const entry of legacyEntries) { - const channel = this.tryDecryptLegacyChannelEntry(entry.key, entry.value) - if (channel == null) { - continue - } - if (channel.public === false) { - await this.privateChannels!.put(entry.key, entry.value) - } else { - await newChannels.put(entry.key, entry.value) - } - } - - await legacyChannels.close() - return newChannels - } - - this.logger.info('Using legacy channel metadata database until an admin populates the new database', { - legacyAddress: legacyChannels.address, - newAddress: newChannels.address, - channels: legacyEntries.length, - }) - await newChannels.close() - return legacyChannels } private async openPrivateChannelsDb(): Promise> { @@ -285,14 +233,6 @@ export class ChannelsService extends EventEmitter { ) } - private currentUserIsAdmin(): boolean { - const chain = this.sigchainService.getActiveChain(false) - if (chain == null) { - return false - } - return chain.roles.memberIsAdmin(chain.user.userId) - } - public encryptChannelEntry(payload: PublicChannel): EncryptedAndSignedPayload { try { const chain = this.sigchainService.getActiveChain() @@ -339,19 +279,6 @@ export class ChannelsService extends EventEmitter { } } - private tryDecryptLegacyChannelEntry(key: string, value: EncryptedAndSignedPayload): PublicChannel | undefined { - try { - return this.decryptChannelEntry(value, key) - } catch (e) { - if (e instanceof NotAMemberError || e.message.startsWith('Not a member of this channel')) { - this.logger.warn(`Skipping legacy private channel metadata migration because local user is not a member`, key) - } else { - this.logger.warn(`Skipping legacy channel metadata migration because the entry could not be decrypted`, key, e) - } - return undefined - } - } - private async validateChannelEntryMetadata( entry: LogEntry, encPayload: EncryptedAndSignedPayload, @@ -569,22 +496,20 @@ export class ChannelsService extends EventEmitter { return true } - return this.validateLegacyChannelMetadataUpdate(entry, decEntry, writerId, metadataStore) + return this.validateExistingChannelMetadataUpdate(entry, decEntry, writerId, metadataStore) } /** * Channel metadata is write-once: the only legitimate PUT for a given channel id is its creation. * If an entry already exists for this key, ensure the writer is the original owner and that the - * security-relevant fields (owner, public flag, role) are unchanged. NOTE: this depends on the - * existing entry already being indexed, so it does not protect against a takeover observed during - * an initial full replication/index rebuild. + * security-relevant fields (owner, public flag, role) are unchanged. * * @param entry The log entry being validated * @param decEntry The decrypted channel metadata from the new entry * @param writerId The verified id of the entry writer (already pinned to the encrypted signature author and owner) * @returns True if this is a fresh channel or a valid update by the original owner */ - private async validateLegacyChannelMetadataUpdate( + private async validateExistingChannelMetadataUpdate( entry: LogEntry, decEntry: PublicChannel, writerId: string, @@ -694,10 +619,6 @@ export class ChannelsService extends EventEmitter { return true } - public async validateLegacyChannelMetadataEntry(entry: LogEntry): Promise { - return this.validateChannelMetadataEntry(entry, undefined, this.channels) - } - public async validatePublicChannelMetadataEntry(entry: LogEntry): Promise { return this.validateChannelMetadataEntry(entry, true, this.channels) } diff --git a/packages/e2e-tests/src/selectors.ts b/packages/e2e-tests/src/selectors.ts index 0199c5191c..add88c2d99 100644 --- a/packages/e2e-tests/src/selectors.ts +++ b/packages/e2e-tests/src/selectors.ts @@ -628,6 +628,25 @@ export class ChannelContextMenu { this.driver = driver } + private async waitForElementToBeRemovedOrHidden(element: WebElement, reason: string, timeoutMs = 5_000) { + await this.driver.wait( + async () => { + try { + return !(await element.isDisplayed()) + } catch (e) { + const message = e instanceof Error ? e.message : String(e) + if (message.includes('stale element reference') || message.includes('no such element')) { + return true + } + throw e + } + }, + timeoutMs, + reason, + 500 + ) + } + async openMenu(): Promise<{ menuButton: boolean; menuOpened: boolean; iconVisible: boolean }> { let menu: WebElement try { @@ -738,7 +757,6 @@ export class ChannelContextMenu { await sleep(5000) } - // TODO: replace sleep async addMembersToChannel(channelName: string, memberNames: string[]) { const autoCompleteInput = await this.driver.wait( until.elementLocated(By.xpath(`//div[@data-testid="${channelName}-add-members-autocomplete"]`)), @@ -777,7 +795,10 @@ export class ChannelContextMenu { 500 ) await button.click() - await sleep(5_000) + await this.waitForElementToBeRemovedOrHidden( + await button, + `Channel add members modal for ${channelName} didn't close within timeout` + ) } async checkForMembersInAddMembersAutocomplete(channelName: string, memberNames: string[]): Promise { @@ -843,7 +864,10 @@ export class ChannelContextMenu { 500 ) await button.click() - await sleep(5000) + await this.waitForElementToBeRemovedOrHidden( + await button, + `Channel add members modal for ${channelName} didn't close within timeout` + ) return membersInAutocomplete } } diff --git a/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts b/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts index f76cd5a88f..e95c031f24 100644 --- a/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.privateChannels.qss.test.ts @@ -14,7 +14,7 @@ import { TermsOfServiceModal, UsersList, } from '../selectors' -import { promiseWithRetries, sleep, tailQssLogs } from '../utils' +import { promiseWithRetries, tailQssLogs } from '../utils' import { UserListStatus, UserTestData2, UserTestDataMap } from '../types' import { createLogger } from '../logger' import { SettingsModalTabName } from '../enums' @@ -47,6 +47,7 @@ describe('Multiple Clients (QSS - Private Channels)', () => { const privateChannelName = 'private-chat' const privateChannel2Name = 'second-private-chat' const generalChannelName = 'general' + const qssOnlyJoinCompletionTimeoutMs = 120_000 type Usernames = 'owner' | 'user1' | 'user2' type ChannelNames = 'private' | 'secondPrivate' | 'general' @@ -158,7 +159,6 @@ describe('Multiple Clients (QSS - Private Channels)', () => { const generalChannelText = await generalChannelOwner.element.getText() expect(generalChannelText).toEqual('general') - await sleep(10_000) }) }) @@ -332,7 +332,6 @@ describe('Multiple Clients (QSS - Private Channels)', () => { describe(`Owner Adds User To Private Channel While User Offline`, () => { it('User goes offline', async () => { - await sleep(5_000) await users.user1.app.close() }) @@ -351,7 +350,7 @@ describe('Multiple Clients (QSS - Private Channels)', () => { await channelContextMenuOwner.openAddMembersModal() const membersLeftInAutocomplete = await channelContextMenuOwner.checkForMembersInAddMembersAutocomplete( privateChannelName, - [users.user1.username, users.owner.username] + [users.user1.username] ) expect(membersLeftInAutocomplete.length).toBe(0) expect(menuButton).toBe(true) @@ -554,42 +553,24 @@ describe('Multiple Clients (QSS - Private Channels)', () => { await tosModal.chooseAgreeAndJoin() }) - it('Second user waits to join', async () => { - const joinPanel = new JoiningLoadingPanel(users.user2.app.driver) - await joinPanel.waitForJoinToComplete() - }) + it('Second user joins through QSS and sees expected state', async () => { + const app = users.user2.app + const joinPanel = new JoiningLoadingPanel(app.driver) + await joinPanel.waitForJoinToComplete(60_000, qssOnlyJoinCompletionTimeoutMs) - it('Second user sees user list', async () => { - const userList = new UsersList(users.user2.app.driver) + const userList = new UsersList(app.driver) expect(await userList.isReady()).toBeTruthy() - }) - - it('Second user sees general channel', async () => { - const app = users.user2.app - const loadNewUser = async () => { - generalChannelUser2 = new Channel(app.driver, generalChannelName) - expect(await generalChannelUser2.isReady()).toBeTruthy() - expect(await generalChannelUser2.isOpen()).toBeTruthy() - expect(await generalChannelUser2.isMessageInputReady()).toBeTruthy() - logger.timeEnd(`[${app.name}] '${users.user2.username}' joining community time`) - } - const retryConfig = app.retryConfig - const failureReason = `Failed to load app for new user ${users.user2.username} within ${retryConfig.timeoutMs}ms` - const onTimeout = async () => { - await app.close() - await app.open() - } - await promiseWithRetries(loadNewUser(), failureReason, retryConfig, onTimeout) - }) + generalChannelUser2 = new Channel(app.driver, generalChannelName) + expect(await generalChannelUser2.isReady()).toBeTruthy() + expect(await generalChannelUser2.isOpen()).toBeTruthy() + expect(await generalChannelUser2.isMessageInputReady()).toBeTruthy() + logger.timeEnd(`[${app.name}] '${users.user2.username}' joining community time`) - it('Second user can see messages from before they joined', async () => { await generalChannelUser2.getAtleastNumUserMessages(users.owner.username, users.owner.messages.general.length) await generalChannelUser2.getAtleastNumUserMessages(users.user1.username, users.user1.messages.general.length) - }) - it(`Second user sees only general in sidebar`, async () => { - sidebarUser2 = new Sidebar(users.user2.app.driver) + sidebarUser2 = new Sidebar(app.driver) await sidebarUser2.waitForChannelsNum(1) const channels = await sidebarUser2.getChannelsNames() expect(channels.length).toBe(1) From 4752e7e097510c0e6bf13fa284887c5b7389dde0 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 29 Jun 2026 18:08:51 -0400 Subject: [PATCH 10/21] fix plaintext channel name leak --- .../orbitdb-message-fanout.spec.ts | 4 +- .../storage/channels/channels.service.spec.ts | 57 +++++++++++++++++++ .../nest/storage/channels/channels.service.ts | 12 +++- packages/common/src/channelAddress.test.ts | 29 ---------- packages/common/src/channelAddress.ts | 12 ---- packages/common/src/index.ts | 1 - packages/common/src/messages.test.ts | 6 +- packages/common/src/tests.ts | 9 +++ .../src/renderer/clearCommunity.test.ts | 2 +- .../Channel/CreateChannel/CreateChannel.tsx | 7 +-- .../SearchModal/SearchModal.test.tsx | 4 +- .../SearchModal/SearchModelComponent.tsx | 2 +- .../ChannelsPanel/ChannelsPanel.test.tsx | 4 +- .../notifications/notifications.click.test.ts | 8 +-- .../src/rtl-tests/channel.add.test.tsx | 23 +++++--- .../src/rtl-tests/community.create.test.tsx | 24 ++++---- .../ChannelList/ChannelList.screen.tsx | 3 +- .../CreateChannel/CreateChannel.screen.tsx | 13 ++--- .../showNotification.saga.test.ts | 4 +- .../attachFile/sendFileMessage.saga.test.ts | 4 +- .../autoDownloadFiles.saga.test.ts | 4 +- .../broadcastHostedFile.saga.test.ts | 4 +- .../deleteFilesFromChannel.saga.test.ts | 4 +- .../sendFileMessage/attachFile.saga.test.ts | 4 +- .../updateMessageMedia.test.ts | 4 +- .../addMessages/addMessages.saga.test.ts | 6 +- .../sendDeletionMessage.saga.test.ts | 6 +- .../sendDeletionMessage.saga.ts | 4 +- .../sendMessage/sendMessage.saga.test.ts | 4 +- .../verifyMessage/verifyMessages.saga.test.ts | 6 +- .../channelDeletionResponse.saga.test.ts | 8 +-- .../channelDeletionResponse.saga.ts | 8 ++- .../channelsReplicated.saga.test.ts | 6 +- .../createChannel/createChannel.saga.test.ts | 32 ++++++++--- .../createGeneralChannel.saga.test.ts | 10 ---- .../createGeneralChannel.saga.ts | 11 +--- .../sendInitialChannelMessage.saga.test.ts | 4 +- .../sendInitialChannelMessage.saga.ts | 1 + .../deleteChannel/deleteChannel.saga.test.ts | 4 +- .../markUnreadChannels.saga.test.ts | 4 +- .../publicChannels.selectors.test.ts | 6 +- .../publicChannels.selectors.ts | 15 ++++- .../updateNewestMessage.saga.test.ts | 4 +- .../src/utils/tests/factories.ts | 12 ++-- packages/types/src/channel.ts | 1 - packages/types/src/message.ts | 1 + 46 files changed, 229 insertions(+), 172 deletions(-) delete mode 100644 packages/common/src/channelAddress.test.ts delete mode 100644 packages/common/src/channelAddress.ts diff --git a/packages/backend/src/nest/libp2p/integration-tests/orbitdb-message-fanout.spec.ts b/packages/backend/src/nest/libp2p/integration-tests/orbitdb-message-fanout.spec.ts index 75ea262b14..47f0f68c90 100644 --- a/packages/backend/src/nest/libp2p/integration-tests/orbitdb-message-fanout.spec.ts +++ b/packages/backend/src/nest/libp2p/integration-tests/orbitdb-message-fanout.spec.ts @@ -458,7 +458,7 @@ describe(`OrbitDB Syncing with ${N_PEERS} peers`, () => { expect(channel).toBeDefined() const getChannels = await channelsService.getChannels() expect(getChannels.length).toBe(2) - expect(getChannels[1].id).toBe(newChannel.id) + expect(getChannels.find(channel => channel.id === newChannel.id)).toBeDefined() // Send a message in the new channel const message = await factory.build('ChannelMessage', { @@ -489,7 +489,7 @@ describe(`OrbitDB Syncing with ${N_PEERS} peers`, () => { const channelsService = modules[i].get(ChannelsService) const channels = await channelsService.getChannels() expect(channels.length).toBe(1) // Only the first channel should be present - expect(channels[0].id).toBe(publicChannels[0].id) + expect(channels.find(channel => channel.id === publicChannels[0].id)).toBeDefined() } }) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 399f14628f..36097f4bea 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -4,7 +4,9 @@ import { Test, TestingModule } from '@nestjs/testing' import { getBaseTypesFactory } from '@quiet/state-manager' import { ChannelMessage, + ChannelOperationStatus, Community, + CreateChannelPayload, DeleteChannelResponse, FileMetadata, Identity, @@ -41,6 +43,7 @@ import { InviteService } from '../../auth/services/invites/invite.service' import { UserService } from '../../auth/services/members/user.service' import { OrbitDbService } from '../orbitDb/orbitDb.service' import { SigchainEvents } from '../../auth/types' +import crypto from 'crypto' const logger = createLogger('channelsService:test') @@ -225,6 +228,60 @@ describe('ChannelsService', () => { }) describe('Channels', () => { + it('generates an opaque channel id and stores metadata encrypted', async () => { + const payload: CreateChannelPayload = { + name: 'secret-channel-name', + description: 'secret channel description', + public: true, + teamId: community.teamId!, + } + + const response = await channelsService.handleCreateChannel(payload) + + expect(response.status).toBe(ChannelOperationStatus.SUCCESS) + expect(response.channel).toBeDefined() + expect(response.channel!.id).toMatch(/^[0-9a-f]{32}$/) + expect(response.channel!.id).not.toContain(payload.name) + expect(response.channel!.name).toBe(payload.name) + + const encryptedEntry = await channelsService.channels!.get(response.channel!.id) + expect(encryptedEntry).toBeDefined() + const serializedEntry = JSON.stringify(encryptedEntry) + expect(serializedEntry).not.toContain(payload.name) + expect(serializedEntry).not.toContain(payload.description!) + await expect(channelsService.getChannel(response.channel!.id)).resolves.toEqual(response.channel) + }) + + it('retries channel id generation when a generated id already exists', async () => { + const collidingId = '0'.repeat(32) + const expectedId = '1'.repeat(32) + const existingChannel = await factory.build('PublicChannel', { + id: collidingId, + owner: aliceUserId, + teamId: community.teamId!, + }) + await channelsService.setChannel(existingChannel) + + const randomBytesSpy = jest + .spyOn(crypto, 'randomBytes') + .mockReturnValueOnce(Buffer.from(collidingId, 'hex') as any) + .mockReturnValueOnce(Buffer.from(expectedId, 'hex') as any) + + try { + const response = await channelsService.handleCreateChannel({ + name: 'unique-channel-name', + description: 'unique channel description', + public: true, + teamId: community.teamId!, + }) + + expect(response.channel!.id).toBe(expectedId) + expect(randomBytesSpy.mock.calls.length).toBeGreaterThanOrEqual(2) + } finally { + randomBytesSpy.mockRestore() + } + }) + it('rebroadcasts channel metadata after sigchain updates', async () => { expect(channelsService.channels).toBeDefined() diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 98b7ab4f9d..6543899b5b 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -45,6 +45,7 @@ import { isChannel } from '../../validation/validators' import { NotAMemberError } from './channels.errors' import { SigchainEvents } from '../../auth/types' import { ChannelMetadataAccessController } from './orbitdb/ChannelMetadataAccessController' +import crypto from 'crypto' /** * Manages storage-level logic for all channels in Quiet @@ -763,6 +764,14 @@ export class ChannelsService extends EventEmitter { return stores } + private async generateChannelId(): Promise { + let id: string + do { + id = crypto.randomBytes(16).toString('hex') + } while (this.channelsRepos.has(id) || (await this.getChannel(id)) != null) + return id + } + /** * Read entries for all keys in the channels management database * @@ -876,8 +885,9 @@ export class ChannelsService extends EventEmitter { * @returns Response containing metadata for new channel */ public async handleCreateChannel(payload: CreateChannelPayload): Promise { + const id = await this.generateChannelId() const channelData: PublicChannel = { - id: payload.id, + id: id, name: payload.name, description: payload.description ?? '', owner: this.sigchainService.getActiveChain().user.userId, diff --git a/packages/common/src/channelAddress.test.ts b/packages/common/src/channelAddress.test.ts deleted file mode 100644 index be47efdeb6..0000000000 --- a/packages/common/src/channelAddress.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { generateChannelId, getChannelNameFromChannelId } from './channelAddress' - -describe('Generate Channel Id', () => { - it('name "rockets" is the channel name', () => { - expect(generateChannelId('rockets')).toContain('rockets') - }) - - it('Should include hexadecimals characters in a determined structure (name + _ + 16 hex)', () => { - const channelName = 'rockets' - const randomBytesLength = 32 // 16 chars in hex - const underscoreLength = 1 - const expectedLength = channelName.length + underscoreLength + randomBytesLength - expect(generateChannelId('rockets')).toHaveLength(expectedLength) - }) -}) - -describe('Get Channel Name From Channel Id', () => { - it('Should return the channel name', () => { - const channelId = 'rockets_1faff74afc8daff3256275ce89d30528' - const channelName = 'rockets' - expect(getChannelNameFromChannelId(channelId)).toEqual(channelName) - }) - it('Should return the channel id if does not match the structure', () => { - const channelName = 'rockets' - const invalidChannelId = 'rockets+1faff74afc8daff3256275ce89d30528' - expect(getChannelNameFromChannelId(channelName)).toEqual(channelName) - expect(getChannelNameFromChannelId(invalidChannelId)).toEqual(invalidChannelId) - }) -}) diff --git a/packages/common/src/channelAddress.ts b/packages/common/src/channelAddress.ts deleted file mode 100644 index cc67c5a893..0000000000 --- a/packages/common/src/channelAddress.ts +++ /dev/null @@ -1,12 +0,0 @@ -import crypto from 'crypto' - -export const generateChannelId = (channelName: string) => `${channelName}_${crypto.randomBytes(16).toString('hex')}` - -export const getChannelNameFromChannelId = (channelId: string) => { - const index = channelId.indexOf('_') - if (index === -1) { - return channelId - } else { - return channelId.substring(0, index) - } -} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index c6a363c250..ba4e0ef795 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -5,7 +5,6 @@ export * from './capitalize' export * from './process' export * from './helpers' export * from './sortPeers' -export * from './channelAddress' export * from './naming' export * from './fileData' export * from './libp2p' diff --git a/packages/common/src/messages.test.ts b/packages/common/src/messages.test.ts index 2c25ee43b0..eeb3afba8f 100644 --- a/packages/common/src/messages.test.ts +++ b/packages/common/src/messages.test.ts @@ -1,6 +1,6 @@ import { PublicChannelStorage } from '@quiet/types' -import { generateChannelId } from './channelAddress' import { createdChannelMessage, userJoinedMessage, verifyUserInfoMessage } from './messages' +import { generateTestChannelId } from './tests' describe('messages helper', () => { const username = 'johnny' @@ -11,7 +11,7 @@ describe('messages helper', () => { description: 'Welcome to #general', timestamp: 1, owner: username, - id: generateChannelId('general'), + id: generateTestChannelId('general'), messages: { ids: [], entities: {} }, public: true, teamId: 'foobar', @@ -22,7 +22,7 @@ describe('messages helper', () => { description: 'Welcome to #sport', timestamp: 1, owner: username, - id: generateChannelId('sport'), + id: generateTestChannelId('sport'), messages: { ids: [], entities: {} }, public: true, teamId: 'foobar', diff --git a/packages/common/src/tests.ts b/packages/common/src/tests.ts index b5b188e717..93ce4a7ca2 100644 --- a/packages/common/src/tests.ts +++ b/packages/common/src/tests.ts @@ -105,6 +105,15 @@ export const validInvitationDatav3: InvitationDataV3[] = [ export const validInvitationCodeTestData: InvitationData[] = [...validInvitationDatav1] +export const generateTestChannelId = (seed: string | number): string => { + let hash = 2166136261 + for (const char of String(seed)) { + hash ^= char.charCodeAt(0) + hash = Math.imul(hash, 16777619) + } + return `channel-${(hash >>> 0).toString(16).padStart(8, '0')}` +} + type TestData = { shareUrl: () => string deepUrl: () => string diff --git a/packages/desktop/src/renderer/clearCommunity.test.ts b/packages/desktop/src/renderer/clearCommunity.test.ts index 9606629eda..f6a0841916 100644 --- a/packages/desktop/src/renderer/clearCommunity.test.ts +++ b/packages/desktop/src/renderer/clearCommunity.test.ts @@ -65,7 +65,7 @@ describe('clearCommunityWithDependencies', () => { const newCommunityDatabaseChannels = ['general_new-community'] for (const channelId of state.channels) { if (!newCommunityDatabaseChannels.includes(channelId)) { - state.deletionMessages.push(`Deleted #${channelId.slice(0, channelId.indexOf('_'))}`) + state.deletionMessages.push(`Deleted #${channelId}`) } } diff --git a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx index 4b98406088..eef7345309 100644 --- a/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx +++ b/packages/desktop/src/renderer/components/Channel/CreateChannel/CreateChannel.tsx @@ -5,7 +5,6 @@ import { communities, errors, identity, publicChannels, users } from '@quiet/sta import { CreateChannelPayload, ErrorCodes, ErrorMessages, SocketActions } from '@quiet/types' import { useModal } from '../../../containers/hooks' import { ModalName } from '../../../sagas/modals/modals.types' -import { generateChannelId } from '@quiet/common' import { createLogger } from '../../../logger' const logger = createLogger('createChannel') @@ -29,10 +28,11 @@ export const CreateChannel = () => { useEffect(() => { if (!newChannel) return - if (createChannelModal.open && channels.filter(channel => channel.name === newChannel?.name).length > 0) { + const createdChannel = channels.find(channel => channel.name === newChannel.name) + if (createChannelModal.open && createdChannel != null) { dispatch( publicChannels.actions.setCurrentChannel({ - channelId: newChannel.id, + channelId: createdChannel.id, }) ) setNewChannel(null) @@ -87,7 +87,6 @@ export const CreateChannel = () => { return } const payload = { - id: generateChannelId(name), name: name, description: `Welcome to #${name}`, public: isPublic, diff --git a/packages/desktop/src/renderer/components/SearchModal/SearchModal.test.tsx b/packages/desktop/src/renderer/components/SearchModal/SearchModal.test.tsx index 62133e2726..8235080c2f 100644 --- a/packages/desktop/src/renderer/components/SearchModal/SearchModal.test.tsx +++ b/packages/desktop/src/renderer/components/SearchModal/SearchModal.test.tsx @@ -6,7 +6,7 @@ import { prepareStore } from '../../testUtils/prepareStore' import { renderComponent } from '../../testUtils/renderComponent' import { getReduxStoreFactory, publicChannels, communities, identity } from '@quiet/state-manager' import SearchModalComponent from './SearchModelComponent' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' describe('Search Modal', () => { let socket: MockedSocket @@ -45,7 +45,7 @@ describe('Search Modal', () => { description: `Welcome to #${channelMock.name}`, timestamp: channelMock.timestamp, owner: alice.nickname, - id: generateChannelId(channelMock.name), + id: generateTestChannelId(channelMock.name), teamId: community.teamId, }, }) diff --git a/packages/desktop/src/renderer/components/SearchModal/SearchModelComponent.tsx b/packages/desktop/src/renderer/components/SearchModal/SearchModelComponent.tsx index 32a398c300..1e4d67410e 100644 --- a/packages/desktop/src/renderer/components/SearchModal/SearchModelComponent.tsx +++ b/packages/desktop/src/renderer/components/SearchModal/SearchModelComponent.tsx @@ -156,7 +156,7 @@ const SearchModalComponent: React.FC = ({ mode: 'onChange', }) - const unreadChannels = publicChannelsSelector.filter(channel => unreadChannelsSelector.includes(channel.name)) + const unreadChannels = publicChannelsSelector.filter(channel => unreadChannelsSelector.includes(channel.id)) const unread = unreadChannels.length > 0 diff --git a/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.test.tsx b/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.test.tsx index f5eea3aff9..c305a360f5 100644 --- a/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.test.tsx +++ b/packages/desktop/src/renderer/components/Sidebar/ChannelsPanel/ChannelsPanel.test.tsx @@ -7,7 +7,7 @@ import { getReduxStoreFactory, publicChannels, communities, identity, users } fr import ChannelsPanel from './ChannelsPanel' import DirectMessagesPanel from '../DirectMessagesPanel/DirectMessagesPanel' import { DateTime } from 'luxon' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { Identity, UserProfile } from '@quiet/types' import { createLogger } from '../../../logger' @@ -78,7 +78,7 @@ describe('Channels panel', () => { description: `Welcome to #${name}`, timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId(name), + id: generateTestChannelId(name), public: isPublic, }, }) diff --git a/packages/desktop/src/renderer/sagas/notifications/notifications.click.test.ts b/packages/desktop/src/renderer/sagas/notifications/notifications.click.test.ts index d6a28872f0..77408d6237 100644 --- a/packages/desktop/src/renderer/sagas/notifications/notifications.click.test.ts +++ b/packages/desktop/src/renderer/sagas/notifications/notifications.click.test.ts @@ -7,7 +7,7 @@ import { call, fork } from 'typed-redux-saga' import { publicChannels, NotificationsSounds } from '@quiet/state-manager' import { MessageType, FileMetadata } from '@quiet/types' import { createNotification, handleNotificationActions, NotificationData } from './notifications.saga' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' const notification = jest.fn().mockImplementation(() => { return jest.fn() @@ -44,8 +44,8 @@ describe('clicking in notification', () => { const { store, runSaga } = await prepareStore({}, socket) - const generalId = generateChannelId('general') - const sailingId = generateChannelId('sailing') + const generalId = generateTestChannelId('general') + const sailingId = generateTestChannelId('sailing') const notificationData: NotificationData = { label: 'label', @@ -77,7 +77,7 @@ describe('clicking in notification', () => { const { runSaga } = await prepareStore({}, socket) - const sailingId = generateChannelId('sailing') + const sailingId = generateTestChannelId('sailing') const media: FileMetadata = { cid: 'cid', diff --git a/packages/desktop/src/rtl-tests/channel.add.test.tsx b/packages/desktop/src/rtl-tests/channel.add.test.tsx index 6bb3b75e4e..f7734bba69 100644 --- a/packages/desktop/src/rtl-tests/channel.add.test.tsx +++ b/packages/desktop/src/rtl-tests/channel.add.test.tsx @@ -31,8 +31,11 @@ jest.setTimeout(20_000) describe('Add new channel', () => { let socket: MockedSocket let socketFactory: FactoryGirl + let channelIdCounter = 0 + const createBackendChannelId = () => `created-channel-id-${++channelIdCounter}` beforeEach(async () => { + channelIdCounter = 0 socketFactory = await getSocketFactory() socket = new MockedSocket() ioMock.mockImplementation(() => socket) @@ -107,9 +110,10 @@ describe('Add new channel', () => { const action = input[0] if (action === SocketActions.CREATE_CHANNEL) { const payload = input[1] as CreateChannelPayload + const channelId = createBackendChannelId() factory.create('PublicChannel', { channel: { - id: payload.id, + id: channelId, name: payload.name, description: payload.description ?? '', owner: userProfile.nickname, @@ -119,7 +123,7 @@ describe('Add new channel', () => { }) return socketFactory.build(`${SocketActions.CREATE_CHANNEL}_response`, { channel: { - id: payload.id, + id: channelId, name: payload.name, description: payload.description ?? '', owner: userProfile.nickname, @@ -218,9 +222,10 @@ describe('Add new channel', () => { const action = input[0] if (action === SocketActions.CREATE_CHANNEL) { const payload = input[1] as CreateChannelPayload + const channelId = createBackendChannelId() factory.create('PublicChannel', { channel: { - id: payload.id, + id: channelId, name: payload.name, description: payload.description ?? '', owner: userProfile.nickname, @@ -230,7 +235,7 @@ describe('Add new channel', () => { }) return socketFactory.build(`${SocketActions.CREATE_CHANNEL}_response`, { channel: { - id: payload.id, + id: channelId, name: payload.name, description: payload.description ?? '', owner: userProfile.nickname, @@ -460,9 +465,10 @@ describe('Add new channel', () => { const action = input[0] if (action === SocketActions.CREATE_CHANNEL) { const payload = input[1] as CreateChannelPayload + const channelId = createBackendChannelId() factory.create('PublicChannel', { channel: { - id: payload.id, + id: channelId, name: payload.name, description: payload.description ?? '', owner: 'alice', @@ -473,7 +479,7 @@ describe('Add new channel', () => { }) return socketFactory.build(`${SocketActions.CREATE_CHANNEL}_response`, { channel: { - id: payload.id, + id: channelId, name: payload.name, description: payload.description ?? '', owner: 'alice', @@ -584,9 +590,10 @@ describe('Add new channel', () => { const action = input[0] if (action === SocketActions.CREATE_CHANNEL) { const payload = input[1] as CreateChannelPayload + const channelId = createBackendChannelId() factory.create('PublicChannel', { channel: { - id: payload.id, + id: channelId, name: payload.name, description: payload.description ?? '', owner: 'alice', @@ -597,7 +604,7 @@ describe('Add new channel', () => { }) return socketFactory.build(`${SocketActions.CREATE_CHANNEL}_response`, { channel: { - id: payload.id, + id: channelId, name: payload.name, description: payload.description ?? '', owner: 'alice', diff --git a/packages/desktop/src/rtl-tests/community.create.test.tsx b/packages/desktop/src/rtl-tests/community.create.test.tsx index dfd4fb3f71..9f66404678 100644 --- a/packages/desktop/src/rtl-tests/community.create.test.tsx +++ b/packages/desktop/src/rtl-tests/community.create.test.tsx @@ -1,4 +1,4 @@ -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { getSocketFactory, getBaseTypesFactory } from '@quiet/state-manager' import { SocketActions, socketEventData } from '@quiet/types' import { screen } from '@testing-library/dom' @@ -24,7 +24,7 @@ jest.setTimeout(20_000) describe('User', () => { let socket: MockedSocket - const generalId = generateChannelId('general') + const generalId = generateTestChannelId('general') let factory: FactoryGirl let baseTypesFactory: FactoryGirl @@ -150,17 +150,8 @@ describe('User', () => { "PublicChannels/createGeneralChannel", "PublicChannels/createChannel", "Communities/launchCommunity", - "PublicChannels/setCurrentChannel", "Connection/createInvite", - "PublicChannels/clearUnreadChannel", "Modals/closeModal", - "Messages/lazyLoading", - "Messages/resetCurrentPublicChannelCache", - "Messages/retryVerification", - "Messages/verifyMessages", - "Messages/resetCurrentPublicChannelCache", - "Messages/retryVerification", - "Messages/verifyMessages", "Communities/setCurrentCommunity", "Files/checkForMissingFiles", "Network/addInitializedCommunity", @@ -168,6 +159,8 @@ describe('User', () => { "Messages/addPublicChannelsMessagesBase", "PublicChannels/addChannel", "PublicChannels/sendInitialChannelMessage", + "PublicChannels/setCurrentChannel", + "PublicChannels/clearUnreadChannel", "PublicChannels/finishGeneralRecreation", "Messages/sendMessage", "Messages/addMessagesSendingStatus", @@ -177,6 +170,15 @@ describe('User', () => { "PublicChannels/cacheMessages", "Identity/verifyJoinTimestamp", "PublicChannels/updateNewestMessage", + "Messages/lazyLoading", + "Messages/resetCurrentPublicChannelCache", + "Messages/retryVerification", + "Messages/verifyMessages", + "Messages/addMessageVerificationStatus", + "Messages/resetCurrentPublicChannelCache", + "Messages/retryVerification", + "Messages/verifyMessages", + "Messages/addMessageVerificationStatus", ] `) }) diff --git a/packages/mobile/src/screens/ChannelList/ChannelList.screen.tsx b/packages/mobile/src/screens/ChannelList/ChannelList.screen.tsx index f782d4a021..ef6b1edef7 100644 --- a/packages/mobile/src/screens/ChannelList/ChannelList.screen.tsx +++ b/packages/mobile/src/screens/ChannelList/ChannelList.screen.tsx @@ -2,7 +2,6 @@ import React, { FC, useCallback, useEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import { communities, identity, publicChannels } from '@quiet/state-manager' -import { getChannelNameFromChannelId } from '@quiet/common' import { ChannelList as ChannelListComponent } from '../../components/ChannelList/ChannelList.component' import { ChannelTileProps } from '../../components/ChannelTile/ChannelTile.types' @@ -77,7 +76,7 @@ export const ChannelListScreen: FC = () => { const date = newestMessage?.createdAt ? formatTileDate(newestMessage.createdAt) : undefined const tile: ChannelTileProps = { - name: getChannelNameFromChannelId(status.id), + name: status.name, isPublic: status.public ?? true, id: status.id, message, diff --git a/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx b/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx index 8af78b108b..480ccdc093 100644 --- a/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx +++ b/packages/mobile/src/screens/CreateChannel/CreateChannel.screen.tsx @@ -6,7 +6,6 @@ import { ErrorCodes, ErrorMessages, SocketActions, ChannelStructure } from '@qui import { navigationSelectors } from '../../store/navigation/navigation.selectors' import { ScreenNames } from '../../const/ScreenNames.enum' import { navigationActions } from '../../store/navigation/navigation.slice' -import { generateChannelId } from '@quiet/common' import { createLogger } from '../../utils/logger' const logger = createLogger('CreateChannelScreen') @@ -34,13 +33,14 @@ export const CreateChannelScreen: FC = () => { useEffect(() => { if ( currentScreen === ScreenNames.CreateChannelScreen && - channel.channelId !== null && channel.channelName !== null && - channels.filter(_channel => _channel.name === channel.channelName).length > 0 + channels.find(_channel => _channel.name === channel.channelName) != null ) { + const createdChannel = channels.find(_channel => _channel.name === channel.channelName) + if (createdChannel == null) return dispatch( publicChannels.actions.setCurrentChannel({ - channelId: channel.channelId, + channelId: createdChannel.id, }) ) setChannel({ channelId: null, channelName: null }) @@ -86,9 +86,7 @@ export const CreateChannelScreen: FC = () => { ) return } - const id = generateChannelId(name) - - setChannel({ channelId: id, channelName: name }) + setChannel({ channelId: null, channelName: name }) if (community == null || community.teamId == null) { throw new Error(`Can't create channel when community isn't initialized`) @@ -98,7 +96,6 @@ export const CreateChannelScreen: FC = () => { publicChannels.actions.createChannel({ name: name, description: `Welcome to #${name}`, - id: id, public: isPublic, teamId: community.teamId, }) diff --git a/packages/mobile/src/store/nativeServices/showNotification/showNotification.saga.test.ts b/packages/mobile/src/store/nativeServices/showNotification/showNotification.saga.test.ts index 3aba1e9688..c72cafcecb 100644 --- a/packages/mobile/src/store/nativeServices/showNotification/showNotification.saga.test.ts +++ b/packages/mobile/src/store/nativeServices/showNotification/showNotification.saga.test.ts @@ -29,7 +29,7 @@ import { PublicChannel, UserProfile, } from '@quiet/types' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { DateTime } from 'luxon' describe('showNotificationSaga', () => { @@ -86,7 +86,7 @@ describe('showNotificationSaga', () => { description: 'Welcome to #photo', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('photo'), + id: generateTestChannelId('photo'), public: true, }, }) diff --git a/packages/state-manager/src/sagas/files/attachFile/sendFileMessage.saga.test.ts b/packages/state-manager/src/sagas/files/attachFile/sendFileMessage.saga.test.ts index 7bafe65e7b..a0413e6632 100644 --- a/packages/state-manager/src/sagas/files/attachFile/sendFileMessage.saga.test.ts +++ b/packages/state-manager/src/sagas/files/attachFile/sendFileMessage.saga.test.ts @@ -22,7 +22,7 @@ import { type PublicChannel, MessageType, } from '@quiet/types' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { currentChannelId } from '../../publicChannels/publicChannels.selectors' describe('sendFileMessageSaga', () => { @@ -56,7 +56,7 @@ describe('sendFileMessageSaga', () => { description: 'Welcome to #sailing', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('sailing'), + id: generateTestChannelId('sailing'), }, }) ).channel! diff --git a/packages/state-manager/src/sagas/files/autoDownloadFiles/autoDownloadFiles.saga.test.ts b/packages/state-manager/src/sagas/files/autoDownloadFiles/autoDownloadFiles.saga.test.ts index 7e4601fdf4..47a51742e7 100644 --- a/packages/state-manager/src/sagas/files/autoDownloadFiles/autoDownloadFiles.saga.test.ts +++ b/packages/state-manager/src/sagas/files/autoDownloadFiles/autoDownloadFiles.saga.test.ts @@ -11,7 +11,7 @@ import { autoDownloadFilesSaga } from './autoDownloadFiles.saga' import { publicChannelsActions } from '../../publicChannels/publicChannels.slice' import { DateTime } from 'luxon' import { DEFAULT_AUTODOWNLOAD_SIZE_LIMIT } from '../../../constants' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { publicChannelsSelectors } from '../../publicChannels/publicChannels.selectors' import { type Community, @@ -59,7 +59,7 @@ describe('autoDownloadFilesSaga', () => { description: 'Welcome to #sailing', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('sailing'), + id: generateTestChannelId('sailing'), }, }) ).channel diff --git a/packages/state-manager/src/sagas/files/broadcastHostedFile/broadcastHostedFile.saga.test.ts b/packages/state-manager/src/sagas/files/broadcastHostedFile/broadcastHostedFile.saga.test.ts index ba5d7a96d9..c4b532d18d 100644 --- a/packages/state-manager/src/sagas/files/broadcastHostedFile/broadcastHostedFile.saga.test.ts +++ b/packages/state-manager/src/sagas/files/broadcastHostedFile/broadcastHostedFile.saga.test.ts @@ -20,7 +20,7 @@ import { MessageType, } from '@quiet/types' import { publicChannelsSelectors } from '../../publicChannels/publicChannels.selectors' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { MockedSocket } from '../../../utils/tests/mockedSocket' import { getSocketFactory } from '../../../utils/tests/factories' @@ -57,7 +57,7 @@ describe('broadcastHostedFileSaga', () => { description: 'Welcome to #sailing', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('sailing'), + id: generateTestChannelId('sailing'), }, }) ).channel! diff --git a/packages/state-manager/src/sagas/files/deleteFilesFromChannel/deleteFilesFromChannel.saga.test.ts b/packages/state-manager/src/sagas/files/deleteFilesFromChannel/deleteFilesFromChannel.saga.test.ts index 091932cb59..ad71e83250 100644 --- a/packages/state-manager/src/sagas/files/deleteFilesFromChannel/deleteFilesFromChannel.saga.test.ts +++ b/packages/state-manager/src/sagas/files/deleteFilesFromChannel/deleteFilesFromChannel.saga.test.ts @@ -9,7 +9,7 @@ import { type Socket } from '../../../types' import { filesActions } from '../../files/files.slice' import { deleteFilesFromChannelSaga } from './deleteFilesFromChannel.saga' import { publicChannelsSelectors } from '../../publicChannels/publicChannels.selectors' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { type Community, Identity, MessageType, PublicChannel, SocketActions } from '@quiet/types' import { getReduxStoreFactory } from '../../../utils/tests/factories' @@ -51,7 +51,7 @@ describe('deleteFilesFromChannelSaga', () => { description: 'Welcome to #photo', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('id'), + id: generateTestChannelId('id'), }, }) ).channel diff --git a/packages/state-manager/src/sagas/files/sendFileMessage/attachFile.saga.test.ts b/packages/state-manager/src/sagas/files/sendFileMessage/attachFile.saga.test.ts index 0d28fa01bf..096896e8cc 100644 --- a/packages/state-manager/src/sagas/files/sendFileMessage/attachFile.saga.test.ts +++ b/packages/state-manager/src/sagas/files/sendFileMessage/attachFile.saga.test.ts @@ -20,7 +20,7 @@ import { SendingStatus, MessageType, } from '@quiet/types' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { currentChannelId } from '../../publicChannels/publicChannels.selectors' import { uploadFileSaga } from './attachFile.saga' import { getReduxStoreFactory } from '../../../utils/tests/factories' @@ -58,7 +58,7 @@ describe('uploadFileSaga', () => { description: 'Welcome to #comics', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('comics'), + id: generateTestChannelId('comics'), }, }) ).channel diff --git a/packages/state-manager/src/sagas/files/updateMessageMedia/updateMessageMedia.test.ts b/packages/state-manager/src/sagas/files/updateMessageMedia/updateMessageMedia.test.ts index d189eb3730..6f0dd5a2eb 100644 --- a/packages/state-manager/src/sagas/files/updateMessageMedia/updateMessageMedia.test.ts +++ b/packages/state-manager/src/sagas/files/updateMessageMedia/updateMessageMedia.test.ts @@ -22,7 +22,7 @@ import { FileMetadata, } from '@quiet/types' import { publicChannelsSelectors } from '../../publicChannels/publicChannels.selectors' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { getReduxStoreFactory } from '../../../utils/tests/factories' import { createLogger } from '../../../utils/logger' @@ -63,7 +63,7 @@ describe('downloadedFileSaga', () => { description: 'Welcome to #sailing', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('sailing'), + id: generateTestChannelId('sailing'), }, }) ).channel diff --git a/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.test.ts b/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.test.ts index a3b3d2e7ed..d78dd9f58a 100644 --- a/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.test.ts +++ b/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.test.ts @@ -20,7 +20,7 @@ import { MessageType, type PublicChannel, } from '@quiet/types' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' describe('addMessagesSaga', () => { let store: Store @@ -58,7 +58,7 @@ describe('addMessagesSaga', () => { description: 'Welcome to #sailing', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('sailing'), + id: generateTestChannelId('sailing'), }, }) ).channel @@ -70,7 +70,7 @@ describe('addMessagesSaga', () => { description: 'Welcome to #barbeque', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('barbeque'), + id: generateTestChannelId('barbeque'), }, }) ).channel diff --git a/packages/state-manager/src/sagas/messages/sendDeletionMessage/sendDeletionMessage.saga.test.ts b/packages/state-manager/src/sagas/messages/sendDeletionMessage/sendDeletionMessage.saga.test.ts index 27b41beb81..b6d0e883f6 100644 --- a/packages/state-manager/src/sagas/messages/sendDeletionMessage/sendDeletionMessage.saga.test.ts +++ b/packages/state-manager/src/sagas/messages/sendDeletionMessage/sendDeletionMessage.saga.test.ts @@ -10,7 +10,7 @@ import { DateTime } from 'luxon' import { messagesActions } from '../../messages/messages.slice' import { type publicChannelsActions } from '../../publicChannels/publicChannels.slice' import { sendDeletionMessageSaga } from './sendDeletionMessage.saga' -import { deleteChannelMessage, generateChannelId } from '@quiet/common' +import { deleteChannelMessage, generateTestChannelId } from '@quiet/common' import { type Community, type Identity, @@ -63,7 +63,7 @@ describe('sendDeletionMessage', () => { description: 'Welcome to #photo', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('photo'), + id: generateTestChannelId('photo'), }, }) ).channel! @@ -81,7 +81,7 @@ describe('sendDeletionMessage', () => { description: 'Welcome to #private', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('private'), + id: generateTestChannelId('private'), public: false, }, }) diff --git a/packages/state-manager/src/sagas/messages/sendDeletionMessage/sendDeletionMessage.saga.ts b/packages/state-manager/src/sagas/messages/sendDeletionMessage/sendDeletionMessage.saga.ts index d41a4de00d..50af0c4a54 100644 --- a/packages/state-manager/src/sagas/messages/sendDeletionMessage/sendDeletionMessage.saga.ts +++ b/packages/state-manager/src/sagas/messages/sendDeletionMessage/sendDeletionMessage.saga.ts @@ -18,7 +18,9 @@ export function* sendDeletionMessageSaga( const isOwner = yield* select(communitiesSelectors.isOwner) - const channelName = channelId.slice(0, channelId.indexOf('_')) + const deletedChannel = yield* select(publicChannelsSelectors.getChannelById(channelId)) + const channelName = action.payload.channelName ?? deletedChannel?.name + if (!channelName) return const payload: WriteMessagePayload = { type: MessageType.Info, diff --git a/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.test.ts b/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.test.ts index 250e56b4d1..380810f5e2 100644 --- a/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.test.ts +++ b/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.test.ts @@ -12,7 +12,7 @@ import { generateMessageId, getCurrentTime } from '../utils/message.utils' import { sendMessageSaga } from './sendMessage.saga' import { type FactoryGirl } from 'factory-girl' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { publicChannelsActions } from '../../publicChannels/publicChannels.slice' import { DateTime } from 'luxon' @@ -67,7 +67,7 @@ describe('sendMessageSaga', () => { description: 'Welcome to #sailing', timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId('sailing'), + id: generateTestChannelId('sailing'), }, }) ).channel! diff --git a/packages/state-manager/src/sagas/messages/verifyMessage/verifyMessages.saga.test.ts b/packages/state-manager/src/sagas/messages/verifyMessage/verifyMessages.saga.test.ts index 41791a3419..9e827b6546 100644 --- a/packages/state-manager/src/sagas/messages/verifyMessage/verifyMessages.saga.test.ts +++ b/packages/state-manager/src/sagas/messages/verifyMessage/verifyMessages.saga.test.ts @@ -3,7 +3,7 @@ import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' import { combineReducers } from '@reduxjs/toolkit' import { expectSaga } from 'redux-saga-test-plan' import { type FactoryGirl } from 'factory-girl' -import { generateChannelId, createdChannelMessage, userJoinedMessage, verifyUserInfoMessage } from '@quiet/common' +import { generateTestChannelId, createdChannelMessage, userJoinedMessage, verifyUserInfoMessage } from '@quiet/common' import { DateTime } from 'luxon' import { type Community, @@ -69,7 +69,7 @@ describe('verifyMessage saga test', () => { description: 'Welcome to #general', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('general'), + id: generateTestChannelId('general'), }, }) ).channel @@ -82,7 +82,7 @@ describe('verifyMessage saga test', () => { description: 'Welcome to #sport', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('sport'), + id: generateTestChannelId('sport'), }, }) ).channel diff --git a/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.test.ts index c8f9cce12f..b8731769a9 100644 --- a/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.test.ts @@ -10,7 +10,7 @@ import { communitiesActions } from '../../communities/communities.slice' import { DateTime } from 'luxon' import { messagesActions } from '../../messages/messages.slice' import { channelDeletionResponseSaga } from './channelDeletionResponse.saga' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { CommunityOwnership, type Community, type Identity, type PublicChannel } from '@quiet/types' import { publicChannelsSelectors } from '../publicChannels.selectors' import { select } from 'redux-saga-test-plan/matchers' @@ -52,7 +52,7 @@ describe('channelDeletionResponseSaga', () => { description: 'Welcome to #photo', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('photo'), + id: generateTestChannelId('photo'), }, }) ).channel! @@ -64,7 +64,7 @@ describe('channelDeletionResponseSaga', () => { description: 'Welcome to #private', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('private'), + id: generateTestChannelId('private'), public: false, }, }) @@ -89,7 +89,7 @@ describe('channelDeletionResponseSaga', () => { .put(messagesActions.deleteChannelEntry({ channelId })) .put(publicChannelsActions.deleteChannelFromStore({ channelId })) .put(publicChannelsActions.completeChannelDeletion({})) - .put(messagesActions.sendDeletionMessage({ channelId, isPublic: true })) + .put(messagesActions.sendDeletionMessage({ channelId, channelName: photoChannel.name, isPublic: true })) .run() }) diff --git a/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.ts b/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.ts index 8722c2ecde..b6124ef758 100644 --- a/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/channelDeletionResponse/channelDeletionResponse.saga.ts @@ -66,7 +66,13 @@ export function* channelDeletionResponseSaga( if (deletedGeneral) { yield* put(publicChannelsActions.createGeneralChannel()) } else if (isDeletedChannelPublic) { - yield* put(messagesActions.sendDeletionMessage({ channelId, isPublic: isDeletedChannelPublic })) + yield* put( + messagesActions.sendDeletionMessage({ + channelId, + channelName: deletedChannel.name, + isPublic: isDeletedChannelPublic, + }) + ) } } else { const isUserOnGeneral = currentChannelId === generalChannel.id diff --git a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts index 14730db186..b2e19aae90 100644 --- a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts @@ -14,7 +14,7 @@ import { DateTime } from 'luxon' import { publicChannelsSelectors } from '../publicChannels.selectors' import { messagesActions } from '../../messages/messages.slice' import { ChannelOperationStatus, type Community, type Identity, type PublicChannel } from '@quiet/types' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { createLogger } from '../../../utils/logger' import { getBaseTypesFactory, getReduxStoreFactory } from '../../../utils/tests/factories' @@ -60,7 +60,7 @@ describe('channelsReplicatedSaga', () => { description: 'Welcome to #sailing', timestamp: DateTime.utc().valueOf(), owner: 'owner', - id: generateChannelId('sailing'), + id: generateTestChannelId('sailing'), }, }) ).payload.channel @@ -73,7 +73,7 @@ describe('channelsReplicatedSaga', () => { description: 'Welcome to #photo', timestamp: DateTime.utc().valueOf(), owner: 'owner', - id: generateChannelId('photo'), + id: generateTestChannelId('photo'), }, }) ).payload.channel diff --git a/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.test.ts index 65d22ce022..c13552dc0b 100644 --- a/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/createChannel/createChannel.saga.test.ts @@ -34,7 +34,15 @@ describe('createChannelSaga', () => { const createChannelPayload = await socketPayloadFactory.build(SocketActions.CREATE_CHANNEL) const createChannelResponse: CreateChannelResponse = await socket.buildResponse(SocketActions.CREATE_CHANNEL, { - ...createChannelPayload, + channel: { + id: 'created-channel-id', + name: createChannelPayload.name, + description: createChannelPayload.description ?? '', + owner: 'test-owner', + timestamp: Date.now(), + public: createChannelPayload.public, + teamId: createChannelPayload.teamId, + }, }) socket.registerExpectedResponse(SocketActions.CREATE_CHANNEL, createChannelResponse) await expectSaga( @@ -45,12 +53,12 @@ describe('createChannelSaga', () => { .withReducer(combineReducers(testReducers)) .withState(store.getState()) .apply(socket, socket.emitWithAck, [SocketActions.CREATE_CHANNEL, createChannelPayload]) - .put(messagesActions.addPublicChannelsMessagesBase({ channelId: createChannelPayload.id })) + .put(messagesActions.addPublicChannelsMessagesBase({ channelId: createChannelResponse.channel!.id })) .put(publicChannelsActions.addChannel(createChannelResponse)) .put( publicChannelsActions.sendInitialChannelMessage({ - channelName: createChannelPayload.name, - channelId: createChannelPayload.id, + channelName: createChannelResponse.channel!.name, + channelId: createChannelResponse.channel!.id, }) ) .run() @@ -63,7 +71,15 @@ describe('createChannelSaga', () => { public: false, }) const createChannelResponse: CreateChannelResponse = await socket.buildResponse(SocketActions.CREATE_CHANNEL, { - ...createChannelPayload, + channel: { + id: 'created-private-channel-id', + name: createChannelPayload.name, + description: createChannelPayload.description ?? '', + owner: 'test-owner', + timestamp: Date.now(), + public: createChannelPayload.public, + teamId: createChannelPayload.teamId, + }, }) socket.registerExpectedResponse(SocketActions.CREATE_CHANNEL, createChannelResponse) await expectSaga( @@ -74,12 +90,12 @@ describe('createChannelSaga', () => { .withReducer(combineReducers(testReducers)) .withState(store.getState()) .apply(socket, socket.emitWithAck, [SocketActions.CREATE_CHANNEL, createChannelPayload]) - .put(messagesActions.addPublicChannelsMessagesBase({ channelId: createChannelPayload.id })) + .put(messagesActions.addPublicChannelsMessagesBase({ channelId: createChannelResponse.channel!.id })) .put(publicChannelsActions.addChannel(createChannelResponse)) .put( publicChannelsActions.sendInitialChannelMessage({ - channelName: createChannelPayload.name, - channelId: createChannelPayload.id, + channelName: createChannelResponse.channel!.name, + channelId: createChannelResponse.channel!.id, }) ) .run() diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts index a399daf783..7582fb37f1 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts @@ -4,10 +4,8 @@ import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' import { type FactoryGirl } from 'factory-girl' import { combineReducers } from 'redux' import { expectSaga } from 'redux-saga-test-plan' -import { call } from 'redux-saga-test-plan/matchers' import { publicChannelsActions } from './../publicChannels.slice' import { createGeneralChannelSaga } from './createGeneralChannel.saga' -import { generateChannelId } from '@quiet/common' import { type communitiesActions } from '../../communities/communities.slice' import { type Community, type Identity } from '@quiet/types' import { createLogger } from '../../../utils/logger' @@ -37,24 +35,16 @@ describe('createGeneralChannelSaga', () => { }) test('create general channel', async () => { - const generalId = generateChannelId('general') const channel: ReturnType['payload'] = { name: 'general', description: 'Welcome to #general', - id: generalId, teamId: community.teamId!, public: true, } await expectSaga(createGeneralChannelSaga) .withReducer(combineReducers(testReducers)) .withState(store.getState()) - .provide([[call.fn(generateChannelId), generalId]]) .put(publicChannelsActions.createChannel(channel)) - .put( - publicChannelsActions.setCurrentChannel({ - channelId: generalId, - }) - ) .run() }) }) diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts index 7661d8ecc7..9f9eddcc46 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.ts @@ -1,6 +1,5 @@ -import { put, call, select } from 'typed-redux-saga' +import { put, select } from 'typed-redux-saga' import { publicChannelsActions } from '../publicChannels.slice' -import { generateChannelId } from '@quiet/common' import { createLogger } from '../../../utils/logger' import { CreateChannelPayload } from '@quiet/types' import { communities } from '../../..' @@ -8,7 +7,6 @@ import { communities } from '../../..' const logger = createLogger('createGeneralChannelSaga') export function* createGeneralChannelSaga(): Generator { - const id = yield* call(generateChannelId, 'general') const community = yield* select(communities.selectors.currentCommunity) if (community == null || community.teamId == null) { @@ -18,17 +16,10 @@ export function* createGeneralChannelSaga(): Generator { yield* put( publicChannelsActions.createChannel({ - id: id, name: 'general', description: 'Welcome to #general', teamId: community.teamId, public: true, } as CreateChannelPayload) ) - - yield* put( - publicChannelsActions.setCurrentChannel({ - channelId: id, - }) - ) } diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts index 72b135350c..4035eda075 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts @@ -11,7 +11,7 @@ import { type communitiesActions } from '../../communities/communities.slice' import { DateTime } from 'luxon' import { publicChannelsSelectors } from '../publicChannels.selectors' import { combineReducers } from '@reduxjs/toolkit' -import { generalChannelDeletionMessage, generateChannelId } from '@quiet/common' +import { generalChannelDeletionMessage, generateTestChannelId } from '@quiet/common' import { type Community, type PublicChannel, type Identity, UserProfile } from '@quiet/types' import { userProfiles, userProfileSelectors } from '../../users/userProfile/userProfile.selectors' @@ -54,7 +54,7 @@ describe('sendInitialChannelMessageSaga', () => { description: 'Welcome to #photo', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('photo'), + id: generateTestChannelId('photo'), }, }) ).channel! diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts index 7dea6e7a17..6219c2831c 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts @@ -31,6 +31,7 @@ export function* sendInitialChannelMessageSaga( } if (isGeneral) { + yield* put(publicChannelsActions.setCurrentChannel({ channelId })) yield* put(publicChannelsActions.finishGeneralRecreation()) } diff --git a/packages/state-manager/src/sagas/publicChannels/deleteChannel/deleteChannel.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/deleteChannel/deleteChannel.saga.test.ts index f735a9ed17..303facd23a 100644 --- a/packages/state-manager/src/sagas/publicChannels/deleteChannel/deleteChannel.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/deleteChannel/deleteChannel.saga.test.ts @@ -9,7 +9,7 @@ import { publicChannelsActions } from '../publicChannels.slice' import { DateTime } from 'luxon' import { deleteChannelSaga } from './deleteChannel.saga' import { type Socket } from '../../../types' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { filesActions } from '../../files/files.slice' import { type Community, @@ -62,7 +62,7 @@ describe('deleteChannelSaga', () => { description: 'Welcome to #photo', timestamp: DateTime.utc().valueOf(), owner: owner.userId, - id: generateChannelId('photo'), + id: generateTestChannelId('photo'), }, }) ).channel diff --git a/packages/state-manager/src/sagas/publicChannels/markUnreadChannels/markUnreadChannels.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/markUnreadChannels/markUnreadChannels.saga.test.ts index 5ac4465b2b..5ae5c334a3 100644 --- a/packages/state-manager/src/sagas/publicChannels/markUnreadChannels/markUnreadChannels.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/markUnreadChannels/markUnreadChannels.saga.test.ts @@ -11,7 +11,7 @@ import { identityActions } from '../../identity/identity.slice' import { DateTime } from 'luxon' import { markUnreadChannelsSaga } from './markUnreadChannels.saga' import { messagesActions } from '../../messages/messages.slice' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { type ChannelMessage, type Community, type Identity, MessageType } from '@quiet/types' describe('markUnreadChannelsSaga', () => { @@ -47,7 +47,7 @@ describe('markUnreadChannelsSaga', () => { description: `Welcome to #${name}`, timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId(name), + id: generateTestChannelId(name), }, }) channelIds = [...channelIds, channel.channel.id] diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts index 818c3cd8bc..499c416179 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts @@ -13,7 +13,7 @@ import { publicChannelsActions } from './publicChannels.slice' import { formatMessageDisplayDate } from '../../utils/functions/dates/formatMessageDisplayDate' import { displayableMessage } from '../../utils/functions/dates/formatDisplayableMessage' import { DateTime } from 'luxon' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { type ChannelMessage, type Community, @@ -83,7 +83,7 @@ describe('publicChannelsSelectors', () => { description: `Welcome to #${name}`, timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId(name), + id: generateTestChannelId(name), }, }) channelIdes = [...channelIdes, channel.channel.id] @@ -322,7 +322,7 @@ describe('publicChannelsSelectors', () => { }) it('unreadChannels selector returns only unread channels', async () => { - const channelId = channelIdes.find(channelId => channelId.includes('allergies')) + const channelId = getPublicChannels(store.getState()).find(channel => channel.name === 'allergies')?.id if (!channelId) throw new Error('no channel id') store.dispatch( publicChannelsActions.markUnreadChannel({ diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts index a4c4d8c246..40141413be 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts @@ -19,6 +19,7 @@ import { type MessagesGroupsType, type PublicChannel, type PublicChannelStatus, + type PublicChannelStatusWithName, INITIAL_CURRENT_CHANNEL_ID, type UserProfile, } from '@quiet/types' @@ -262,11 +263,23 @@ export const channelsStatus = createSelector(selectState, state => { return publicChannelsStatusAdapter.getSelectors().selectEntities(state.channelsStatus) }) -export const channelsStatusSorted = createSelector(selectState, state => { +export const channelsStatusSorted = createSelector(selectState, selectChannels, (state, channels) => { if (!state?.channelsStatus) return [] + const channelNamesById = new Map(channels.map(channel => [channel.id, channel.name])) const statuses = publicChannelsStatusAdapter.getSelectors().selectAll(state.channelsStatus) return statuses + .map((status): PublicChannelStatusWithName | undefined => { + const name = channelNamesById.get(status.id) + if (name == null) { + return undefined + } + return { + ...status, + name, + } + }) + .filter(isDefined) .sort((a, b) => { const aCreatedAt = a.newestMessage?.createdAt const bCreatedAt = b.newestMessage?.createdAt diff --git a/packages/state-manager/src/sagas/publicChannels/updateNewestMessage/updateNewestMessage.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/updateNewestMessage/updateNewestMessage.saga.test.ts index c11707c43b..afcb3d6e3a 100644 --- a/packages/state-manager/src/sagas/publicChannels/updateNewestMessage/updateNewestMessage.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/updateNewestMessage/updateNewestMessage.saga.test.ts @@ -11,7 +11,7 @@ import { type identityActions } from '../../identity/identity.slice' import { DateTime } from 'luxon' import { updateNewestMessageSaga } from './updateNewestMessage.saga' import { messagesActions } from '../../messages/messages.slice' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { publicChannelsSelectors } from '../publicChannels.selectors' import { type ChannelMessage, type Community, type Identity, MessageType, type PublicChannel } from '@quiet/types' @@ -55,7 +55,7 @@ describe('markUnreadChannelsSaga', () => { description: `Welcome to #${name}`, timestamp: DateTime.utc().valueOf(), owner: alice.userId, - id: generateChannelId(name), + id: generateTestChannelId(name), }, } ) diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts index 3169d2c192..ee570c1ae0 100644 --- a/packages/state-manager/src/utils/tests/factories.ts +++ b/packages/state-manager/src/utils/tests/factories.ts @@ -4,7 +4,7 @@ import { CustomReduxAdapter } from './reduxAdapter' import { Store } from '../../sagas/store.types' import { createPeerIdTestHelper } from './helpers' import { DateTime } from 'luxon' -import { generateChannelId } from '@quiet/common' +import { generateTestChannelId } from '@quiet/common' import { ChannelMessage, CommunityOwnership, @@ -111,7 +111,7 @@ export const getBaseTypesFactory = async () => { }) factory.define('PublicChannel', Object, { - id: factory.sequence('PublicChannel.id', (n: number) => generateChannelId(`publicChannel${n}`)), + id: factory.sequence('PublicChannel.id', (n: number) => generateTestChannelId(`publicChannel${n}`)), name: factory.sequence('PublicChannel.name', (n: number) => `public-channel-${n}`), description: factory.sequence('PublicChannel.description', (n: number) => `description-${n}`), public: true, @@ -228,7 +228,7 @@ export const getReduxStoreFactory = async (store: Store) => { description: 'Welcome to channel #general', timestamp: DateTime.utc().toSeconds(), owner: 'alice', - id: generateChannelId('general'), + id: generateTestChannelId('general'), public: true, teamId: payload.teamId, }, @@ -309,7 +309,7 @@ export const getReduxStoreFactory = async (store: Store) => { description: 'Description', timestamp: DateTime.utc().toSeconds(), owner: 'alice', // simpler than nested assoc; tests only need non‑undefined - id: generateChannelId(name), + id: generateTestChannelId(name), public: true, teamId: factory.assoc('Community', 'teamId'), } @@ -347,7 +347,7 @@ export const getReduxStoreFactory = async (store: Store) => { type: MessageType.Basic, message: factory.sequence('Message.message', (n: number) => `message_${n}`), createdAt: DateTime.utc().valueOf(), - channelId: generateChannelId('general'), + channelId: generateTestChannelId('general'), userId: factory.assoc('UserProfile', 'userId'), }, verifyAutomatically: true, @@ -563,7 +563,6 @@ export const getSocketFactory = async () => { }) factory.define(SocketActions.CREATE_CHANNEL, Object, { - id: 'new-channel-id', name: 'Test Channel', description: 'A channel used for tests', teamId: 'foobar', @@ -579,6 +578,7 @@ export const getSocketFactory = async () => { public: true, teamId: 'foobar', }, + status: ChannelOperationStatus.SUCCESS, }) factory.define(SocketActions.ADD_MEMBERS_TO_CHANNEL, Object, { diff --git a/packages/types/src/channel.ts b/packages/types/src/channel.ts index f7ceea0987..fb089cbb3d 100644 --- a/packages/types/src/channel.ts +++ b/packages/types/src/channel.ts @@ -91,7 +91,6 @@ export interface ChannelsReplicatedPayload { } export interface CreateChannelPayload { - id: string name: string public: boolean teamId: string diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 11799e87cf..99cbbe950b 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -91,6 +91,7 @@ export interface DeleteChannelEntryPayload { export interface SendDeletionMessagePayload { channelId: string + channelName?: string isPublic: boolean } From 8303aadbc2697b4fb7cf4110e49f27902ec02290 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 30 Jun 2026 16:30:37 -0400 Subject: [PATCH 11/21] improve store management and error handling in channel and notification services --- .../nest/storage/channels/channel.store.ts | 22 +++- .../storage/channels/channels.service.spec.ts | 18 +++ .../nest/storage/channels/channels.service.ts | 115 +++++++++++++----- .../notifications/notificationTokens.store.ts | 16 ++- .../src/nest/storage/storage.service.spec.ts | 15 +++ .../storage/userProfile/userProfile.store.ts | 16 ++- packages/e2e-tests/src/selectors.ts | 58 +++++---- packages/identity/src/test/helpers.ts | 2 +- .../verifyMessage/verifyMessages.saga.test.ts | 3 +- .../markUnreadChannels.saga.test.ts | 22 ++-- 10 files changed, 211 insertions(+), 76 deletions(-) diff --git a/packages/backend/src/nest/storage/channels/channel.store.ts b/packages/backend/src/nest/storage/channels/channel.store.ts index 8e5b91a592..a8feb89759 100644 --- a/packages/backend/src/nest/storage/channels/channel.store.ts +++ b/packages/backend/src/nest/storage/channels/channel.store.ts @@ -374,8 +374,20 @@ export class ChannelStore extends EventStoreBase { this.logger.info(`Closing channel store`) + const store = this.store + if (store == null) { + this.logger.warn(`Store is already undefined, nothing to close`) + return + } + await this.stopSync() - await this.getStore().close() + await store.close() + if (this.authListenerAttached) { + this.auth.removeListener(SigchainEvents.UPDATED, this.handleAuthUpdated) + this.authListenerAttached = false + } + this.store = undefined + this._subscribing = false } /** @@ -393,6 +405,7 @@ export class ChannelStore extends EventStoreBase { this.logger.info(`Cleaning channel store`, this.channelData.id, this.channelData.name) + const store = this.store try { await this.stopSync() } catch (e) { @@ -402,11 +415,16 @@ export class ChannelStore extends EventStoreBase { }) }) + it('logs channel metadata update handler errors without rejecting the event', async () => { + const events = new EventEmitter() + const error = new Error('broadcast failed') + const broadcastCurrentChannelsSpy = jest + .spyOn(channelsService, 'broadcastCurrentChannels') + .mockRejectedValue(error) + const loggerErrorSpy = jest.spyOn((channelsService as any).logger, 'error').mockImplementation(() => {}) + + ;(channelsService as any).attachChannelMetadataUpdateHandler({ events }) + events.emit('update', channelPutEntry('test-channel-id', {} as EncryptedAndSignedPayload)) + + await waitForExpect(() => { + expect(broadcastCurrentChannelsSpy).toHaveBeenCalled() + expect(loggerErrorSpy).toHaveBeenCalledWith('Error handling channels database update', error) + }) + }) + it('deletes channel as owner', async () => { logger.info('Deleting channel as owner') await channelsService.subscribeToChannel(channel) diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 6543899b5b..c0f23f21b3 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -16,7 +16,6 @@ import { CreateChannelPayload, ChannelSubscribedPayload, DeleteChannelPayload, - ConsumedChannelMessage, AddMembersChannelPayload, AddMembersChannelResponse, AddMembersChannelStatus, @@ -196,16 +195,22 @@ export class ChannelsService extends EventEmitter { private attachChannelMetadataUpdateHandler( store: KeyValueIndexedValidatedType | undefined ): void { - store?.events.on('update', (entry: LogEntry) => { - const channelId = entry.payload?.value?.channelId - const operation = entry.payload.op - this.logger.info('channels database updated', channelId, operation) - - this.emit(SocketEvents.CONNECTION_PROCESS_INFO, ConnectionProcessInfo.CHANNELS_STORED) - this.broadcastCurrentChannels() + store?.events.on('update', (entry: LogEntry) => { + void this.handleChannelMetadataUpdate(entry).catch(e => { + this.logger.error('Error handling channels database update', e) + }) }) } + private async handleChannelMetadataUpdate(entry: LogEntry): Promise { + const channelId = entry.payload?.key + const operation = entry.payload.op + this.logger.info('channels database updated', channelId, operation) + + this.emit(SocketEvents.CONNECTION_PROCESS_INFO, ConnectionProcessInfo.CHANNELS_STORED) + await this.broadcastCurrentChannels() + } + private async openChannelsDb(): Promise> { return await this.orbitDbService.open>( CHANNEL_METADATA_STORE_NAME, @@ -1261,17 +1266,20 @@ export class ChannelsService extends EventEmitter { * Close the channels management database on OrbitDB and each channel's DB */ public async closeChannels(): Promise { - try { - this.logger.info('Closing channels DB') - await this.channels?.close() - await this.privateChannels?.close() - this.logger.info('Closed channels DB') - } catch (e) { - this.logger.error('Error closing channels db', e) - } + const channels = this.channels + const privateChannels = this.privateChannels + const channelsRepos = this.channelsRepos + this.channels = undefined + this.privateChannels = undefined + this.channelsRepos = new Map() + + this.logger.info('Closing channels DB') + await this.closeMetadataStore('public', channels) + await this.closeMetadataStore('private', privateChannels) + this.logger.info('Closed channels DB') this.logger.info(`Closing each channel's DB`) - for (const [channelId, channel] of this.channelsRepos.entries()) { + for (const [channelId, channel] of channelsRepos.entries()) { try { this.logger.info(`Closing ${channelId} DB`) await channel.store.close() @@ -1312,29 +1320,72 @@ export class ChannelsService extends EventEmitter { public async clean(): Promise { this.initialized = false this.detachFileManagerEvents() + const channels = this.channels + const privateChannels = this.privateChannels + const channelsRepos = this.channelsRepos + this.channels = undefined + this.privateChannels = undefined + this.channelsRepos = new Map() + this.logger.info('Cleaning channels DB') + await this.cleanMetadataStore('public', channels) + await this.cleanMetadataStore('private', privateChannels) + + for (const [channelId, channel] of channelsRepos.entries()) { + try { + this.logger.info(`Cleaning ${channelId} DB`) + await channel.store.clean() + } catch (e) { + this.logger.error(`Error cleaning ${channelId} DB`, e) + } + } + } + + private async closeMetadataStore( + label: string, + store: KeyValueIndexedValidatedType | undefined + ): Promise { + if (store == null) { + return + } + try { - await this.channels?.sync?.stop?.() - await this.privateChannels?.sync?.stop?.() + await store.sync?.stop?.() } catch (e) { // If the sync is not started, this will throw an error } + try { - await this.channels?.drop?.() - await this.privateChannels?.drop?.() + await store.close() } catch (e) { - this.logger.error('Error dropping channels DB', e) + this.logger.error(`Error closing ${label} channels DB`, e) } - for (const [channelId, channel] of this.channelsRepos.entries()) { - try { - this.logger.info(`Cleaning ${channelId} DB`) - await channel.store.clean() - } catch (e) { - this.logger.error(`Error cleaning ${channelId} DB`, e) - } + } + + private async cleanMetadataStore( + label: string, + store: KeyValueIndexedValidatedType | undefined + ): Promise { + if (store == null) { + return + } + + try { + await store.sync?.stop?.() + } catch (e) { + // If the sync is not started, this will throw an error + } + + try { + await store.drop?.() + } catch (e) { + this.logger.error(`Error dropping ${label} channels DB`, e) + } + + try { + await store.close() + } catch (e) { + this.logger.error(`Error closing ${label} channels DB after drop`, e) } - this.channels = undefined - this.privateChannels = undefined - this.channelsRepos = new Map() } } diff --git a/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts b/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts index dc8cd26604..49f55380bd 100644 --- a/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts +++ b/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts @@ -260,11 +260,21 @@ export class NotificationTokensStore extends EncryptedKeyValueIndexedValidatedSt public async clean(): Promise { logger.info('Cleaning notification tokens store') this.deferredEntries = [] + const store = this.store try { - await this.store?.sync?.stop?.() - await this.store?.drop?.() + await store?.sync?.stop?.() } catch (err) { - logger.error('Failed to clean notification tokens store:', err) + // If the sync is not started, this will throw an error + } + try { + await store?.drop?.() + } catch (err) { + logger.error('Failed to drop notification tokens store:', err) + } + try { + await store?.close?.() + } catch (err) { + logger.error('Failed to close notification tokens store after drop:', err) } this.store = undefined } diff --git a/packages/backend/src/nest/storage/storage.service.spec.ts b/packages/backend/src/nest/storage/storage.service.spec.ts index 99fdbdfbfa..502cc20871 100644 --- a/packages/backend/src/nest/storage/storage.service.spec.ts +++ b/packages/backend/src/nest/storage/storage.service.spec.ts @@ -22,6 +22,7 @@ import { LocalDbService } from '../local-db/local-db.service' import { ORBIT_DB_DIR } from '../const' import { createLogger } from '../common/logger' import { UserProfileStore } from './userProfile/userProfile.store' +import { NotificationTokensStore } from './notifications/notificationTokens.store' import { SigChainService } from '../auth/sigchain.service' import { SigChainModule } from '../auth/sigchain.service.module' import waitForExpect from 'wait-for-expect' @@ -38,6 +39,7 @@ describe('StorageService', () => { let libp2pService: Libp2pService let localDbService: LocalDbService let userProfileStore: UserProfileStore + let notificationTokensStore: NotificationTokensStore let sigchainService: SigChainService let store: Store @@ -90,6 +92,7 @@ describe('StorageService', () => { libp2pService = await module.resolve(Libp2pService) ipfsService = await module.resolve(IpfsService) userProfileStore = await module.resolve(UserProfileStore) + notificationTokensStore = await module.resolve(NotificationTokensStore) sigchainService = await module.resolve(SigChainService) await sigchainService.createChain('team', 'alice', true) @@ -135,6 +138,18 @@ describe('StorageService', () => { await storageService.init() }) + it('should clean after stop and reinitialize metadata stores', async () => { + await storageService.init() + await storageService.stop() + await storageService.clean() + await storageService.init() + + expect(storageService.channelsService.channels).toBeDefined() + expect(storageService.channelsService.privateChannels).toBeDefined() + expect(userProfileStore.getStore()).toBeDefined() + expect(notificationTokensStore.getStore()).toBeDefined() + }) + describe('Storage', () => { it('should not create paths if createPaths is set to false', async () => { const orgProcessPlatform = process.platform diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts index dfebf7bada..012bccf118 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts @@ -332,11 +332,21 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase public async clean(): Promise { logger.info('Cleaning user profiles store') this.deferredProfiles = [] + const store = this.store try { - await this.store?.sync?.stop?.() - await this.store?.drop?.() + await store?.sync?.stop?.() } catch (err) { - logger.error('Failed to clean user profiles store:', err) + // If the sync is not started, this will throw an error + } + try { + await store?.drop?.() + } catch (err) { + logger.error('Failed to drop user profiles store:', err) + } + try { + await store?.close?.() + } catch (err) { + logger.error('Failed to close user profiles store after drop:', err) } this.store = undefined } diff --git a/packages/e2e-tests/src/selectors.ts b/packages/e2e-tests/src/selectors.ts index add88c2d99..bc5a6ceb1f 100644 --- a/packages/e2e-tests/src/selectors.ts +++ b/packages/e2e-tests/src/selectors.ts @@ -499,36 +499,50 @@ export class JoiningLoadingPanel { } async waitForJoinToComplete(visibleTimeoutMs = 60_000, completionTimeoutMs = 360_000): Promise { - // First check if the panel exists at all. In some flows (e.g., Not Now on server offer), - // the joining panel may never appear, which is OK. - const candidates = await this.driver.findElements(By.xpath('//div[@data-testid="joiningPanelComponent"]')) - if (!candidates || candidates.length === 0) { - logger.warn('Joining loading panel not present; skipping wait') - return + const panelLocator = By.xpath('//div[@data-testid="joiningPanelComponent"]') + const visiblePanelTimeoutMs = Math.min(visibleTimeoutMs, 10_000) + try { + await this.driver.wait( + async () => this.hasVisiblePanel(panelLocator), + visiblePanelTimeoutMs, + `Loading panel element couldn't be seen within timeout`, + 500 + ) + } catch (e) { + if (this.isLoadingPanelTimeout(e)) { + logger.warn('Joining loading panel not present; skipping wait') + return + } + throw e } - const panel = candidates[0] await this.driver.wait( - until.elementIsVisible(panel), - visibleTimeoutMs, - `Loading panel element couldn't be seen within timeout`, - 500 + async () => !(await this.hasVisiblePanel(panelLocator)), + completionTimeoutMs, + `Loading panel element didn't disappear within timeout`, + 5_000 ) + } - try { - await this.driver.wait( - until.elementIsNotVisible(panel), - completionTimeoutMs, - `Loading panel element didn't disappear within timeout`, - 5_000 - ) - } catch (e) { - if (e.message.includes('stale element reference')) { - logger.warn(`Join loading panel disappeared and we couldn't get visibility information. This is fine.`) - } else { + private async hasVisiblePanel(panelLocator: By): Promise { + const panels = await this.driver.findElements(panelLocator) + for (const panel of panels) { + try { + if (await panel.isDisplayed()) return true + } catch (e) { + if (this.isStaleElementReference(e)) continue throw e } } + return false + } + + private isLoadingPanelTimeout(e: unknown): boolean { + return e instanceof Error && e.message.includes(`Loading panel element couldn't be seen within timeout`) + } + + private isStaleElementReference(e: unknown): boolean { + return e instanceof Error && e.message.includes('stale element reference') } } diff --git a/packages/identity/src/test/helpers.ts b/packages/identity/src/test/helpers.ts index 3ae8b55e4c..1e032c0d30 100644 --- a/packages/identity/src/test/helpers.ts +++ b/packages/identity/src/test/helpers.ts @@ -35,7 +35,7 @@ export async function createTestUserCert(rootCert?: RootCA, userCsr?: UserCsr): export function setupCrypto() { // prettier-ignore - const crypto = require('crypto').webcrypto; + const crypto = require('crypto').webcrypto setEngine( 'newEngine', crypto, diff --git a/packages/state-manager/src/sagas/messages/verifyMessage/verifyMessages.saga.test.ts b/packages/state-manager/src/sagas/messages/verifyMessage/verifyMessages.saga.test.ts index 9e827b6546..56b81eb585 100644 --- a/packages/state-manager/src/sagas/messages/verifyMessage/verifyMessages.saga.test.ts +++ b/packages/state-manager/src/sagas/messages/verifyMessage/verifyMessages.saga.test.ts @@ -49,6 +49,7 @@ describe('verifyMessage saga test', () => { logger.info('create owner identity') owner = await factory.create('Identity', { communityId: community.id, + userId: 'alice', }) ownerProfile = await factory.create('UserProfile', { @@ -146,7 +147,7 @@ describe('verifyMessage saga test', () => { userId: owner.userId, channelId: generalChannel.id, type: MessageType.Info, - message: createdChannelMessage(generalChannel.name), + message: verifyUserInfoMessage(ownerProfile.nickname, ownerProfile.userId, generalChannel), }), ], isVerified: true, diff --git a/packages/state-manager/src/sagas/publicChannels/markUnreadChannels/markUnreadChannels.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/markUnreadChannels/markUnreadChannels.saga.test.ts index 5ae5c334a3..7e377b4705 100644 --- a/packages/state-manager/src/sagas/publicChannels/markUnreadChannels/markUnreadChannels.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/markUnreadChannels/markUnreadChannels.saga.test.ts @@ -22,6 +22,7 @@ describe('markUnreadChannelsSaga', () => { let alice: Identity let channelIds: string[] = [] + const channelIdsByName: Partial> = {} beforeAll(async () => { setupCrypto() @@ -51,6 +52,7 @@ describe('markUnreadChannelsSaga', () => { }, }) channelIds = [...channelIds, channel.channel.id] + channelIdsByName[name] = channel.channel.id } }) @@ -78,7 +80,7 @@ describe('markUnreadChannelsSaga', () => { } // Set the newest message - const channelId = channelIds.find(id => id.includes('enya')) + const channelId = channelIdsByName.enya if (!channelId) throw new Error('no channel id') const message = ( await factory.create('TestMessage', { @@ -98,11 +100,9 @@ describe('markUnreadChannelsSaga', () => { store.dispatch(publicChannelsActions.updateNewestMessage({ message })) - const channelIdMemes = channelIds.find(id => id.includes('memes')) - - const channelIdEnya = channelIds.find(id => id.includes('enya')) - - const channelIdTravels = channelIds.find(id => id.includes('travels')) + const channelIdMemes = channelIdsByName.memes + const channelIdEnya = channelIdsByName.enya + const channelIdTravels = channelIdsByName.travels if (!channelIdMemes || !channelIdEnya || !channelIdTravels) throw new Error('no channel id') const reducer = combineReducers(testReducers) @@ -167,7 +167,7 @@ describe('markUnreadChannelsSaga', () => { messages.push(message) } - const channelId = channelIds.find(id => id.includes('enya')) + const channelId = channelIdsByName.enya if (!channelId) throw new Error('no channel id') // Set the newest message const message = ( @@ -186,11 +186,9 @@ describe('markUnreadChannelsSaga', () => { messages.push(message) - const channelIdMemes = channelIds.find(id => id.includes('memes')) - - const channelIdEnya = channelIds.find(id => id.includes('enya')) - - const channelIdTravels = channelIds.find(id => id.includes('travels')) + const channelIdMemes = channelIdsByName.memes + const channelIdEnya = channelIdsByName.enya + const channelIdTravels = channelIdsByName.travels if (!channelIdMemes || !channelIdEnya || !channelIdTravels) throw new Error('no channel id') const reducer = combineReducers(testReducers) await expectSaga( From 7cfcdae25fcc9e17118b73df3b636bae0c45b1f1 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 30 Jun 2026 17:45:33 -0400 Subject: [PATCH 12/21] fix linting --- .../notifications/notificationTokens.store.ts | 20 +++++++++---------- .../storage/userProfile/userProfile.store.ts | 20 +++++++++---------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts b/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts index 49f55380bd..2c1fe78c3d 100644 --- a/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts +++ b/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts @@ -219,17 +219,15 @@ export class NotificationTokensStore extends EncryptedKeyValueIndexedValidatedSt const valueUserId = encPayload.userId const decUserId = decEntry.userId const sigAuthor = encPayload.signature.author.name - if ( - !( - key && - valueUserId && - decUserId && - sigAuthor && - key === valueUserId && - key === decUserId && - key === sigAuthor - ) - ) { + const idsMatch = + key != null && + valueUserId != null && + decUserId != null && + sigAuthor != null && + key === valueUserId && + key === decUserId && + key === sigAuthor + if (!idsMatch) { logger.error( `Failed to verify notification token entry: ${entry.hash} - ID mismatch. key=${key}, valueUserId=${valueUserId}, decUserId=${decUserId}, sigAuthor=${sigAuthor}` ) diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts index 012bccf118..fc2949c14b 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts @@ -253,17 +253,15 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase const valueUserId = encPayload.userId const decUserId = decEntry.userId const sigAuthor = encPayload.signature.author.name - if ( - !( - key && - valueUserId && - decUserId && - sigAuthor && - key === valueUserId && - key === decUserId && - key === sigAuthor - ) - ) { + const idsMatch = + key != null && + valueUserId != null && + decUserId != null && + sigAuthor != null && + key === valueUserId && + key === decUserId && + key === sigAuthor + if (!idsMatch) { logger.error( `Failed to verify user profile entry: ${entry.hash} - key, value.userId, decEntry.userId, and signature.author.name must all match. Got key=${key}, valueUserId=${valueUserId}, decUserId=${decUserId}, sigAuthor=${sigAuthor}` ) From 0dfa522e4ef2d60d7e341a4d5e4557c6c9b349b8 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 1 Jul 2026 11:40:37 -0400 Subject: [PATCH 13/21] feat(channels): enhance channel subscription handling and metadata updates - Introduced channel metadata update handlers to manage updates more effectively. - Added methods to attach and detach channel metadata update handlers. - Improved channel subscription logic to ensure channels are subscribed before sending messages. - Refactored channel subscription checks and added new selectors for subscribed channels. - Implemented a saga to wait for channel subscriptions before proceeding with message sending and initial channel message creation. - Updated tests to cover new subscription logic and ensure proper handling of channel states. --- packages/backend/src/nest/common/types.ts | 2 + .../storage/channels/channels.service.spec.ts | 234 +++++++++++++++++- .../nest/storage/channels/channels.service.ts | 196 +++++++++++---- .../storage/orbitDb/orbitDb.service.spec.ts | 49 +++- .../nest/storage/orbitDb/orbitDb.service.ts | 44 +++- .../renderer/components/Channel/Channel.tsx | 2 + .../Channel/ChannelComponent.test.tsx | 80 ++++++ .../components/Channel/ChannelComponent.tsx | 4 +- .../checkForMessages.saga.test.ts | 27 ++ .../checkForMessages/checkForMessages.saga.ts | 7 +- .../sendMessage/sendMessage.saga.test.ts | 36 +++ .../messages/sendMessage/sendMessage.saga.ts | 14 +- .../channelsReplicated.saga.test.ts | 26 +- .../channelsReplicated.saga.ts | 5 +- .../sendInitialChannelMessage.saga.test.ts | 93 +++++++ .../sendInitialChannelMessage.saga.ts | 2 + .../publicChannels.selectors.test.ts | 38 +++ .../publicChannels.selectors.ts | 25 +- .../waitForChannelSubscription.saga.ts | 22 ++ 19 files changed, 830 insertions(+), 76 deletions(-) create mode 100644 packages/desktop/src/renderer/components/Channel/ChannelComponent.test.tsx create mode 100644 packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.ts diff --git a/packages/backend/src/nest/common/types.ts b/packages/backend/src/nest/common/types.ts index c7a1762a89..d73df8b913 100644 --- a/packages/backend/src/nest/common/types.ts +++ b/packages/backend/src/nest/common/types.ts @@ -4,6 +4,8 @@ import { ChannelStore } from '../storage/channels/channel.store' export interface ChannelRepo { store: ChannelStore eventsAttached: boolean + subscribed: boolean + subscriptionPromise?: Promise public: boolean } diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index a196f00663..77b42ea8ac 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -45,6 +45,7 @@ import { OrbitDbService } from '../orbitDb/orbitDb.service' import { SigchainEvents } from '../../auth/types' import crypto from 'crypto' import { EventEmitter } from 'events' +import { StorageEvents } from '../storage.types' const logger = createLogger('channelsService:test') @@ -156,9 +157,11 @@ describe('ChannelsService', () => { key: string, value: EncryptedAndSignedPayload, hash: string = 'test-channel-metadata-entry', - identity: string = 'test-channel-put-identity' + identity: string = 'test-channel-put-identity', + storeId: string = 'test-channel-metadata-store' ): LogEntry => ({ + id: storeId, hash, identity, payload: { @@ -171,9 +174,11 @@ describe('ChannelsService', () => { const channelDelEntry = ( key: string, identity: string = 'test-channel-delete-identity', - hash: string = 'test-channel-metadata-delete-entry' + hash: string = 'test-channel-metadata-delete-entry', + storeId: string = 'test-channel-metadata-store' ): LogEntry => ({ + id: storeId, hash, identity, payload: { @@ -253,6 +258,127 @@ describe('ChannelsService', () => { await expect(channelsService.getChannel(response.channel!.id)).resolves.toEqual(response.channel) }) + it('subscribes to a created channel before returning success', async () => { + const payload: CreateChannelPayload = { + name: 'ready-channel-name', + description: 'ready channel description', + public: true, + teamId: community.teamId!, + } + const subscribedChannelIds: string[] = [] + channelsService.on(StorageEvents.CHANNEL_SUBSCRIBED, payload => { + subscribedChannelIds.push(payload.channelId) + }) + + const response = await channelsService.handleCreateChannel(payload) + + expect(response.status).toBe(ChannelOperationStatus.SUCCESS) + expect(response.channel).toBeDefined() + const repo = channelsService.channelsRepos.get(response.channel!.id) + expect(repo).toBeDefined() + expect(repo!.eventsAttached).toBe(true) + expect(repo!.subscribed).toBe(true) + expect(subscribedChannelIds).toContain(response.channel!.id) + }) + + it('shares one subscription promise for concurrent subscribe calls', async () => { + const channel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + let resolveSubscription: () => void = () => {} + const subscriptionGate = new Promise(resolve => { + resolveSubscription = resolve + }) + const store = new EventEmitter() as any + store.subscribe = jest.fn(async () => await subscriptionGate) + const subscribedChannelIds: string[] = [] + channelsService.channelsRepos.set(channel.id, { + store, + eventsAttached: false, + subscribed: false, + public: true, + }) + channelsService.on(StorageEvents.CHANNEL_SUBSCRIBED, payload => { + subscribedChannelIds.push(payload.channelId) + }) + + const firstSubscription = channelsService.subscribeToChannel(channel) + const secondSubscription = channelsService.subscribeToChannel(channel) + await Promise.resolve() + + expect(store.subscribe).toHaveBeenCalledTimes(1) + expect(channelsService.channelsRepos.get(channel.id)!.eventsAttached).toBe(true) + expect(channelsService.channelsRepos.get(channel.id)!.subscribed).toBe(false) + + resolveSubscription() + await Promise.all([firstSubscription, secondSubscription]) + + expect(channelsService.channelsRepos.get(channel.id)!.subscribed).toBe(true) + expect(subscribedChannelIds).toEqual([channel.id]) + }) + + it('shares one channel creation for concurrent subscribe calls before a repo exists', async () => { + const channel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + let resolveStoreCreation: (store: any) => void = () => {} + const storeCreationGate = new Promise(resolve => { + resolveStoreCreation = resolve + }) + const store = new EventEmitter() as any + store.subscribe = jest.fn(async () => {}) + const createChannelStoreSpy = jest + .spyOn(channelsService as any, 'createChannelStore') + .mockImplementation(async () => await storeCreationGate) + + try { + const firstSubscription = channelsService.subscribeToChannel(channel) + const secondSubscription = channelsService.subscribeToChannel(channel) + await Promise.resolve() + + expect(createChannelStoreSpy).toHaveBeenCalledTimes(1) + + resolveStoreCreation(store) + await Promise.all([firstSubscription, secondSubscription]) + + expect(channelsService.channelsRepos.get(channel.id)?.store).toBe(store) + expect(store.subscribe).toHaveBeenCalledTimes(1) + } finally { + createChannelStoreSpy.mockRestore() + } + }) + + it('broadcasts current channels before background subscriptions settle', async () => { + const channel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const store = new EventEmitter() as any + store.subscribe = jest.fn(() => new Promise(() => {})) + channelsService.channelsRepos.set(channel.id, { + store, + eventsAttached: false, + subscribed: false, + public: true, + }) + const getChannelsSpy = jest.spyOn(channelsService, 'getChannels').mockResolvedValue([channel]) + const channelsStoredPayloads: Array<{ channels: PublicChannel[] }> = [] + channelsService.on(StorageEvents.CHANNELS_STORED, payload => { + channelsStoredPayloads.push(payload) + }) + + try { + await channelsService.broadcastCurrentChannels() + + expect(channelsStoredPayloads).toEqual([{ channels: [channel] }]) + expect(store.subscribe).toHaveBeenCalledTimes(1) + } finally { + getChannelsSpy.mockRestore() + } + }) + it('retries channel id generation when a generated id already exists', async () => { const collidingId = '0'.repeat(32) const expectedId = '1'.repeat(32) @@ -301,21 +427,91 @@ describe('ChannelsService', () => { it('logs channel metadata update handler errors without rejecting the event', async () => { const events = new EventEmitter() + const storeAddress = 'test-channel-metadata-store' const error = new Error('broadcast failed') const broadcastCurrentChannelsSpy = jest .spyOn(channelsService, 'broadcastCurrentChannels') .mockRejectedValue(error) + const retryIndexingUnindexedEntries = jest.fn<() => Promise>().mockResolvedValue() const loggerErrorSpy = jest.spyOn((channelsService as any).logger, 'error').mockImplementation(() => {}) - ;(channelsService as any).attachChannelMetadataUpdateHandler({ events }) + ;(channelsService as any).attachChannelMetadataUpdateHandler({ + events, + address: storeAddress, + retryIndexingUnindexedEntries, + }) events.emit('update', channelPutEntry('test-channel-id', {} as EncryptedAndSignedPayload)) await waitForExpect(() => { + expect(retryIndexingUnindexedEntries).toHaveBeenCalled() expect(broadcastCurrentChannelsSpy).toHaveBeenCalled() expect(loggerErrorSpy).toHaveBeenCalledWith('Error handling channels database update', error) }) }) + it('ignores shared OrbitDB update events from non-channel metadata stores', async () => { + const events = new EventEmitter() + const retryIndexingUnindexedEntries = jest.fn<() => Promise>().mockResolvedValue() + const broadcastCurrentChannelsSpy = jest.spyOn(channelsService, 'broadcastCurrentChannels').mockResolvedValue() + + ;(channelsService as any).attachChannelMetadataUpdateHandler({ + events, + address: 'test-channel-metadata-store', + retryIndexingUnindexedEntries, + }) + events.emit( + 'update', + channelPutEntry( + 'test-user-profile-id', + {} as EncryptedAndSignedPayload, + 'test-user-profile-entry', + 'test-user-profile-identity', + 'test-user-profile-store' + ) + ) + + await new Promise(resolve => setImmediate(resolve)) + + expect(retryIndexingUnindexedEntries).not.toHaveBeenCalled() + expect(broadcastCurrentChannelsSpy).not.toHaveBeenCalled() + }) + + it('reindexes the channel metadata store before broadcasting channel updates', async () => { + const events = new EventEmitter() + const calls: string[] = [] + const retryIndexingUnindexedEntries = jest.fn<() => Promise>().mockImplementation(async () => { + calls.push('retry') + }) + jest.spyOn(channelsService, 'broadcastCurrentChannels').mockImplementation(async () => { + calls.push('broadcast') + }) + ;(channelsService as any).attachChannelMetadataUpdateHandler({ + events, + address: 'test-channel-metadata-store', + retryIndexingUnindexedEntries, + }) + events.emit('update', channelPutEntry('test-channel-id', {} as EncryptedAndSignedPayload)) + + await waitForExpect(() => { + expect(calls).toEqual(['retry', 'broadcast']) + }) + }) + + it('detaches channel metadata update handlers', async () => { + const events = new EventEmitter() + + ;(channelsService as any).attachChannelMetadataUpdateHandler({ + events, + address: 'test-channel-metadata-store', + retryIndexingUnindexedEntries: jest.fn<() => Promise>().mockResolvedValue(), + }) + + expect(events.listenerCount('update')).toBe(1) + ;(channelsService as any).detachChannelMetadataUpdateHandlers() + + expect(events.listenerCount('update')).toBe(0) + }) + it('deletes channel as owner', async () => { logger.info('Deleting channel as owner') await channelsService.subscribeToChannel(channel) @@ -487,6 +683,38 @@ describe('ChannelsService', () => { await expect(channelsService.getChannel(publicChannel.id)).resolves.toEqual(publicChannel) }) + it('subscribes before sending to an existing unsubscribed channel repo', async () => { + const channel = await factory.build('PublicChannel', { + owner: aliceUserId, + teamId: community.teamId!, + }) + const message = await factory.build('ChannelMessage', { + channelId: channel.id, + userId: aliceUserId, + }) + const store = new EventEmitter() as any + store.subscribe = jest.fn(async () => {}) + store.sendMessage = jest.fn(async () => true) + channelsService.channelsRepos.set(channel.id, { + store, + eventsAttached: false, + subscribed: false, + public: true, + }) + const getChannelSpy = jest.spyOn(channelsService, 'getChannel').mockResolvedValue(channel) + + try { + const sent = await channelsService.sendMessage(message) + + expect(sent).toBe(true) + expect(store.subscribe).toHaveBeenCalledTimes(1) + expect(store.sendMessage).toHaveBeenCalledWith(message) + expect(channelsService.channelsRepos.get(channel.id)!.subscribed).toBe(true) + } finally { + getChannelSpy.mockRestore() + } + }) + // skipping because we don't have a strong way to prevent a user from deleting a channel yet it.skip('delete channel as standard user', async () => { logger.info('Deleting channel as standard user') diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index c0f23f21b3..02c23f47b1 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -59,6 +59,11 @@ export class ChannelsService extends EventEmitter { public privateChannels: KeyValueIndexedValidatedType | undefined private fileManagerEventsAttached = false private sigchainListenerAttached = false + private channelCreationPromises: Map> = new Map() + private channelMetadataUpdateHandlers: Array<{ + store: KeyValueIndexedValidatedType + handler: (entry: LogEntry) => void + }> = [] private readonly handleSigchainUpdated = async (): Promise => { if (!this.channels || !this.privateChannels) { return @@ -195,18 +200,47 @@ export class ChannelsService extends EventEmitter { private attachChannelMetadataUpdateHandler( store: KeyValueIndexedValidatedType | undefined ): void { - store?.events.on('update', (entry: LogEntry) => { - void this.handleChannelMetadataUpdate(entry).catch(e => { + if (store == null) { + return + } + const handler = (entry: LogEntry) => { + void this.handleChannelMetadataUpdate(store, entry).catch(e => { this.logger.error('Error handling channels database update', e) }) - }) + } + store.events.on('update', handler) + this.channelMetadataUpdateHandlers.push({ store, handler }) } - private async handleChannelMetadataUpdate(entry: LogEntry): Promise { + private detachChannelMetadataUpdateHandlers(): void { + for (const { store, handler } of this.channelMetadataUpdateHandlers) { + store.events.off('update', handler) + } + this.channelMetadataUpdateHandlers = [] + } + + private isChannelMetadataStoreUpdate( + store: KeyValueIndexedValidatedType, + entry: LogEntry + ): boolean { + const storeAddress = (store as { address?: string }).address + const entryStoreAddress = (entry as LogEntry & { id?: string }).id + return storeAddress != null && entryStoreAddress === storeAddress + } + + private async handleChannelMetadataUpdate( + store: KeyValueIndexedValidatedType, + entry: LogEntry + ): Promise { + if (!this.isChannelMetadataStoreUpdate(store, entry)) { + return + } + const channelId = entry.payload?.key const operation = entry.payload.op this.logger.info('channels database updated', channelId, operation) + await store.retryIndexingUnindexedEntries() this.emit(SocketEvents.CONNECTION_PROCESS_INFO, ConnectionProcessInfo.CHANNELS_STORED) await this.broadcastCurrentChannels() } @@ -688,12 +722,10 @@ export class ChannelsService extends EventEmitter { // // This fixes a bug where joining a community with multiple channels doesn't initialize all channels immediately. for (const channel of channels) { - if ( - !this.channelsRepos.has(channel.id) || - (!this.channelsRepos.get(channel.id)?.eventsAttached && - !this.channelsRepos.get(channel.id)?.store.isSubscribing) - ) { - await this.subscribeToChannel(channel) + if (!this.channelsRepos.get(channel.id)?.subscribed) { + void this.ensureChannelSubscription(channel).catch(e => { + this.logger.error(`Can't subscribe to channel ${channel.id}`, e) + }) } } } @@ -851,20 +883,51 @@ export class ChannelsService extends EventEmitter { * @returns Newly created ChannelStore */ public async createChannel(channelData: PublicChannel): Promise { + const channelId = channelData.id + const creationPromise = this.channelCreationPromises.get(channelId) + if (creationPromise) { + return await creationPromise + } + + const existingRepo = this.channelsRepos.get(channelId) + if (existingRepo) { + this.logger.info(`Channel ${channelId} already has a local repo`) + return existingRepo.store + } + + const nextCreationPromise = this.createChannelWithRepo(channelData).finally(() => { + this.channelCreationPromises.delete(channelId) + }) + this.channelCreationPromises.set(channelId, nextCreationPromise) + return await nextCreationPromise + } + + private async createChannelWithRepo(channelData: PublicChannel): Promise { this.logger.info(`Creating channel`, channelData.id, channelData.name) const channelId = channelData.id const store = await this.createChannelStore(channelData) - const channel = await this.getChannel(channelId) - if (channel == undefined) { - await this.setChannel(channelData) - } else { - this.logger.info(`Channel ${channelId} already exists`) + this.channelsRepos.set(channelId, { + store, + eventsAttached: false, + subscribed: false, + public: channelData.public ?? true, + }) + this.logger.info(`Set ${channelId} to local channels`) + + try { + const channel = await this.getChannel(channelId) + if (channel == undefined) { + await this.setChannel(channelData) + } else { + this.logger.info(`Channel ${channelId} already exists`) + } + } catch (e) { + this.channelsRepos.delete(channelId) + throw e } - this.channelsRepos.set(channelId, { store, eventsAttached: false, public: channelData.public ?? true }) - this.logger.info(`Set ${channelId} to local channels`) this.logger.info(`Created channel ${channelId}`) return store @@ -909,6 +972,7 @@ export class ChannelsService extends EventEmitter { if (!store) { throw new Error('Failed to create channel') } + await this.ensureChannelSubscription(channelData) return { channel: channelData, status: ChannelOperationStatus.SUCCESS } } @@ -925,41 +989,74 @@ export class ChannelsService extends EventEmitter { * @emits StorageEvents.CHANNEL_SUBSCRIBED */ public async subscribeToChannel(channelData: PublicChannel): Promise { - let store: ChannelStore - // @ts-ignore - if (channelData.address) { - // @ts-ignore - channelData.id = channelData.address + const channel = this.normalizeChannelData(channelData) + const repo = await this.ensureChannelSubscription(channel) + if (!repo) return + + return { channel, status: ChannelOperationStatus.SUCCESS } + } + + private normalizeChannelData(channelData: PublicChannel): PublicChannel { + const channelWithAddress = channelData as PublicChannel & { address?: string } + if (channelWithAddress.address) { + return { + ...channelData, + id: channelWithAddress.address, + } } - let repo = this.channelsRepos.get(channelData.id) + return channelData + } + private async ensureChannelRepo(channelData: PublicChannel): Promise { + let repo = this.channelsRepos.get(channelData.id) if (repo) { - store = repo.store - } else { - try { - store = await this.createChannel(channelData) - } catch (e) { - this.logger.error(`Can't subscribe to channel ${channelData.id}`, e) - return - } - if (!store) { - this.logger.error(`Can't subscribe to channel ${channelData.id}, the DB isn't initialized!`) - return - } - repo = this.channelsRepos.get(channelData.id) + return repo + } + + try { + await this.createChannel(channelData) + } catch (e) { + this.logger.error(`Can't subscribe to channel ${channelData.id}`, e) + return } - if (repo && !repo.eventsAttached && !repo.store.isSubscribing) { - this.handleMessageEventsOnChannelStore(channelData.id, repo) - await repo.store.subscribe() + repo = this.channelsRepos.get(channelData.id) + if (!repo) { + this.logger.error(`Can't subscribe to channel ${channelData.id}, the DB isn't initialized!`) + return + } + return repo + } + + private async ensureChannelSubscription(channelData: PublicChannel): Promise { + const channel = this.normalizeChannelData(channelData) + const repo = await this.ensureChannelRepo(channel) + if (!repo) return + if (repo.subscribed) return repo + + if (!repo.subscriptionPromise) { + repo.subscriptionPromise = this.subscribeChannelRepo(channel.id, repo).finally(() => { + repo.subscriptionPromise = undefined + }) + } + + await repo.subscriptionPromise + return repo + } + + private async subscribeChannelRepo(channelId: string, repo: ChannelRepo): Promise { + if (!repo.eventsAttached) { + this.handleMessageEventsOnChannelStore(channelId, repo) repo.eventsAttached = true } - this.logger.info(`Subscribed to channel ${channelData.id}`) + await repo.store.subscribe() + repo.subscribed = true + + this.logger.info(`Subscribed to channel ${channelId}`) this.emit(StorageEvents.CHANNEL_SUBSCRIBED, { - channelId: channelData.id, + channelId, } as ChannelSubscribedPayload) - return { channel: channelData, status: ChannelOperationStatus.SUCCESS } } /** @@ -1089,7 +1186,16 @@ export class ChannelsService extends EventEmitter { */ public async sendMessage(message: ChannelMessage): Promise { this.logger.info('Sending message', message) - const repo = this.channelsRepos.get(message.channelId) + let repo = this.channelsRepos.get(message.channelId) + if (!repo?.subscribed) { + const channel = await this.getChannel(message.channelId) + if (channel == null) { + this.logger.error(`Could not send message. No '${message.channelId}' channel in saved public channels`) + return false + } + repo = await this.ensureChannelSubscription(channel) + } + if (repo == null) { this.logger.error(`Could not send message. No '${message.channelId}' channel in saved public channels`) return false @@ -1266,6 +1372,8 @@ export class ChannelsService extends EventEmitter { * Close the channels management database on OrbitDB and each channel's DB */ public async closeChannels(): Promise { + this.detachChannelMetadataUpdateHandlers() + this.channelCreationPromises.clear() const channels = this.channels const privateChannels = this.privateChannels const channelsRepos = this.channelsRepos @@ -1320,6 +1428,8 @@ export class ChannelsService extends EventEmitter { public async clean(): Promise { this.initialized = false this.detachFileManagerEvents() + this.detachChannelMetadataUpdateHandlers() + this.channelCreationPromises.clear() const channels = this.channels const privateChannels = this.privateChannels const channelsRepos = this.channelsRepos diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts index aaf58072cb..619dc9cf8d 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts @@ -120,15 +120,54 @@ describe('OrbitDbService', () => { AccessController: IPFSAccessController({ write: ['*'] }), sync: false, }) - let putEventEmitted = false - OrbitDbService.events.on('put', (update: LogUpdate) => { - putEventEmitted = true + + const putListener = jest.fn((update: LogUpdate) => { expect(update).toBeDefined() expect(update.entry.payload.value).toStrictEqual({ content: 'test content' }) expect(update.entry.identity).toEqual(orbitDbService.orbitDb.identity.hash) }) + OrbitDbService.events.on('put', putListener) + + try { + await store.add({ content: 'test content' }) + expect(putListener).toHaveBeenCalled() + } finally { + OrbitDbService.events.off('put', putListener) + } + }) + + it('ignores local update events when the entry store is not open', async () => { + await orbitDbService.create(ipfsService.ipfsInstance!) + const putListener = jest.fn() + OrbitDbService.events.on('put', putListener) + + try { + expect(() => { + OrbitDbService.events.emit('update', { + id: 'missing-store', + hash: 'missing-store-entry', + identity: orbitDbService.orbitDb.identity.hash, + payload: { + value: { content: 'test content' }, + }, + } as LogEntry) + }).not.toThrow() + expect(putListener).not.toHaveBeenCalled() + } finally { + OrbitDbService.events.off('put', putListener) + } + }) + + it('detaches and reattaches its static update listener across stop and create', async () => { + await orbitDbService.create(ipfsService.ipfsInstance!) + const updateListener = (orbitDbService as any).handleOrbitDbUpdate + + expect(OrbitDbService.events.listeners('update')).toContain(updateListener) - await store.add({ content: 'test content' }) - expect(putEventEmitted).toBeTruthy() + await orbitDbService.stop() + expect(OrbitDbService.events.listeners('update')).not.toContain(updateListener) + + await orbitDbService.create(ipfsService.ipfsInstance!) + expect(OrbitDbService.events.listeners('update')).toContain(updateListener) }) }) diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 8192224244..60e64b8831 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -35,11 +35,30 @@ import { LFAIdentities } from './identity/lfa/lfa-identity.service' export class OrbitDbService { private orbitDbInstance: OrbitDBType | undefined = undefined private stores: Record = {} + private orbitDbUpdateListenerAttached = false public identities: LFAIdentities | undefined = undefined public static readonly events = new EventEmitter() private readonly logger = createLogger(OrbitDbService.name) + private readonly handleOrbitDbUpdate = (entry: LogEntry): void => { + const localIdentityHash = this.orbitDbInstance?.identity.hash + if (localIdentityHash == null || entry.identity !== localIdentityHash) { + return + } + + const store = this.stores[entry.id] + if (store == null) { + this.logger.warn('Skipping OrbitDB put fanout for local entry without an open store', { + storeId: entry.id, + hash: entry.hash, + }) + return + } + + OrbitDbService.events.emit('put', logEntryToLogUpdate(entry, store.address, store.meta?.['teamId'])) + } + constructor( @Inject(ORBIT_DB_DIR) public readonly orbitDbDir: string, private readonly localDbService: LocalDbService, @@ -48,12 +67,23 @@ export class OrbitDbService { private readonly messagesAccessController: MessagesAccessController, private readonly channelMetadataAccessController: ChannelMetadataAccessController ) { - OrbitDbService.events.on('update', (entry: LogEntry) => { - if (entry.identity == this.orbitDbInstance?.identity.hash) { - const store = this.stores[entry.id] - OrbitDbService.events.emit('put', logEntryToLogUpdate(entry, store.address, store.meta['teamId'])) - } - }) + this.attachOrbitDbUpdateListener() + } + + private attachOrbitDbUpdateListener(): void { + if (this.orbitDbUpdateListenerAttached) { + return + } + OrbitDbService.events.on('update', this.handleOrbitDbUpdate) + this.orbitDbUpdateListenerAttached = true + } + + private detachOrbitDbUpdateListener(): void { + if (!this.orbitDbUpdateListenerAttached) { + return + } + OrbitDbService.events.off('update', this.handleOrbitDbUpdate) + this.orbitDbUpdateListenerAttached = false } get orbitDb() { @@ -70,6 +100,7 @@ export class OrbitDbService { this.logger.warn(`Already had an instance of OrbitDB, returning...`) return } + this.attachOrbitDbUpdateListener() orbitDbUseAccessController( this.messagesAccessController.createAccessControllerFunc({ @@ -138,6 +169,7 @@ export class OrbitDbService { } public async stop() { + this.detachOrbitDbUpdateListener() if (this.orbitDbInstance != undefined) { this.logger.info('Stopping OrbitDB') try { diff --git a/packages/desktop/src/renderer/components/Channel/Channel.tsx b/packages/desktop/src/renderer/components/Channel/Channel.tsx index d07a6b0b82..f5676c8ad2 100644 --- a/packages/desktop/src/renderer/components/Channel/Channel.tsx +++ b/packages/desktop/src/renderer/components/Channel/Channel.tsx @@ -26,6 +26,7 @@ const Channel = () => { const currentChannelId = useSelector(publicChannels.selectors.currentChannelId) const currentChannelName = useSelector(publicChannels.selectors.currentChannelName) const currentChannel = useSelector(publicChannels.selectors.currentChannel) + const currentChannelSubscribed = useSelector(publicChannels.selectors.currentChannelSubscribed) const currentChannelMessagesCount = useSelector(publicChannels.selectors.currentChannelMessagesCount) @@ -212,6 +213,7 @@ const Channel = () => { handleFileDrop: handleFileDrop, openFilesDialog: openFilesDialog, isCommunityInitialized: isCommunityInitialized, + currentChannelSubscribed: currentChannelSubscribed, handleClipboardFiles: handleClipboardFiles, uploadedFileModal: uploadedFileModal, openContextMenu: openContextMenu, diff --git a/packages/desktop/src/renderer/components/Channel/ChannelComponent.test.tsx b/packages/desktop/src/renderer/components/Channel/ChannelComponent.test.tsx new file mode 100644 index 0000000000..baf698f397 --- /dev/null +++ b/packages/desktop/src/renderer/components/Channel/ChannelComponent.test.tsx @@ -0,0 +1,80 @@ +import React from 'react' +import '@testing-library/jest-dom/extend-expect' +import { screen } from '@testing-library/dom' +import { ChannelComponent } from './ChannelComponent' +import { renderComponent } from '../../testUtils/renderComponent' +import { ModalName } from '../../sagas/modals/modals.types' + +describe('ChannelComponent', () => { + const renderChannel = (overrides: Partial> = {}) => { + window.HTMLElement.prototype.scrollTo = jest.fn() + window.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })) + + const props: React.ComponentProps = { + user: { + userId: 'userId', + nickname: 'alice', + }, + channelId: 'general-channel-id', + channelName: 'general', + isPublic: true, + messages: { + count: 0, + groups: {}, + }, + newestMessage: { + id: 'newest-message-id', + type: 1, + message: 'newest message', + createdAt: 1, + channelId: 'general-channel-id', + userId: 'userId', + }, + pendingMessages: {}, + maxAutodownloadSizeBytes: 0, + lazyLoading: jest.fn(), + onInputChange: jest.fn(), + onInputEnter: jest.fn(), + openUrl: jest.fn(), + openFilesDialog: jest.fn(), + handleFileDrop: jest.fn(), + isCommunityInitialized: true, + currentChannelSubscribed: true, + handleClipboardFiles: jest.fn(), + pendingGeneralChannelRecreation: false, + unregisteredUsernameModalHandleOpen: () => ({ + type: 'Modals/openModal', + payload: { + name: ModalName.unregisteredUsernameModal, + }, + }), + duplicatedUsernameModalHandleOpen: () => ({ + type: 'Modals/openModal', + payload: { + name: ModalName.duplicatedUsernameModal, + }, + }), + filesData: {}, + removeFile: jest.fn(), + ...overrides, + } + + return renderComponent() + } + + it('enables input for an initialized subscribed channel with zero messages', () => { + renderChannel() + + expect(screen.getByTestId('messageInput')).toBeEnabled() + }) + + it('disables input for an initialized unsubscribed channel with zero messages', () => { + renderChannel({ currentChannelSubscribed: false }) + + expect(screen.getByTestId('messageInput')).toBeDisabled() + }) +}) diff --git a/packages/desktop/src/renderer/components/Channel/ChannelComponent.tsx b/packages/desktop/src/renderer/components/Channel/ChannelComponent.tsx index 37d6f598ce..96a37c33fd 100644 --- a/packages/desktop/src/renderer/components/Channel/ChannelComponent.tsx +++ b/packages/desktop/src/renderer/components/Channel/ChannelComponent.tsx @@ -53,6 +53,7 @@ export interface ChannelComponentProps { openFilesDialog: () => void handleFileDrop: (arg: any) => void isCommunityInitialized: boolean + currentChannelSubscribed?: boolean handleClipboardFiles: (arg: ArrayBuffer, ext: string, name: string) => void uploadedFileModal?: UseModalType<{ src: string @@ -88,6 +89,7 @@ export const ChannelComponent: React.FC { ) .run() }) + + test('creates missing message base before asking for messages', async () => { + const channelId = 'new-private-channel' + const messageId = 'created-private-channel-message' + + const reducer = combineReducers(testReducers) + await expectSaga( + checkForMessagesSaga, + messagesActions.checkForMessages({ + ids: [messageId], + channelId, + communityId: community.id, + }) + ) + .withReducer(reducer) + .withState(store.getState()) + .put(messagesActions.addPublicChannelsMessagesBase({ channelId })) + .put( + messagesActions.getMessages({ + peerId: alice.networkInfo.peerId.id, + communityId: community.id, + channelId, + ids: [messageId], + }) + ) + .run() + }) }) diff --git a/packages/state-manager/src/sagas/messages/checkForMessages/checkForMessages.saga.ts b/packages/state-manager/src/sagas/messages/checkForMessages/checkForMessages.saga.ts index c444c740b4..75c5b5bf99 100644 --- a/packages/state-manager/src/sagas/messages/checkForMessages/checkForMessages.saga.ts +++ b/packages/state-manager/src/sagas/messages/checkForMessages/checkForMessages.saga.ts @@ -1,5 +1,5 @@ import { put, select } from 'typed-redux-saga' -import { missingChannelMessages } from '../messages.selectors' +import { messagesSelectors, missingChannelMessages } from '../messages.selectors' import { type PayloadAction } from '@reduxjs/toolkit' import { messagesActions } from '../messages.slice' import { currentCommunity } from '../../communities/communities.selectors' @@ -15,6 +15,11 @@ export function* checkForMessagesSaga( const identity = yield* select(currentIdentity) if (!community || !identity) return + const channelMessagesBase = yield* select(messagesSelectors.publicChannelsMessagesBase) + if (!channelMessagesBase[channelId]) { + yield* put(messagesActions.addPublicChannelsMessagesBase({ channelId })) + } + const missingMessages = yield* select(missingChannelMessages(ids, channelId)) if (missingMessages?.length > 0) { diff --git a/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.test.ts b/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.test.ts index 380810f5e2..b376025bb6 100644 --- a/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.test.ts +++ b/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.test.ts @@ -128,6 +128,42 @@ describe('sendMessageSaga', () => { .run() }) + test('waits for the target channel subscription before emitting', async () => { + const channelId = sailingChannel.id + const channelMessage = await baseTypesFactory.build('ChannelMessage', { + userId: alice.userId, + channelId, + }) + const reducer = combineReducers(testReducers) + const baseState = store.getState() + const stateWithNoSubscriptions = { + ...baseState, + PublicChannels: { + ...baseState.PublicChannels, + channelsSubscriptions: { + ids: [], + entities: {}, + }, + }, + } + + await expectSaga( + sendMessageSaga, + socket as unknown as Socket, + messagesActions.sendMessage({ message: channelMessage.message, channelId }) + ) + .withReducer(reducer) + .withState(stateWithNoSubscriptions) + .provide([ + [call.fn(generateMessageId), channelMessage.id], + [call.fn(getCurrentTime), channelMessage.createdAt], + ]) + .dispatch(publicChannelsActions.setChannelSubscribed({ channelId: generateTestChannelId('unrelated') })) + .dispatch(publicChannelsActions.setChannelSubscribed({ channelId })) + .apply(socket, socket.emit, applyEmitParams(SocketActions.SEND_MESSAGE, channelMessage)) + .run() + }) + test('do not broadcast message until file is uploaded', async () => { const messageId = Math.random().toString(36).substr(2.9) const currentChannel = currentChannelId(store.getState()) diff --git a/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.ts b/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.ts index ca18a33483..594ca93952 100644 --- a/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.ts +++ b/packages/state-manager/src/sagas/messages/sendMessage/sendMessage.saga.ts @@ -1,7 +1,6 @@ import { type Socket, applyEmitParams } from '../../../types' import { type PayloadAction } from '@reduxjs/toolkit' import { call, select, apply, put, delay, take } from 'typed-redux-saga' -import { publicChannelsActions } from '../../publicChannels/publicChannels.slice' import { publicChannelsSelectors } from '../../publicChannels/publicChannels.selectors' import { messagesActions } from '../messages.slice' import { generateMessageId, getCurrentTime } from '../utils/message.utils' @@ -9,6 +8,7 @@ import { type ChannelMessage, MessageType, SendingStatus, SocketActions } from ' import { createLogger } from '../../../utils/logger' import { identitySelectors } from '../../identity/identity.selectors' import { identityActions } from '../../identity/identity.slice' +import { waitForChannelSubscriptionSaga } from '../../publicChannels/waitForChannelSubscription.saga' const logger = createLogger('sendMessageSaga') @@ -92,17 +92,7 @@ export function* sendMessageSaga( // TODO: I think we probably want to revise how we are sending // messages by having the backend handling queueing and retrying // (in a durable way). - while (true) { - const subscribedChannels = yield* select(publicChannelsSelectors.subscribedChannels) - logger.info('Subscribed channels', subscribedChannels) - if (subscribedChannels.includes(channelId)) { - logger.info(`Channel ${channelId} subscribed`) - break - } - logger.error(`Waiting to send message ${id} - channel not subscribed`) - yield* take(publicChannelsActions.setChannelSubscribed) - logger.info('New channel subscribed') - } + yield* waitForChannelSubscriptionSaga(channelId) logger.info('Emitting SEND_MESSAGE', message) yield* apply(socket, socket.emit, applyEmitParams(SocketActions.SEND_MESSAGE, message)) diff --git a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts index b2e19aae90..c40f16f012 100644 --- a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.test.ts @@ -13,7 +13,13 @@ import { channelsReplicatedSaga } from './channelsReplicated.saga' import { DateTime } from 'luxon' import { publicChannelsSelectors } from '../publicChannels.selectors' import { messagesActions } from '../../messages/messages.slice' -import { ChannelOperationStatus, type Community, type Identity, type PublicChannel } from '@quiet/types' +import { + ChannelOperationStatus, + CommunityOwnership, + type Community, + type Identity, + type PublicChannel, +} from '@quiet/types' import { generateTestChannelId } from '@quiet/common' import { createLogger } from '../../../utils/logger' import { getBaseTypesFactory, getReduxStoreFactory } from '../../../utils/tests/factories' @@ -268,4 +274,22 @@ describe('channelsReplicatedSaga', () => { .not.putResolve(publicChannelsActions.deleteChannel({ channelId: generalChannel.id })) .run() }) + + test('sends introduction when general already exists locally and replicated channels are empty', async () => { + const localStore = prepareStore().store + const localFactory = await getReduxStoreFactory(localStore) + await localFactory.create('Community', { ownership: CommunityOwnership.User }) + + const reducer = combineReducers(testReducers) + await expectSaga( + channelsReplicatedSaga, + publicChannelsActions.channelsReplicated({ + channels: [], + }) + ) + .withReducer(reducer) + .withState(localStore.getState()) + .putResolve(publicChannelsActions.sendIntroductionMessage()) + .run() + }) }) diff --git a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.ts b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.ts index b64f4b276d..64b0561a37 100644 --- a/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/channelsReplicated/channelsReplicated.saga.ts @@ -65,7 +65,10 @@ export function* channelsReplicatedSaga( const isOwner = yield* select(communitiesSelectors.isOwner) - if (!isOwner && databaseStoredChannels.find(channel => channel.name === 'general')) { + const generalChannel = databaseStoredChannels.find(channel => channel.name === 'general') + const locallyStoredGeneralChannel = yield* select(publicChannelsSelectors.generalChannel) + + if (!isOwner && (generalChannel || locallyStoredGeneralChannel)) { logger.info('Sending introduction message') yield* putResolve(publicChannelsActions.sendIntroductionMessage()) } diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts index 4035eda075..ba1065eb01 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.test.ts @@ -102,4 +102,97 @@ describe('sendInitialChannelMessageSaga', () => { ) .run() }) + + test('waits for matching general channel subscription before queueing the recreation message', async () => { + const localStore = prepareStore().store + const localFactory = await getReduxStoreFactory(localStore) + const localCommunity = await localFactory.create('Community') + const localOwner = await localFactory.create('Identity', { + communityId: localCommunity.id, + userId: 'localOwnerUserId', + }) + const localOwnerUserProfile = await localFactory.create('UserProfile', { + userId: localOwner.userId, + }) + const localGeneralChannel = publicChannelsSelectors.generalChannel(localStore.getState()) + if (!localGeneralChannel) throw new Error('No general channel') + localStore.dispatch(publicChannelsActions.startGeneralRecreation()) + const baseState = localStore.getState() + const stateWithNoSubscriptions = { + ...baseState, + PublicChannels: { + ...baseState.PublicChannels, + channelsSubscriptions: { + ids: [], + entities: {}, + }, + }, + } + const expectedMessagePayload = { + type: 3, + message: generalChannelDeletionMessage(localOwnerUserProfile.nickname), + channelId: localGeneralChannel.id, + } + const reducer = combineReducers(testReducers) + + await expectSaga( + sendInitialChannelMessageSaga, + publicChannelsActions.sendInitialChannelMessage({ + channelName: localGeneralChannel.name, + channelId: localGeneralChannel.id, + }) + ) + .withReducer(reducer) + .withState(stateWithNoSubscriptions) + .dispatch(publicChannelsActions.setChannelSubscribed({ channelId: generateTestChannelId('unrelated') })) + .dispatch(publicChannelsActions.setChannelSubscribed({ channelId: localGeneralChannel.id })) + .put(publicChannelsActions.setCurrentChannel({ channelId: localGeneralChannel.id })) + .put(publicChannelsActions.finishGeneralRecreation()) + .put(messagesActions.sendMessage(expectedMessagePayload)) + .run() + }) + + test('finishes general recreation before waiting to queue the recreation message', async () => { + const localStore = prepareStore().store + const localFactory = await getReduxStoreFactory(localStore) + const localCommunity = await localFactory.create('Community') + const localOwner = await localFactory.create('Identity', { + communityId: localCommunity.id, + userId: 'orderedOwnerUserId', + }) + const localOwnerUserProfile = await localFactory.create('UserProfile', { + userId: localOwner.userId, + }) + const localGeneralChannel = publicChannelsSelectors.generalChannel(localStore.getState()) + if (!localGeneralChannel) throw new Error('No general channel') + const expectedMessage = generalChannelDeletionMessage(localOwnerUserProfile.nickname) + const expectedMessagePayload = { + type: 3, + message: expectedMessage, + channelId: localGeneralChannel.id, + } + const generator = sendInitialChannelMessageSaga( + publicChannelsActions.sendInitialChannelMessage({ + channelName: localGeneralChannel.name, + channelId: localGeneralChannel.id, + }) + ) + + generator.next() + generator.next(localGeneralChannel) + generator.next(true) + generator.next(localOwnerUserProfile) + + const setCurrentChannelEffect = generator.next(expectedMessage).value as any + expect(setCurrentChannelEffect.payload.action).toEqual( + publicChannelsActions.setCurrentChannel({ channelId: localGeneralChannel.id }) + ) + + const finishGeneralRecreationEffect = generator.next().value as any + expect(finishGeneralRecreationEffect.payload.action).toEqual(publicChannelsActions.finishGeneralRecreation()) + + generator.next() + const sendMessageEffect = generator.next(true).value as any + expect(sendMessageEffect.payload.action).toEqual(messagesActions.sendMessage(expectedMessagePayload)) + }) }) diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts index 6219c2831c..efd7094a58 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/sendInitialChannelMessage.saga.ts @@ -6,6 +6,7 @@ import { publicChannelsActions } from '../publicChannels.slice' import { MessageType, type WriteMessagePayload } from '@quiet/types' import { generalChannelDeletionMessage, createdChannelMessage } from '@quiet/common' import { userProfileSelectors } from '../../users/userProfile/userProfile.selectors' +import { waitForChannelSubscriptionSaga } from '../waitForChannelSubscription.saga' export function* sendInitialChannelMessageSaga( action: PayloadAction['payload']> @@ -35,5 +36,6 @@ export function* sendInitialChannelMessageSaga( yield* put(publicChannelsActions.finishGeneralRecreation()) } + yield* waitForChannelSubscriptionSaga(channelId) yield* put(messagesActions.sendMessage(payload)) } diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts index 499c416179..ca8aab2729 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.test.ts @@ -332,6 +332,44 @@ describe('publicChannelsSelectors', () => { const unreadChannels = publicChannelsSelectors.unreadChannels(store.getState()) expect(unreadChannels).toEqual([channelId]) }) + + it('subscription selectors return only subscribed channels and current channel readiness', async () => { + const baseState = store.getState() + const stateWithSubscriptions = { + ...baseState, + PublicChannels: { + ...baseState.PublicChannels, + currentChannelId: 'subscribed-channel-id', + channelsSubscriptions: { + ids: ['subscribed-channel-id', 'unsubscribed-channel-id'], + entities: { + 'subscribed-channel-id': { + id: 'subscribed-channel-id', + subscribed: true, + }, + 'unsubscribed-channel-id': { + id: 'unsubscribed-channel-id', + subscribed: false, + }, + }, + }, + }, + } + + expect(publicChannelsSelectors.subscribedChannels(stateWithSubscriptions)).toEqual(['subscribed-channel-id']) + expect(publicChannelsSelectors.isChannelSubscribed('subscribed-channel-id')(stateWithSubscriptions)).toBe(true) + expect(publicChannelsSelectors.isChannelSubscribed('unsubscribed-channel-id')(stateWithSubscriptions)).toBe(false) + expect(publicChannelsSelectors.currentChannelSubscribed(stateWithSubscriptions)).toBe(true) + expect( + publicChannelsSelectors.currentChannelSubscribed({ + ...stateWithSubscriptions, + PublicChannels: { + ...stateWithSubscriptions.PublicChannels, + currentChannelId: 'unsubscribed-channel-id', + }, + }) + ).toBe(false) + }) }) export {} diff --git a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts index 40141413be..224dcd5b2a 100644 --- a/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts +++ b/packages/state-manager/src/sagas/publicChannels/publicChannels.selectors.ts @@ -18,6 +18,7 @@ import { type MessagesDailyGroups, type MessagesGroupsType, type PublicChannel, + type PublicChannelSubscription, type PublicChannelStatus, type PublicChannelStatusWithName, INITIAL_CURRENT_CHANNEL_ID, @@ -48,11 +49,19 @@ const pendingGeneralChannelRecreation = createSelector(selectState, state => { }) export const subscribedChannels = createSelector(selectChannelsSubscriptions, subscriptions => { - return subscriptions.map(subscription => { - if (subscription.subscribed) return subscription.id - }) + return subscriptions.filter(subscription => subscription.subscribed).map(subscription => subscription.id) }) +const hasSubscribedChannel = (subscriptions: PublicChannelSubscription[], channelId: string | undefined): boolean => { + if (!channelId) return false + return subscriptions.some(subscription => subscription.id === channelId && subscription.subscribed) +} + +export const isChannelSubscribed = (channelId: string | undefined) => + createSelector(selectChannelsSubscriptions, subscriptions => { + return hasSubscribedChannel(subscriptions, channelId) + }) + // Serves for testing purposes only export const selectGeneralChannel = createSelector(selectChannels, currentCommunity, (channels, currentCommunity) => { if (currentCommunity == null || currentCommunity.teamId == null) { @@ -124,6 +133,14 @@ export const currentChannelId = createSelector(selectState, generalChannel, (sta } }) +export const currentChannelSubscribed = createSelector( + currentChannelId, + selectChannelsSubscriptions, + (id, subscriptions) => { + return hasSubscribedChannel(subscriptions, id) + } +) + export const recentChannels = createSelector( publicChannels, generalChannel, @@ -325,6 +342,8 @@ export const canCreatePrivateChannel = createSelector(selectState, () => { export const publicChannelsSelectors = { publicChannels, subscribedChannels, + isChannelSubscribed, + currentChannelSubscribed, currentChannelId, currentChannelName, currentChannel, diff --git a/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.ts b/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.ts new file mode 100644 index 0000000000..8673f2c2d7 --- /dev/null +++ b/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.ts @@ -0,0 +1,22 @@ +import { select, take } from 'typed-redux-saga' +import { createLogger } from '../../utils/logger' +import { publicChannelsSelectors } from './publicChannels.selectors' +import { publicChannelsActions } from './publicChannels.slice' + +const logger = createLogger('waitForChannelSubscriptionSaga') + +export function* waitForChannelSubscriptionSaga(channelId: string): Generator { + const targetChannelSubscribed = publicChannelsSelectors.isChannelSubscribed(channelId) + + while (!(yield* select(targetChannelSubscribed))) { + logger.info(`Waiting for channel ${channelId} subscription`) + const action: ReturnType = yield* take( + publicChannelsActions.setChannelSubscribed + ) + if (action.payload.channelId !== channelId) { + logger.info(`Ignoring subscription for channel ${action.payload.channelId} while waiting for ${channelId}`) + } + } + + logger.info(`Channel ${channelId} subscribed`) +} From ad04c098a8abd6337180a8df8fff1da3001d8062 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 1 Jul 2026 12:13:38 -0400 Subject: [PATCH 14/21] update community creation tests with channel subscription handling; add integration test for channels service --- .../storage/channels/channels.service.spec.ts | 188 +++++++++++++++++- .../src/rtl-tests/community.create.test.tsx | 9 +- 2 files changed, 194 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 77b42ea8ac..95564565d2 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -5,13 +5,18 @@ import { getBaseTypesFactory } from '@quiet/state-manager' import { ChannelMessage, ChannelOperationStatus, + ChannelSubscribedPayload, + ChannelsReplicatedPayload, Community, CreateChannelPayload, + CreateChannelResponse, + DeleteChannelPayload, DeleteChannelResponse, FileMetadata, - Identity, MessageType, PublicChannel, + SocketActions, + SocketEvents, } from '@quiet/types' import path from 'path' @@ -224,6 +229,88 @@ describe('ChannelsService', () => { } } + type MockStateManagerSocket = EventEmitter & { + channelSubscribedPayloads: ChannelSubscribedPayload[] + channelsStoredPayloads: ChannelsReplicatedPayload[] + emitWithAck: ( + event: SocketActions.CREATE_CHANNEL | SocketActions.DELETE_CHANNEL, + payload: CreateChannelPayload | DeleteChannelPayload + ) => Promise + } + + const createMockStateManagerSocket = (): MockStateManagerSocket => { + const socket = new EventEmitter() as MockStateManagerSocket + socket.channelSubscribedPayloads = [] + socket.channelsStoredPayloads = [] + + socket.on(SocketEvents.CHANNEL_SUBSCRIBED, (payload: ChannelSubscribedPayload) => { + socket.channelSubscribedPayloads.push(payload) + }) + socket.on(SocketEvents.CHANNELS_STORED, (payload: ChannelsReplicatedPayload) => { + socket.channelsStoredPayloads.push(payload) + }) + + socket.emitWithAck = async ( + event: SocketActions.CREATE_CHANNEL | SocketActions.DELETE_CHANNEL, + payload: CreateChannelPayload | DeleteChannelPayload + ): Promise => { + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for ${event} ack`)) + }, 5000) + const callback = (response: Response) => { + clearTimeout(timeout) + resolve(response) + } + const emitted = socket.emit(event, payload, callback) + if (!emitted) { + clearTimeout(timeout) + reject(new Error(`No mock socket listener registered for ${event}`)) + } + }) + } + + return socket + } + + const connectMockStateManagerSocket = (socket: MockStateManagerSocket): (() => void) => { + const handleCreateChannel = async ( + payload: CreateChannelPayload, + callback: (response: CreateChannelResponse) => void + ) => { + try { + callback(await channelsService.handleCreateChannel(payload)) + } catch { + callback({ status: ChannelOperationStatus.FAILED }) + } + } + const handleDeleteChannel = async ( + payload: DeleteChannelPayload, + callback: (response: DeleteChannelResponse) => void + ) => { + callback(await channelsService.deleteChannel(payload)) + } + const forwardChannelsStored = (payload: ChannelsReplicatedPayload) => { + socket.emit(SocketEvents.CHANNELS_STORED, payload) + } + const forwardChannelSubscribed = (payload: ChannelSubscribedPayload) => { + socket.emit(SocketEvents.CHANNEL_SUBSCRIBED, payload) + } + + socket.on(SocketActions.CREATE_CHANNEL, handleCreateChannel) + socket.on(SocketActions.DELETE_CHANNEL, handleDeleteChannel) + channelsService.on(StorageEvents.CHANNELS_STORED, forwardChannelsStored) + channelsService.on(StorageEvents.CHANNEL_SUBSCRIBED, forwardChannelSubscribed) + + return () => { + socket.off(SocketActions.CREATE_CHANNEL, handleCreateChannel) + socket.off(SocketActions.DELETE_CHANNEL, handleDeleteChannel) + channelsService.off(StorageEvents.CHANNELS_STORED, forwardChannelsStored) + channelsService.off(StorageEvents.CHANNEL_SUBSCRIBED, forwardChannelSubscribed) + socket.removeAllListeners() + } + } + afterEach(async () => { await storageService.stop() await libp2pService.close() @@ -281,6 +368,105 @@ describe('ChannelsService', () => { expect(subscribedChannelIds).toContain(response.channel!.id) }) + it('stress tests channel repo lifecycle through a mocked state-manager socket', async () => { + const stateManagerSocket = createMockStateManagerSocket() + const disconnectSocket = connectMockStateManagerSocket(stateManagerSocket) + const getLatestStoredChannels = (): PublicChannel[] => + stateManagerSocket.channelsStoredPayloads[stateManagerSocket.channelsStoredPayloads.length - 1]?.channels ?? [] + + try { + const createPayloads: CreateChannelPayload[] = Array.from({ length: 6 }, (_, index) => ({ + name: `socket-channel-${index}`, + description: `socket channel ${index}`, + public: true, + teamId: community.teamId!, + })) + + const createResponses = await Promise.all( + createPayloads.map(payload => + stateManagerSocket.emitWithAck(SocketActions.CREATE_CHANNEL, payload) + ) + ) + const createdChannels = createResponses.map(response => response.channel!) + const createdChannelIds = createdChannels.map(createdChannel => createdChannel.id) + + expect(createResponses.every(response => response.status === ChannelOperationStatus.SUCCESS)).toBe(true) + expect(new Set(createdChannelIds).size).toBe(createPayloads.length) + for (const createdChannel of createdChannels) { + const repo = channelsService.channelsRepos.get(createdChannel.id) + expect(repo).toBeDefined() + expect(repo!.eventsAttached).toBe(true) + expect(repo!.subscribed).toBe(true) + expect(repo!.public).toBe(true) + await expect(channelsService.getChannel(createdChannel.id)).resolves.toEqual(createdChannel) + } + + const replicatedChannel = await factory.build('PublicChannel', { + id: 'socket-metadata-update-channel-id', + name: 'socket-metadata-update-channel', + description: 'channel replicated through metadata update', + owner: aliceUserId, + teamId: community.teamId!, + public: true, + }) + await channelsService.setChannel(replicatedChannel) + + const expectedSubscribedIds = [...createdChannelIds, replicatedChannel.id] + await waitForExpect(() => { + const subscribedIds = stateManagerSocket.channelSubscribedPayloads.map(payload => payload.channelId) + for (const channelId of expectedSubscribedIds) { + expect(subscribedIds.filter(id => id === channelId)).toHaveLength(1) + } + const replicatedRepo = channelsService.channelsRepos.get(replicatedChannel.id) + expect(replicatedRepo).toBeDefined() + expect(replicatedRepo!.eventsAttached).toBe(true) + expect(replicatedRepo!.subscribed).toBe(true) + expect(getLatestStoredChannels().map(storedChannel => storedChannel.id)).toEqual( + expect.arrayContaining(expectedSubscribedIds) + ) + }) + + const subscriptionEventsBeforeResubscribe = stateManagerSocket.channelSubscribedPayloads.length + await Promise.all( + createdChannels + .flatMap(createdChannel => [createdChannel, createdChannel]) + .map(createdChannel => channelsService.subscribeToChannel(createdChannel)) + ) + expect(stateManagerSocket.channelSubscribedPayloads).toHaveLength(subscriptionEventsBeforeResubscribe) + + const channelIdsToDelete = [createdChannels[1].id, createdChannels[4].id, replicatedChannel.id] + const deleteResponses = await Promise.all( + channelIdsToDelete.map(channelId => + stateManagerSocket.emitWithAck(SocketActions.DELETE_CHANNEL, { channelId }) + ) + ) + expect(deleteResponses).toEqual(channelIdsToDelete.map(channelId => ({ channelId, deleted: true }))) + + for (const channelId of channelIdsToDelete) { + expect(channelsService.channelsRepos.has(channelId)).toBe(false) + await expect(channelsService.getChannel(channelId)).resolves.toBeUndefined() + } + + const survivingChannelIds = createdChannelIds.filter(channelId => !channelIdsToDelete.includes(channelId)) + for (const channelId of survivingChannelIds) { + const repo = channelsService.channelsRepos.get(channelId) + expect(repo).toBeDefined() + expect(repo!.eventsAttached).toBe(true) + expect(repo!.subscribed).toBe(true) + await expect(channelsService.getChannel(channelId)).resolves.toBeDefined() + } + + await channelsService.broadcastCurrentChannels() + + await waitForExpect(() => { + const latestStoredChannelIds = getLatestStoredChannels().map(storedChannel => storedChannel.id) + expect([...latestStoredChannelIds].sort()).toEqual([...survivingChannelIds].sort()) + }) + } finally { + disconnectSocket() + } + }) + it('shares one subscription promise for concurrent subscribe calls', async () => { const channel = await factory.build('PublicChannel', { owner: aliceUserId, diff --git a/packages/desktop/src/rtl-tests/community.create.test.tsx b/packages/desktop/src/rtl-tests/community.create.test.tsx index 9f66404678..53adae9cd8 100644 --- a/packages/desktop/src/rtl-tests/community.create.test.tsx +++ b/packages/desktop/src/rtl-tests/community.create.test.tsx @@ -1,6 +1,6 @@ import { generateTestChannelId } from '@quiet/common' import { getSocketFactory, getBaseTypesFactory } from '@quiet/state-manager' -import { SocketActions, socketEventData } from '@quiet/types' +import { ChannelSubscribedPayload, SocketActions, SocketEvents, socketEventData } from '@quiet/types' import { screen } from '@testing-library/dom' import '@testing-library/jest-dom/extend-expect' import userEvent from '@testing-library/user-event' @@ -85,8 +85,12 @@ describe('User', () => { }), }) } else if (action === SocketActions.CREATE_CHANNEL) { + const channel = await baseTypesFactory.build('PublicChannel', { ...input[1], id: generalId }) + socket.socketClient.emit(SocketEvents.CHANNEL_SUBSCRIBED, { + channelId: channel.id, + }) return await factory.build(`${action}_response`, { - channel: baseTypesFactory.build('PublicChannel', { ...input[1] }), + channel, }) } } @@ -156,6 +160,7 @@ describe('User', () => { "Files/checkForMissingFiles", "Network/addInitializedCommunity", "Connection/setLongLivedInvite", + "PublicChannels/setChannelSubscribed", "Messages/addPublicChannelsMessagesBase", "PublicChannels/addChannel", "PublicChannels/sendInitialChannelMessage", From 075181716b9d9b9049a143d0c61b1154af88f1cc Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 1 Jul 2026 14:36:03 -0400 Subject: [PATCH 15/21] untangle store close handling to keep unrelated stores registered when closing one; add unit test for ChannelStore to ignore updates without matching channel id --- .../storage/channels/channel.store.spec.ts | 76 +++++++++ .../nest/storage/channels/channel.store.ts | 4 +- .../storage/orbitDb/orbitDb.service.spec.ts | 95 ++++++++++- .../nest/storage/orbitDb/orbitDb.service.ts | 156 +++++++++++++++--- .../dates/formatMessageDisplayDate.test.ts | 17 ++ .../dates/formatMessageDisplayDate.ts | 16 +- 6 files changed, 334 insertions(+), 30 deletions(-) create mode 100644 packages/backend/src/nest/storage/channels/channel.store.spec.ts diff --git a/packages/backend/src/nest/storage/channels/channel.store.spec.ts b/packages/backend/src/nest/storage/channels/channel.store.spec.ts new file mode 100644 index 0000000000..8bc15d51d1 --- /dev/null +++ b/packages/backend/src/nest/storage/channels/channel.store.spec.ts @@ -0,0 +1,76 @@ +import EventEmitter from 'node:events' +import { jest } from '@jest/globals' + +import { ChannelStore } from './channel.store' +import { StorageEvents } from '../storage.types' + +describe('ChannelStore', () => { + const makeLogger = () => ({ + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + trace: jest.fn(), + warn: jest.fn(), + }) + + async function* emptyIterator() {} + + it('ignores update entries without a matching channel id', async () => { + const channelId = 'channel-1' + const storeEvents = new EventEmitter() + const messagesService = { + onConsume: jest.fn(), + } + const localDbService = { + getCurrentCommunity: jest.fn(async () => ({ id: 'community-1' })), + } + const auth = Object.assign(new EventEmitter(), { + team: { id: 'team-1' }, + }) + const channelStore = new ChannelStore( + {} as any, + localDbService as any, + messagesService as any, + {} as any, + {} as any, + auth as any, + {} as any, + {} as any + ) + ;(channelStore as any).channelData = { + id: channelId, + name: 'general', + public: true, + teamId: 'team-1', + } + ;(channelStore as any).logger = makeLogger() + ;(channelStore as any)._messagesService = messagesService + ;(channelStore as any).store = { + events: storeEvents, + iterator: emptyIterator, + sync: { + start: jest.fn(async () => {}), + }, + } + + const messageIdsListener = jest.fn() + channelStore.on(StorageEvents.MESSAGE_IDS_STORED, messageIdsListener) + await channelStore.subscribe() + messageIdsListener.mockClear() + localDbService.getCurrentCommunity.mockClear() + + storeEvents.emit('update', { + hash: 'non-message-entry', + payload: { + value: { + id: 'non-message-value', + }, + }, + }) + await new Promise(resolve => setImmediate(resolve)) + + expect(messagesService.onConsume).not.toHaveBeenCalled() + expect(localDbService.getCurrentCommunity).not.toHaveBeenCalled() + expect(messageIdsListener).not.toHaveBeenCalled() + }) +}) diff --git a/packages/backend/src/nest/storage/channels/channel.store.ts b/packages/backend/src/nest/storage/channels/channel.store.ts index a8feb89759..f2819c69ae 100644 --- a/packages/backend/src/nest/storage/channels/channel.store.ts +++ b/packages/backend/src/nest/storage/channels/channel.store.ts @@ -144,9 +144,9 @@ export class ChannelStore extends EventStoreBase) => { const entryChannelId = entry.payload.value?.channelId // TODO: seperate event bus for each channel so we don't have to check this on every update - if (entryChannelId != null && entryChannelId !== this.channelData.id) { + if (entryChannelId !== this.channelData.id) { this.logger.debug( - `Ignoring database update for different channel`, + `Ignoring database update without matching channel`, entry.hash, entryChannelId, this.channelData.id diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts index 619dc9cf8d..4ee94ee9d9 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts @@ -20,6 +20,7 @@ import { Libp2pNodeParams } from '../../libp2p/libp2p.types' import { EventsType, IPFSAccessController, LogEntry } from '@orbitdb/core' import { LogUpdate } from './orbitdb.types' import { EventsWithStorage } from './eventsWithStorage' +import { KeyValueIndexedValidated, KeyValueIndexedValidatedType } from './keyValueIndexedValidated' import { SigChainModule } from '../../auth/sigchain.service.module' import { SigChainService } from '../../auth/sigchain.service' @@ -136,23 +137,105 @@ describe('OrbitDbService', () => { } }) - it('ignores local update events when the entry store is not open', async () => { + it('emits put event when a local update matches the requested store alias', async () => { await orbitDbService.create(ipfsService.ipfsInstance!) + const requestedAddress = `alias-test-store-${Date.now()}` + const store = await orbitDbService.open>(requestedAddress, { + type: 'events', + Database: EventsWithStorage(), + AccessController: IPFSAccessController({ write: ['*'] }), + sync: false, + }) const putListener = jest.fn() OrbitDbService.events.on('put', putListener) try { expect(() => { OrbitDbService.events.emit('update', { - id: 'missing-store', - hash: 'missing-store-entry', + id: requestedAddress, + hash: 'alias-store-entry', identity: orbitDbService.orbitDb.identity.hash, payload: { - value: { content: 'test content' }, + value: { content: 'test content', teamId: sigchainService.team.id }, }, - } as LogEntry) + } as unknown as LogEntry) }).not.toThrow() - expect(putListener).not.toHaveBeenCalled() + expect(putListener).toHaveBeenCalledWith( + expect.objectContaining({ + addr: store.address, + hash: 'alias-store-entry', + teamId: sigchainService.team.id, + }) + ) + } finally { + OrbitDbService.events.off('put', putListener) + } + }) + + it('emits put event for key-value metadata updates keyed by resolved store address', async () => { + await orbitDbService.create(ipfsService.ipfsInstance!) + const requestedAddress = `metadata-alias-test-store-${Date.now()}` + const teamId = sigchainService.team.id + const store = await orbitDbService.open>( + requestedAddress, + { + Database: KeyValueIndexedValidated(), + AccessController: IPFSAccessController({ write: ['*'] }), + sync: false, + } + ) + OrbitDbService.updateMetadata(store, { teamId }) + const putListener = jest.fn() + OrbitDbService.events.on('put', putListener) + + try { + const hash = await store.put('channel-id', { content: 'metadata content', teamId }) + expect(putListener).toHaveBeenCalledWith( + expect.objectContaining({ + id: store.address, + addr: store.address, + hash, + teamId, + }) + ) + } finally { + OrbitDbService.events.off('put', putListener) + } + }) + + it('keeps unrelated stores registered when one shared-event store closes or drops', async () => { + await orbitDbService.create(ipfsService.ipfsInstance!) + const closedStore = await orbitDbService.open>(`closed-store-${Date.now()}`, { + type: 'events', + Database: EventsWithStorage(), + AccessController: IPFSAccessController({ write: ['*'] }), + sync: false, + }) + const droppedStore = await orbitDbService.open>(`dropped-store-${Date.now()}`, { + type: 'events', + Database: EventsWithStorage(), + AccessController: IPFSAccessController({ write: ['*'] }), + sync: false, + }) + const activeStore = await orbitDbService.open>(`active-store-${Date.now()}`, { + type: 'events', + Database: EventsWithStorage(), + AccessController: IPFSAccessController({ write: ['*'] }), + sync: false, + }) + const putListener = jest.fn() + OrbitDbService.events.on('put', putListener) + + try { + await closedStore.close() + await droppedStore.drop() + const hash = await activeStore.add({ content: 'still registered' }) + expect(putListener).toHaveBeenCalledWith( + expect.objectContaining({ + addr: activeStore.address, + hash, + }) + ) } finally { OrbitDbService.events.off('put', putListener) } diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 60e64b8831..1a0edecad8 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -35,6 +35,7 @@ import { LFAIdentities } from './identity/lfa/lfa-identity.service' export class OrbitDbService { private orbitDbInstance: OrbitDBType | undefined = undefined private stores: Record = {} + private storeAliases: Record = {} private orbitDbUpdateListenerAttached = false public identities: LFAIdentities | undefined = undefined public static readonly events = new EventEmitter() @@ -47,7 +48,7 @@ export class OrbitDbService { return } - const store = this.stores[entry.id] + const store = this.getStore(entry.id) if (store == null) { this.logger.warn('Skipping OrbitDB put fanout for local entry without an open store', { storeId: entry.id, @@ -56,7 +57,112 @@ export class OrbitDbService { return } - OrbitDbService.events.emit('put', logEntryToLogUpdate(entry, store.address, store.meta?.['teamId'])) + const storeAddress = this.normalizeStoreIdentifier((store as { address?: unknown }).address) ?? entry.id + OrbitDbService.events.emit('put', logEntryToLogUpdate(entry, storeAddress, store.meta?.['teamId'])) + } + + private normalizeStoreIdentifier(value: unknown): string | undefined { + if (typeof value === 'string') { + return value.length > 0 ? value : undefined + } + if (value == null) { + return undefined + } + const asString = String(value) + return asString.length > 0 && asString !== '[object Object]' ? asString : undefined + } + + private getStoreIdentifierAliases(value: unknown): string[] { + const normalized = this.normalizeStoreIdentifier(value) + if (normalized == null) { + return [] + } + + const aliases = new Set([normalized]) + if (normalized.startsWith('/orbitdb/')) { + aliases.add(normalized.slice('/orbitdb/'.length)) + } else if (normalized.startsWith('zdpu')) { + aliases.add(`/orbitdb/${normalized}`) + } + return Array.from(aliases) + } + + private getStore(address: string): DatabaseType | undefined { + const normalizedAddress = this.normalizeStoreIdentifier(address) + if (normalizedAddress == null) { + return undefined + } + const canonicalAddress = this.storeAliases[normalizedAddress] ?? normalizedAddress + return this.stores[canonicalAddress] + } + + private getStoreAliases(requestedAddress: string, store: DatabaseType): string[] { + const storeWithIds = store as DatabaseType & { + address?: unknown + id?: unknown + name?: unknown + } + return Array.from( + new Set( + [requestedAddress, storeWithIds.address, storeWithIds.id, storeWithIds.name].flatMap(alias => + this.getStoreIdentifierAliases(alias) + ) + ) + ) + } + + private registerStore(requestedAddress: string, store: DatabaseType): string[] { + const storeAddress = this.normalizeStoreIdentifier((store as { address?: unknown }).address) ?? requestedAddress + const aliases = Array.from(new Set([storeAddress, ...this.getStoreAliases(requestedAddress, store)])) + this.stores[storeAddress] = store + for (const alias of aliases) { + this.storeAliases[alias] = storeAddress + } + return aliases + } + + private unregisterStore(storeAddress: string): void { + const normalizedAddress = this.normalizeStoreIdentifier(storeAddress) ?? storeAddress + delete this.stores[normalizedAddress] + for (const [alias, canonicalAddress] of Object.entries(this.storeAliases)) { + if (canonicalAddress === normalizedAddress) { + delete this.storeAliases[alias] + } + } + } + + private attachStoreLifecycleUnregister(store: DatabaseType, storeAddress: string): void { + const storeWithLifecycle = store as unknown as { + close?: (...args: any[]) => Promise + drop?: (...args: any[]) => Promise + __quietOrbitDbLifecycleWrapped?: boolean + } + if (storeWithLifecycle.__quietOrbitDbLifecycleWrapped) { + return + } + storeWithLifecycle.__quietOrbitDbLifecycleWrapped = true + + if (typeof storeWithLifecycle.close === 'function') { + const close = storeWithLifecycle.close.bind(store) + storeWithLifecycle.close = async (...args: any[]) => { + try { + await close(...args) + } finally { + this.unregisterStore(storeAddress) + } + } + } + + if (typeof storeWithLifecycle.drop === 'function') { + const drop = storeWithLifecycle.drop.bind(store) + storeWithLifecycle.drop = async (...args: any[]) => { + try { + await drop(...args) + } finally { + this.unregisterStore(storeAddress) + } + } + } } constructor( @@ -140,13 +246,16 @@ export class OrbitDbService { if (this.orbitDbInstance == undefined) { throw new Error('OrbitDB instance is not initialized. Call create() first.') } - if (address && !this.stores[address]) { - throw new Error(`No store found for address ${address}. Cannot start sync.`) + if (address) { + const store = this.getStore(address) + if (store == null) { + throw new Error(`No store found for address ${address}. Cannot start sync.`) + } + await store.sync?.start?.() + this.logger.info(`Started sync for store ${store.address}`) + return } for (const store of Object.values(this.stores)) { - if (address && store.address !== address) { - continue - } await store.sync?.start?.() this.logger.info(`Started sync for store ${store.address}`) } @@ -156,13 +265,16 @@ export class OrbitDbService { if (this.orbitDbInstance == undefined) { throw new Error('OrbitDB instance is not initialized. Call create() first.') } - if (address && !this.stores[address]) { - throw new Error(`No store found for address ${address}. Cannot stop sync.`) + if (address) { + const store = this.getStore(address) + if (store == null) { + throw new Error(`No store found for address ${address}. Cannot stop sync.`) + } + await store.sync?.stop?.() + this.logger.info(`Stopped sync for store ${store.address}`) + return } for (const store of Object.values(this.stores)) { - if (address && store.address !== address) { - continue - } await store.sync?.stop?.() this.logger.info(`Stopped sync for store ${store.address}`) } @@ -179,6 +291,7 @@ export class OrbitDbService { } } this.stores = {} + this.storeAliases = {} this.orbitDbInstance = undefined this.identities = undefined } @@ -188,14 +301,14 @@ export class OrbitDbService { throw new Error('OrbitDB instance is not initialized. Call create() first.') } const store = await this.orbitDbInstance.open(address, options) - const storeAddress = (store as { address: string }).address - this.stores[storeAddress] = store - store.events?.on?.('close', () => { - delete this.stores[storeAddress] - }) + const storeAddress = this.normalizeStoreIdentifier((store as { address?: unknown }).address) ?? address + const aliases = this.registerStore(address, store) + this.attachStoreLifecycleUnregister(store, storeAddress) this.logger.info(`Opened OrbitDB store ${address} at address: ${storeAddress}`) - await this.joinPendingHeads(storeAddress) + for (const alias of aliases) { + await this.joinPendingHeads(alias) + } return store } @@ -229,7 +342,7 @@ export class OrbitDbService { return } - const store = this.stores[address] + const store = this.getStore(address) if (!store) { return } @@ -309,7 +422,10 @@ export class OrbitDbService { } const entries: LogEntry[] = [] - const store = this.stores[address] + const store = this.getStore(address) + if (store == null) { + throw new Error(`No store found for address ${address}. Cannot get log entries.`) + } for (const hash of hashes) { try { const entry = await (store.log as LogType).get(hash) diff --git a/packages/state-manager/src/utils/functions/dates/formatMessageDisplayDate.test.ts b/packages/state-manager/src/utils/functions/dates/formatMessageDisplayDate.test.ts index a579e7725f..9674dd4978 100644 --- a/packages/state-manager/src/utils/functions/dates/formatMessageDisplayDate.test.ts +++ b/packages/state-manager/src/utils/functions/dates/formatMessageDisplayDate.test.ts @@ -84,4 +84,21 @@ describe('Format message display date', () => { const result = formatMessageDisplayDate(specificDate) expect(result).toBe('Mar 5, 2004') }) + + it('falls back to English formatting when LC_ALL is not a valid locale', () => { + const previousLocale = process.env.LC_ALL + process.env.LC_ALL = 'C.UTF-8' + + try { + const createdAt = DateTime.now().minus({ days: 2 }).toSeconds() + const result = formatMessageDisplayDate(createdAt) + expect(result).toBe('Wednesday') + } finally { + if (previousLocale === undefined) { + delete process.env.LC_ALL + } else { + process.env.LC_ALL = previousLocale + } + } + }) }) diff --git a/packages/state-manager/src/utils/functions/dates/formatMessageDisplayDate.ts b/packages/state-manager/src/utils/functions/dates/formatMessageDisplayDate.ts index 50857b7331..b0e8cb7a78 100644 --- a/packages/state-manager/src/utils/functions/dates/formatMessageDisplayDate.ts +++ b/packages/state-manager/src/utils/functions/dates/formatMessageDisplayDate.ts @@ -1,12 +1,24 @@ import { DateTime } from 'luxon' +const FALLBACK_LOCALE = 'en-US' + +const getSystemLocale = (): string => { + const locale = process.env.LC_ALL || process.env.LC_TIME || process.env.LANG || FALLBACK_LOCALE + const normalizedLocale = locale.split('.')[0].replace('_', '-') + + try { + return Intl.DateTimeFormat.supportedLocalesOf([normalizedLocale]).length > 0 ? normalizedLocale : FALLBACK_LOCALE + } catch { + return FALLBACK_LOCALE + } +} + export const formatMessageDisplayDate = (createdAt: number): string => { // Extract timezone offset from native Date API const tzOffsetHours = -new Date().getTimezoneOffset() / 60 const formattedOffset = `UTC${tzOffsetHours >= 0 ? '+' : ''}${tzOffsetHours}` - const LC = process.env.LC_ALL || 'en_US.UTF-8' - const locale = LC.split('_')[0] + const locale = getSystemLocale() const messageDate = DateTime.fromSeconds(createdAt).setZone(formattedOffset).setLocale(locale) const now = DateTime.now().setZone(formattedOffset).setLocale(locale) const diffInDays = now.startOf('day').diff(messageDate.startOf('day'), 'days').days From c37925669e5b103deb732977401c779afb9bf070 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 2 Jul 2026 12:28:30 -0400 Subject: [PATCH 16/21] refactor(sidebar): enhance waitForChannels method for improved channel validation --- packages/e2e-tests/src/selectors.ts | 16 ++++++--- .../multipleClients.privateChannels.test.ts | 35 ++++++++++++------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/e2e-tests/src/selectors.ts b/packages/e2e-tests/src/selectors.ts index bc5a6ceb1f..de080878ad 100644 --- a/packages/e2e-tests/src/selectors.ts +++ b/packages/e2e-tests/src/selectors.ts @@ -2257,10 +2257,18 @@ export class Sidebar { ) } - async waitForChannels(channelsNames: Array): Promise { - await this.waitForChannelsNum(channelsNames.length) - const names = await this.getChannelsNames() - expect(names).toEqual(expect.arrayContaining(channelsNames)) + async waitForChannels(channelsNames: Array, timeoutMs: number = 15_000): Promise { + logger.info(`Waiting for channels: ${channelsNames.join(', ')}`) + return (await this.driver.wait( + async () => { + const names = await this.getChannelsNames() + const hasExpectedChannels = channelsNames.every(channelName => names.includes(channelName)) + return names.length === channelsNames.length && hasExpectedChannels ? names : false + }, + timeoutMs, + `Sidebar channels ${channelsNames.join(', ')} couldn't be found within timeout`, + 500 + )) as string[] } async openSettings(): Promise { diff --git a/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts b/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts index be3b4a028e..6ae074ed8c 100644 --- a/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.privateChannels.test.ts @@ -298,8 +298,7 @@ describe('Multiple Clients (Private Channels)', () => { it(`First user's sidebar is missing private channel`, async () => { sidebarUser1 = new Sidebar(users.user1.app.driver) - const channels = await sidebarUser1.getChannelsNames() - expect(channels.length).toBe(1) + const channels = await sidebarUser1.waitForChannels([generalChannelName]) expect(channels).not.toContain(privateChannelName) }) }) @@ -316,8 +315,8 @@ describe('Multiple Clients (Private Channels)', () => { }) it(`Private channel is in user's sidebar`, async () => { - const channels = await sidebarUser1.getChannelsNames() - expect(channels.length).toBe(2) + const channels = await sidebarUser1.waitForChannels([generalChannelName, privateChannelName]) + expect(channels).toHaveLength(2) expect(channels).toContain(privateChannelName) await sidebarUser1.getChannelIcon(privateChannelName, false) }) @@ -359,13 +358,21 @@ describe('Multiple Clients (Private Channels)', () => { sidebarOwner = new Sidebar(users.owner.app.driver) await sidebarOwner.addNewChannel(privateChannel2Name, false) await sidebarOwner.switchChannel(privateChannel2Name, false) - const channels = await sidebarOwner.getChannelList() - expect(channels.length).toEqual(3) + const channels = await sidebarOwner.waitForChannels([ + generalChannelName, + privateChannelName, + privateChannel2Name, + ]) + expect(channels).toHaveLength(3) }) it(`Second private channel is in owner's sidebar`, async () => { - const channels = await sidebarOwner.getChannelsNames() - expect(channels.length).toBe(3) + const channels = await sidebarOwner.waitForChannels([ + generalChannelName, + privateChannelName, + privateChannel2Name, + ]) + expect(channels).toHaveLength(3) expect(channels).toContain(privateChannel2Name) await sidebarOwner.getChannelIcon(privateChannel2Name, false) }) @@ -387,8 +394,8 @@ describe('Multiple Clients (Private Channels)', () => { describe(`Owner Adds User To Second Private Channel`, () => { it(`First user's sidebar is missing private channel`, async () => { sidebarUser1 = new Sidebar(users.user1.app.driver) - const channels = await sidebarUser1.getChannelList() - expect(channels.length).toBe(2) + const channels = await sidebarUser1.waitForChannels([generalChannelName, privateChannelName]) + expect(channels).toHaveLength(2) }) it('Owner adds first user to second private channel', async () => { @@ -402,9 +409,11 @@ describe('Multiple Clients (Private Channels)', () => { }) it(`Second private channel is in user's sidebar`, async () => { - const correctChannelCount = await sidebarUser1.waitForChannelsNum(3) - expect(correctChannelCount).toBeTruthy() - const channels = await sidebarUser1.getChannelsNames() + const channels = await sidebarUser1.waitForChannels([ + generalChannelName, + privateChannelName, + privateChannel2Name, + ]) expect(channels).toHaveLength(3) expect(channels).toContain(privateChannel2Name) await sidebarUser1.getChannelIcon(privateChannel2Name, false) From d19373670318a68cb47fc758ff5c4b2ad8e7d03d Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:12:50 -0400 Subject: [PATCH 17/21] Add tests for wait for subscription saga --- packages/e2e-tests/src/selectors.ts | 4 +- .../createGeneralChannel.saga.test.ts | 1 - .../waitForChannelSubscription.saga.test.ts | 73 +++++++++++++++++++ .../waitForChannelSubscription.saga.ts | 1 + 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.test.ts diff --git a/packages/e2e-tests/src/selectors.ts b/packages/e2e-tests/src/selectors.ts index 3f5d2b6743..57bc74a75f 100644 --- a/packages/e2e-tests/src/selectors.ts +++ b/packages/e2e-tests/src/selectors.ts @@ -661,7 +661,9 @@ export class ChannelContextMenu { ) } - async openMenu(): Promise<{ menuButton: boolean; menuOpened: boolean; iconVisible: boolean }> { + async openMenu( + expectChannelTypeIcon = true + ): Promise<{ menuButton: boolean; menuOpened: boolean; iconVisible: boolean | undefined }> { let menu: WebElement try { menu = await this.driver.wait( diff --git a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts index 7582fb37f1..7ad0bd762b 100644 --- a/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts +++ b/packages/state-manager/src/sagas/publicChannels/createGeneralChannel/createGeneralChannel.saga.test.ts @@ -6,7 +6,6 @@ import { combineReducers } from 'redux' import { expectSaga } from 'redux-saga-test-plan' import { publicChannelsActions } from './../publicChannels.slice' import { createGeneralChannelSaga } from './createGeneralChannel.saga' -import { type communitiesActions } from '../../communities/communities.slice' import { type Community, type Identity } from '@quiet/types' import { createLogger } from '../../../utils/logger' import { getReduxStoreFactory } from '../../../utils/tests/factories' diff --git a/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.test.ts b/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.test.ts new file mode 100644 index 0000000000..2bef4dd6f2 --- /dev/null +++ b/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.test.ts @@ -0,0 +1,73 @@ +import { setupCrypto } from '@quiet/identity' +import { type Store } from '../store.types' +import { prepareStore, testReducers } from '../../utils/tests/prepareStore' +import { type FactoryGirl } from 'factory-girl' +import { combineReducers } from 'redux' +import { expectSaga } from 'redux-saga-test-plan' +import { publicChannelsActions } from './publicChannels.slice' +import { type Community, type Identity } from '@quiet/types' +import { createLogger } from '../../utils/logger' +import { getReduxStoreFactory } from '../../utils/tests/factories' +import { waitForChannelSubscriptionSaga } from './waitForChannelSubscription.saga' +import { publicChannelsSelectors } from './publicChannels.selectors' + +const logger = createLogger('waitForChannelSubscriptionSaga:test') + +describe('waitForChannelSubscriptionSaga', () => { + let store: Store + let factory: FactoryGirl + + let community: Community + let alice: Identity + + beforeAll(async () => { + setupCrypto() + }) + + beforeEach(async () => { + store = prepareStore().store + factory = await getReduxStoreFactory(store) + + community = await factory.create('Community') + + alice = await factory.create('Identity', { + communityId: community.id, + nickname: 'alice', + }) + }) + + test('wait for channel subscription - already subscribed', async () => { + const localGeneralChannel = publicChannelsSelectors.generalChannel(store.getState()) + expect(localGeneralChannel).toBeDefined() + const channelId = localGeneralChannel!.id + await expectSaga(waitForChannelSubscriptionSaga, channelId) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .not.take(publicChannelsActions.setChannelSubscribed) + .run() + }) + + test('wait for channel subscription - not initially subscribed', async () => { + const channelId = 'foobar' + await expectSaga(waitForChannelSubscriptionSaga, channelId) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .take(publicChannelsActions.setChannelSubscribed) + .dispatch(publicChannelsActions.setChannelSubscribed({ channelId: channelId })) + .not.take(publicChannelsActions.setChannelSubscribed) + .run() + }) + + test('wait for channel subscription - ignores setChannelSubscribed for other channel', async () => { + const channelId = 'foobar' + await expectSaga(waitForChannelSubscriptionSaga, channelId) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .take(publicChannelsActions.setChannelSubscribed) + .dispatch(publicChannelsActions.setChannelSubscribed({ channelId: 'abc123' })) + .take(publicChannelsActions.setChannelSubscribed) + .dispatch(publicChannelsActions.setChannelSubscribed({ channelId: channelId })) + .not.take(publicChannelsActions.setChannelSubscribed) + .run() + }) +}) diff --git a/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.ts b/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.ts index 8673f2c2d7..be26c1e635 100644 --- a/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.ts +++ b/packages/state-manager/src/sagas/publicChannels/waitForChannelSubscription.saga.ts @@ -6,6 +6,7 @@ import { publicChannelsActions } from './publicChannels.slice' const logger = createLogger('waitForChannelSubscriptionSaga') export function* waitForChannelSubscriptionSaga(channelId: string): Generator { + logger.info('Checking channel subscription', channelId) const targetChannelSubscribed = publicChannelsSelectors.isChannelSubscribed(channelId) while (!(yield* select(targetChannelSubscribed))) { From caba20a7234eba30741967ddf2964c7f550db79d Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:14:46 -0400 Subject: [PATCH 18/21] Revert e2e test changes --- packages/e2e-tests/src/tests/multipleClients.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/e2e-tests/src/tests/multipleClients.test.ts b/packages/e2e-tests/src/tests/multipleClients.test.ts index 1615a25c20..2d6f2d59fe 100644 --- a/packages/e2e-tests/src/tests/multipleClients.test.ts +++ b/packages/e2e-tests/src/tests/multipleClients.test.ts @@ -334,7 +334,7 @@ describe('Multiple Clients', () => { it('Owner creates second channel', async () => { sidebarOwner = new Sidebar(users.owner.app.driver) // TODO: revert when private channels are added back - await sidebarOwner.addNewChannel(newChannelName, true, false) + await sidebarOwner.addNewChannel(newChannelName, true, true) await sidebarOwner.switchChannel(newChannelName) const channels = await sidebarOwner.getChannelList() expect(channels.length).toEqual(2) @@ -423,7 +423,7 @@ describe('Multiple Clients', () => { it('User can create channel with the same name and is fresh channel', async () => { // TODO: revert when private channels are added back - await sidebarUser1.addNewChannel(newChannelName, true, false) + await sidebarUser1.addNewChannel(newChannelName, true, true) await sidebarUser1.switchChannel(newChannelName) const messages = await secondChannelUser1.getUserMessages(users.user1.username) expect(messages.length).toEqual(1) From c988a1c7e4c23461eb23ccd69af97143b72612d1 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:59:49 -0400 Subject: [PATCH 19/21] Fix test post develop merge --- .../backend/src/nest/storage/channels/channels.service.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/nest/storage/channels/channels.service.spec.ts b/packages/backend/src/nest/storage/channels/channels.service.spec.ts index 6328398171..24f016725c 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.spec.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.spec.ts @@ -134,7 +134,7 @@ describe('ChannelsService', () => { adminChain.lockbox.createInviteLockboxes(invite.seed, salt, RoleName.MEMBER) - const invitedChain = SigChain.createFromInvite(username, invite.seed) + const invitedChain = SigChain.createFromInvite({ seed: invite.seed }) adminChain.invites.admitMemberFromInvite( InviteService.generateProof(invite.seed), invitedChain.user.userName, From cf840fb9b9f3b7bc39ec08e7783b90a3875298dd Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:39:16 -0400 Subject: [PATCH 20/21] Mild refactor to consolidate logic --- .../nest/storage/channels/channels.service.ts | 40 ++++++++----------- packages/types/src/channel.ts | 2 +- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/packages/backend/src/nest/storage/channels/channels.service.ts b/packages/backend/src/nest/storage/channels/channels.service.ts index 7ab8f15643..cf2941d59d 100644 --- a/packages/backend/src/nest/storage/channels/channels.service.ts +++ b/packages/backend/src/nest/storage/channels/channels.service.ts @@ -21,7 +21,7 @@ import { AddMembersChannelStatus, DownloadStatus, RemoveDownloadStatus, - CHANNEL_METADATA_STORE_NAME, + PUBLIC_CHANNEL_METADATA_STORE_NAME, PRIVATE_CHANNEL_METADATA_STORE_NAME, ChannelOperationStatus, } from '@quiet/types' @@ -246,31 +246,25 @@ export class ChannelsService extends EventEmitter { } private async openChannelsDb(): Promise> { - return await this.orbitDbService.open>( - CHANNEL_METADATA_STORE_NAME, - { - sync: false, - Database: KeyValueIndexedValidated(this.validatePublicChannelMetadataEntry.bind(this)), - AccessController: this.channelMetadataAccessController.createAccessControllerFunc({ - write: ['*'], - sigchainService: this.sigchainService, - }), - } - ) + return await this.openMetadataDb(PUBLIC_CHANNEL_METADATA_STORE_NAME, this.validatePublicChannelMetadataEntry) } private async openPrivateChannelsDb(): Promise> { - return await this.orbitDbService.open>( - PRIVATE_CHANNEL_METADATA_STORE_NAME, - { - sync: false, - Database: KeyValueIndexedValidated(this.validatePrivateChannelMetadataEntry.bind(this)), - AccessController: this.channelMetadataAccessController.createAccessControllerFunc({ - write: ['*'], - sigchainService: this.sigchainService, - }), - } - ) + return await this.openMetadataDb(PRIVATE_CHANNEL_METADATA_STORE_NAME, this.validatePrivateChannelMetadataEntry) + } + + private async openMetadataDb( + dbName: string, + validateFunc: typeof this.validatePublicChannelMetadataEntry | typeof this.validatePrivateChannelMetadataEntry + ): Promise> { + return await this.orbitDbService.open>(dbName, { + sync: false, + Database: KeyValueIndexedValidated(validateFunc.bind(this)), + AccessController: this.channelMetadataAccessController.createAccessControllerFunc({ + write: ['*'], + sigchainService: this.sigchainService, + }), + }) } public encryptChannelEntry(payload: PublicChannel): EncryptedAndSignedPayload { diff --git a/packages/types/src/channel.ts b/packages/types/src/channel.ts index 58ca4ac049..82f582267a 100644 --- a/packages/types/src/channel.ts +++ b/packages/types/src/channel.ts @@ -6,7 +6,7 @@ export const PROFILE_PHOTO_CHANNEL_ID = '__profile-photo__' export const INITIAL_CURRENT_CHANNEL_ID = 'initialcurrentChannelId' -export const CHANNEL_METADATA_STORE_NAME = 'public-channels' +export const PUBLIC_CHANNEL_METADATA_STORE_NAME = 'public-channels' export const PRIVATE_CHANNEL_METADATA_STORE_NAME = 'private-channels' export interface PublicChannel { From ddcb229efe1b3d6d8f326420d0257f2d4d30d86a Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:52:25 -0400 Subject: [PATCH 21/21] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0be68c814c..6d63eefa72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ * Include team ID and createdAt in message encryption and validate on consume [#3304](https://github.com/TryQuiet/quiet/issues/3304) * Require team ID on invite links, use team ID for all chain operations, and hash team name on sigchains [#3296](https://github.com/TryQuiet/quiet/issues/3296) * Use randomly generated Base58 usernames on sigchain [#3321](https://github.com/TryQuiet/quiet/issues/3321) +* Tighten controls on channel metadata DB operations, move private channels to separate metadata DB [#3329](https://github.com/TryQuiet/quiet/issues/3329) ## [7.3.0]