From 1a76ebcc809d7ab4ca364e6aadc275934daee516 Mon Sep 17 00:00:00 2001 From: Alexey Zinoviev Date: Wed, 12 Feb 2025 19:57:04 +0400 Subject: [PATCH] UBERF-8425: Account DB unit tests (#7994) Signed-off-by: Alexey Zinoviev --- server/account/src/__tests__/mongo.test.ts | 686 +++++++++++++++++- server/account/src/__tests__/postgres.test.ts | 615 ++++++++++++++++ server/account/src/collections/mongo.ts | 10 +- 3 files changed, 1307 insertions(+), 4 deletions(-) create mode 100644 server/account/src/__tests__/postgres.test.ts diff --git a/server/account/src/__tests__/mongo.test.ts b/server/account/src/__tests__/mongo.test.ts index 97e54f16f0..4faf5d8104 100644 --- a/server/account/src/__tests__/mongo.test.ts +++ b/server/account/src/__tests__/mongo.test.ts @@ -13,10 +13,447 @@ // limitations under the License. // /* eslint-disable @typescript-eslint/unbound-method */ -import { type WorkspaceUuid } from '@hcengineering/core' -import { MongoDbCollection, WorkspaceStatusMongoDbCollection } from '../collections/mongo' +import { Collection, Db } from 'mongodb' +import { type WorkspaceMode, type WorkspaceUuid, type PersonUuid, SocialIdType, AccountRole } from '@hcengineering/core' +import { + MongoDbCollection, + AccountMongoDbCollection, + SocialIdMongoDbCollection, + WorkspaceStatusMongoDbCollection, + MongoAccountDB +} from '../collections/mongo' import { WorkspaceInfoWithStatus, WorkspaceStatus } from '../types' +interface TestWorkspace { + _id?: string + uuid: WorkspaceUuid + mode: WorkspaceMode + name: string + processingAttempts?: number + lastProcessingTime?: number +} + +describe('MongoDbCollection', () => { + let mockCollection: Partial> + let mockDb: Partial + let collection: MongoDbCollection + + beforeEach(() => { + mockCollection = { + find: jest.fn(), + findOne: jest.fn(), + insertOne: jest.fn(), + updateOne: jest.fn(), + deleteMany: jest.fn(), + createIndex: jest.fn(), + dropIndex: jest.fn(), + listIndexes: jest.fn() + } + + mockDb = { + collection: jest.fn().mockReturnValue(mockCollection) + } + + collection = new MongoDbCollection('workspace', mockDb as Db, 'uuid') + }) + + describe('find', () => { + it('should find documents and remove _id field', async () => { + const mockDocs = [ + { _id: 'id1', uuid: 'ws1' as WorkspaceUuid, mode: 'active' as const, name: 'Workspace 1' }, + { _id: 'id2', uuid: 'ws2' as WorkspaceUuid, mode: 'active' as const, name: 'Workspace 2' } + ] + + // Define type for our mock cursor + interface MockCursor { + sort: jest.Mock + limit: jest.Mock + map: jest.Mock + toArray: jest.Mock + transform?: (doc: any) => any + } + + // Create a mock cursor that properly implements the map functionality + const mockCursor: MockCursor = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + map: jest.fn().mockImplementation(function (this: MockCursor, callback) { + this.transform = callback + return this + }), + toArray: jest.fn().mockImplementation(function (this: MockCursor) { + return Promise.resolve(this.transform != null ? mockDocs.map(this.transform) : mockDocs) + }) + } + + ;(mockCollection.find as jest.Mock).mockReturnValue(mockCursor) + + const result = await collection.find({ mode: 'active' as const }) + + expect(mockCollection.find).toHaveBeenCalledWith({ mode: 'active' }) + expect(result).toEqual([ + { uuid: 'ws1' as WorkspaceUuid, mode: 'active' as const, name: 'Workspace 1' }, + { uuid: 'ws2' as WorkspaceUuid, mode: 'active' as const, name: 'Workspace 2' } + ]) + }) + + it('should apply sort and limit', async () => { + const mockFind = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([]), + map: jest.fn().mockReturnThis() + } + ;(mockCollection.find as jest.Mock).mockReturnValue(mockFind) + + await collection.find({ mode: 'active' as const }, { name: 'ascending', processingAttempts: 'descending' }, 10) + + expect(mockFind.sort).toHaveBeenCalledWith({ + name: 'ascending', + processingAttempts: 'descending' + }) + expect(mockFind.limit).toHaveBeenCalledWith(10) + }) + }) + + describe('findOne', () => { + it('should find single document and remove _id field', async () => { + const mockDoc = { + _id: 'id1', + uuid: 'ws1' as WorkspaceUuid, + mode: 'active' as const, + name: 'Workspace 1' + } + const expectedDoc = { ...mockDoc } + delete (expectedDoc as any)._id + ;(mockCollection.findOne as jest.Mock).mockResolvedValue(mockDoc) + + const result = await collection.findOne({ uuid: 'ws1' as WorkspaceUuid }) + + expect(mockCollection.findOne).toHaveBeenCalledWith({ uuid: 'ws1' }) + expect(result).toEqual(expectedDoc) + }) + }) + + describe('insertOne', () => { + it('should insert document with generated UUID', async () => { + const doc = { + mode: 'pending-creation' as const, + name: 'New Workspace' + } + + await collection.insertOne(doc) + + expect(mockCollection.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ + mode: 'pending-creation', + name: 'New Workspace' + }) + ) + + // Get the actual document passed to insertOne + const insertedDoc = (mockCollection.insertOne as jest.Mock).mock.calls[0][0] + + // Check that uuid was generated + expect(insertedDoc.uuid).toBeDefined() + // Check that _id matches uuid + expect(insertedDoc._id).toBe(insertedDoc.uuid) + }) + + it('should use provided UUID if available', async () => { + const doc = { + uuid: 'custom-uuid' as WorkspaceUuid, + mode: 'pending-creation' as const, + name: 'New Workspace' + } + + await collection.insertOne(doc) + + expect(mockCollection.insertOne).toHaveBeenCalledWith({ + ...doc, + _id: 'custom-uuid' + }) + }) + }) + + describe('updateOne', () => { + it('should handle simple field updates', async () => { + await collection.updateOne({ uuid: 'ws1' as WorkspaceUuid }, { mode: 'creating' as const }) + + expect(mockCollection.updateOne).toHaveBeenCalledWith({ uuid: 'ws1' }, { $set: { mode: 'creating' } }) + }) + + it('should handle increment operations', async () => { + await collection.updateOne( + { uuid: 'ws1' as WorkspaceUuid }, + { + $inc: { processingAttempts: 1 }, + mode: 'upgrading' as const + } + ) + + expect(mockCollection.updateOne).toHaveBeenCalledWith( + { uuid: 'ws1' }, + { + $inc: { processingAttempts: 1 }, + $set: { mode: 'upgrading' } + } + ) + }) + }) + + describe('deleteMany', () => { + it('should delete documents matching query', async () => { + await collection.deleteMany({ mode: 'deleted' as const }) + + expect(mockCollection.deleteMany).toHaveBeenCalledWith({ mode: 'deleted' }) + }) + }) + + describe('ensureIndices', () => { + it('should create new indices', async () => { + ;(mockCollection.listIndexes as jest.Mock).mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ key: { _id: 1 }, name: '_id_' }]) + }) + + const indices = [ + { + key: { uuid: 1 }, + options: { unique: true, name: 'uuid_1' } + }, + { + key: { mode: 1 }, + options: { name: 'mode_1' } + } + ] + + await collection.ensureIndices(indices) + + expect(mockCollection.createIndex).toHaveBeenCalledTimes(2) + expect(mockCollection.createIndex).toHaveBeenCalledWith({ uuid: 1 }, { unique: true, name: 'uuid_1' }) + expect(mockCollection.createIndex).toHaveBeenCalledWith({ mode: 1 }, { name: 'mode_1' }) + }) + + it('should drop unused indices', async () => { + ;(mockCollection.listIndexes as jest.Mock).mockReturnValue({ + toArray: jest.fn().mockResolvedValue([ + { key: { _id: 1 }, name: '_id_' }, + { key: { oldField: 1 }, name: 'oldField_1' } + ]) + }) + + const indices = [ + { + key: { uuid: 1 }, + options: { unique: true, name: 'uuid_1' } + } + ] + + await collection.ensureIndices(indices) + + expect(mockCollection.dropIndex).toHaveBeenCalledWith('oldField_1') + expect(mockCollection.createIndex).toHaveBeenCalledWith({ uuid: 1 }, { unique: true, name: 'uuid_1' }) + }) + }) +}) + +describe('AccountMongoDbCollection', () => { + let mockCollection: any + let mockDb: any + let collection: AccountMongoDbCollection + + beforeEach(() => { + mockCollection = { + find: jest.fn(), + findOne: jest.fn(), + insertOne: jest.fn(), + updateOne: jest.fn() + } + + mockDb = { + collection: jest.fn().mockReturnValue(mockCollection) + } + + collection = new AccountMongoDbCollection(mockDb) + }) + + describe('find', () => { + // Define type for our mock cursor + interface MockCursor { + sort: jest.Mock + limit: jest.Mock + map: jest.Mock + toArray: jest.Mock + transform?: (doc: any) => any + } + + it('should convert Buffer fields in found documents', async () => { + const mockDocs = [ + { + _id: 'id1', + uuid: 'acc1' as PersonUuid, + hash: { buffer: new Uint8Array([1, 2, 3]) }, + salt: { buffer: new Uint8Array([4, 5, 6]) } + } + ] + + const mockCursor: MockCursor = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + map: jest.fn().mockImplementation(function (this: MockCursor, callback) { + this.transform = callback + return this + }), + toArray: jest.fn().mockImplementation(function (this: MockCursor) { + return Promise.resolve(this.transform != null ? mockDocs.map(this.transform) : mockDocs) + }) + } + + mockCollection.find.mockReturnValue(mockCursor) + + const results = await collection.find({ uuid: 'acc1' as PersonUuid }) + + expect(results[0].hash).toBeInstanceOf(Buffer) + expect(results[0].salt).toBeInstanceOf(Buffer) + expect(Buffer.from(results[0].hash as any).toString('hex')).toBe(Buffer.from([1, 2, 3]).toString('hex')) + expect(Buffer.from(results[0].salt as any).toString('hex')).toBe(Buffer.from([4, 5, 6]).toString('hex')) + }) + + it('should handle null hash and salt in found documents', async () => { + const mockDocs = [ + { + _id: 'id1', + uuid: 'acc1' as PersonUuid, + hash: null, + salt: null + } + ] + + const mockCursor: MockCursor = { + sort: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + map: jest.fn().mockImplementation(function (this: MockCursor, callback) { + this.transform = callback + return this + }), + toArray: jest.fn().mockImplementation(function (this: MockCursor) { + return Promise.resolve(this.transform != null ? mockDocs.map(this.transform) : mockDocs) + }) + } + + mockCollection.find.mockReturnValue(mockCursor) + + const results = await collection.find({ uuid: 'acc1' as PersonUuid }) + + expect(results[0].hash).toBeNull() + expect(results[0].salt).toBeNull() + }) + }) + + describe('findOne', () => { + it('should convert Buffer fields in found document', async () => { + const mockDoc = { + _id: 'id1', + uuid: 'acc1' as PersonUuid, + hash: { buffer: new Uint8Array([1, 2, 3]) }, + salt: { buffer: new Uint8Array([4, 5, 6]) } + } + + mockCollection.findOne.mockResolvedValue(mockDoc) + + const result = await collection.findOne({ uuid: 'acc1' as PersonUuid }) + + expect(result?.hash).toBeInstanceOf(Buffer) + expect(result?.salt).toBeInstanceOf(Buffer) + expect(Buffer.from(result?.hash as any).toString('hex')).toBe(Buffer.from([1, 2, 3]).toString('hex')) + expect(Buffer.from(result?.salt as any).toString('hex')).toBe(Buffer.from([4, 5, 6]).toString('hex')) + }) + + it('should handle null hash and salt in found document', async () => { + const mockDoc = { + _id: 'id1', + uuid: 'acc1' as PersonUuid, + hash: null, + salt: null + } + + mockCollection.findOne.mockResolvedValue(mockDoc) + + const result = await collection.findOne({ uuid: 'acc1' as PersonUuid }) + + expect(result?.hash).toBeNull() + expect(result?.salt).toBeNull() + }) + + it('should handle null result', async () => { + mockCollection.findOne.mockResolvedValue(null) + + const result = await collection.findOne({ uuid: 'non-existent' as PersonUuid }) + + expect(result).toBeNull() + }) + }) +}) + +describe('SocialIdMongoDbCollection', () => { + let mockCollection: any + let mockDb: any + let collection: SocialIdMongoDbCollection + + beforeEach(() => { + mockCollection = { + insertOne: jest.fn() + } + + mockDb = { + collection: jest.fn().mockReturnValue(mockCollection) + } + + collection = new SocialIdMongoDbCollection(mockDb) + }) + + describe('insertOne', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should throw error if type is missing', async () => { + const socialId = { + value: 'test@example.com', + personUuid: 'person1' as PersonUuid + } + + await expect(collection.insertOne(socialId)).rejects.toThrow('Type and value are required') + }) + + it('should throw error if value is missing', async () => { + const socialId = { + type: SocialIdType.EMAIL, + personUuid: 'person1' as PersonUuid + } + + await expect(collection.insertOne(socialId)).rejects.toThrow('Type and value are required') + }) + + it('should generate key', async () => { + const socialId = { + type: SocialIdType.EMAIL, + value: 'test@example.com', + personUuid: 'person1' as PersonUuid + } + + await collection.insertOne(socialId) + + expect(mockCollection.insertOne).toHaveBeenCalledWith( + expect.objectContaining({ + ...socialId, + key: 'email:test@example.com' + }) + ) + }) + }) +}) + describe('WorkspaceStatusMongoDbCollection', () => { let mockWsCollection: MongoDbCollection let wsStatusCollection: WorkspaceStatusMongoDbCollection @@ -224,3 +661,248 @@ describe('WorkspaceStatusMongoDbCollection', () => { }) }) }) + +describe('MongoAccountDB', () => { + let mockDb: any + let accountDb: MongoAccountDB + let mockAccount: any + let mockWorkspace: any + let mockWorkspaceMembers: any + let mockWorkspaceStatus: any + + beforeEach(() => { + mockDb = {} + + // Create mock collections with jest.fn() + mockAccount = { + updateOne: jest.fn(), + ensureIndices: jest.fn() + } + + mockWorkspace = { + updateOne: jest.fn(), + insertOne: jest.fn(), + find: jest.fn(), + ensureIndices: jest.fn() + } + + mockWorkspaceMembers = { + insertOne: jest.fn(), + deleteMany: jest.fn(), + updateOne: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + ensureIndices: jest.fn() + } + + mockWorkspaceStatus = { + insertOne: jest.fn() + } + + accountDb = new MongoAccountDB(mockDb) + + // Override the getters to return our mocks + Object.defineProperties(accountDb, { + account: { get: () => mockAccount }, + workspace: { get: () => mockWorkspace }, + workspaceMembers: { get: () => mockWorkspaceMembers }, + workspaceStatus: { get: () => mockWorkspaceStatus } + }) + }) + + describe('init', () => { + it('should create required indices', async () => { + await accountDb.init() + + // Verify account indices + expect(accountDb.account.ensureIndices).toHaveBeenCalledWith([ + { + key: { uuid: 1 }, + options: { unique: true, name: 'hc_account_account_uuid_1' } + } + ]) + + // Verify workspace indices + expect(accountDb.workspace.ensureIndices).toHaveBeenCalledWith([ + { + key: { uuid: 1 }, + options: { + unique: true, + name: 'hc_account_workspace_uuid_1' + } + }, + { + key: { url: 1 }, + options: { + unique: true, + name: 'hc_account_workspace_url_1' + } + } + ]) + + // Verify workspace members indices + expect(accountDb.workspaceMembers.ensureIndices).toHaveBeenCalledWith([ + { + key: { workspaceUuid: 1 }, + options: { + name: 'hc_account_workspace_members_workspace_uuid_1' + } + }, + { + key: { accountUuid: 1 }, + options: { + name: 'hc_account_workspace_members_account_uuid_1' + } + } + ]) + }) + }) + + describe('workspace operations', () => { + const accountId = 'acc1' as PersonUuid + const workspaceId = 'ws1' as WorkspaceUuid + const role = AccountRole.Owner + + describe('assignWorkspace', () => { + it('should insert workspace member', async () => { + await accountDb.assignWorkspace(accountId, workspaceId, role) + + expect(accountDb.workspaceMembers.insertOne).toHaveBeenCalledWith({ + workspaceUuid: workspaceId, + accountUuid: accountId, + role + }) + }) + }) + + describe('unassignWorkspace', () => { + it('should delete workspace member', async () => { + await accountDb.unassignWorkspace(accountId, workspaceId) + + expect(accountDb.workspaceMembers.deleteMany).toHaveBeenCalledWith({ + workspaceUuid: workspaceId, + accountUuid: accountId + }) + }) + }) + + describe('updateWorkspaceRole', () => { + it('should update member role', async () => { + await accountDb.updateWorkspaceRole(accountId, workspaceId, role) + + expect(accountDb.workspaceMembers.updateOne).toHaveBeenCalledWith( + { + workspaceUuid: workspaceId, + accountUuid: accountId + }, + { role } + ) + }) + }) + + describe('getWorkspaceRole', () => { + it('should return role when member exists', async () => { + ;(accountDb.workspaceMembers.findOne as jest.Mock).mockResolvedValue({ role }) + + const result = await accountDb.getWorkspaceRole(accountId, workspaceId) + + expect(result).toBe(role) + }) + + it('should return null when member does not exist', async () => { + ;(accountDb.workspaceMembers.findOne as jest.Mock).mockResolvedValue(null) + + const result = await accountDb.getWorkspaceRole(accountId, workspaceId) + + expect(result).toBeNull() + }) + }) + + describe('getWorkspaceMembers', () => { + it('should return mapped member info', async () => { + const members = [ + { accountUuid: 'acc1' as PersonUuid, role: AccountRole.Owner }, + { accountUuid: 'acc2' as PersonUuid, role: AccountRole.Maintainer } + ] + + ;(accountDb.workspaceMembers.find as jest.Mock).mockResolvedValue(members) + + const result = await accountDb.getWorkspaceMembers(workspaceId) + + expect(result).toEqual([ + { person: 'acc1', role: AccountRole.Owner }, + { person: 'acc2', role: AccountRole.Maintainer } + ]) + }) + }) + + describe('getAccountWorkspaces', () => { + it('should return workspaces for account', async () => { + const members = [{ workspaceUuid: 'ws1' as WorkspaceUuid }, { workspaceUuid: 'ws2' as WorkspaceUuid }] + const workspaces = [ + { uuid: 'ws1', name: 'Workspace 1' }, + { uuid: 'ws2', name: 'Workspace 2' } + ] + + ;(accountDb.workspaceMembers.find as jest.Mock).mockResolvedValue(members) + ;(accountDb.workspace.find as jest.Mock).mockResolvedValue(workspaces) + + const result = await accountDb.getAccountWorkspaces(accountId) + + expect(result).toEqual(workspaces) + expect(accountDb.workspace.find).toHaveBeenCalledWith({ + uuid: { $in: ['ws1', 'ws2'] } + }) + }) + }) + + describe('createWorkspace', () => { + it('should create workspace and status', async () => { + const workspaceData = { + name: 'New Workspace', + url: 'new-workspace' + } + const statusData = { + mode: 'active' as const, + versionMajor: 1, + versionMinor: 0, + versionPatch: 0, + isDisabled: false + } + + ;(accountDb.workspace.insertOne as jest.Mock).mockResolvedValue('ws1') + + const result = await accountDb.createWorkspace(workspaceData, statusData) + + expect(result).toBe('ws1') + expect(accountDb.workspace.insertOne).toHaveBeenCalledWith(workspaceData) + expect(accountDb.workspaceStatus.insertOne).toHaveBeenCalledWith({ + workspaceUuid: 'ws1', + ...statusData + }) + }) + }) + }) + + describe('password operations', () => { + const accountId = 'acc1' as PersonUuid + const passwordHash = Buffer.from('hash') + const salt = Buffer.from('salt') + + describe('setPassword', () => { + it('should update account with password hash and salt', async () => { + await accountDb.setPassword(accountId, passwordHash, salt) + + expect(accountDb.account.updateOne).toHaveBeenCalledWith({ uuid: accountId }, { hash: passwordHash, salt }) + }) + }) + + describe('resetPassword', () => { + it('should reset password hash and salt to null', async () => { + await accountDb.resetPassword(accountId) + + expect(accountDb.account.updateOne).toHaveBeenCalledWith({ uuid: accountId }, { hash: null, salt: null }) + }) + }) + }) +}) diff --git a/server/account/src/__tests__/postgres.test.ts b/server/account/src/__tests__/postgres.test.ts new file mode 100644 index 0000000000..a2412e6a32 --- /dev/null +++ b/server/account/src/__tests__/postgres.test.ts @@ -0,0 +1,615 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { + AccountRole, + Data, + Version, + type PersonUuid, + type WorkspaceMode, + type WorkspaceUuid +} from '@hcengineering/core' +import { AccountPostgresDbCollection, PostgresAccountDB, PostgresDbCollection } from '../collections/postgres' +import { Sql } from 'postgres' + +interface TestWorkspace { + uuid: WorkspaceUuid + mode: WorkspaceMode + name: string + processingAttempts?: number + lastProcessingTime?: number +} + +describe('PostgresDbCollection', () => { + let mockClient: any + let collection: PostgresDbCollection + + beforeEach(() => { + mockClient = { + unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result + } + + collection = new PostgresDbCollection('workspace', mockClient as Sql, 'uuid') + }) + + describe('getTableName', () => { + it('should return table name with default namespace', () => { + expect(collection.getTableName()).toBe('global_account.workspace') + }) + + it('should return table name without namespace when ns is empty', () => { + collection = new PostgresDbCollection('workspace', mockClient as Sql, 'uuid', '') + expect(collection.getTableName()).toBe('workspace') + }) + + it('should return table name with custom namespace when ns is provided', () => { + collection = new PostgresDbCollection( + 'workspace', + mockClient as Sql, + 'uuid', + 'custom_account' + ) + expect(collection.getTableName()).toBe('custom_account.workspace') + }) + }) + + describe('find', () => { + it('should generate simple query', async () => { + await collection.find({ mode: 'active' as const }) + + expect(mockClient.unsafe).toHaveBeenCalledWith('SELECT * FROM global_account.workspace WHERE "mode" = $1', [ + 'active' + ]) + }) + + it('should handle $in operator', async () => { + await collection.find({ + mode: { $in: ['active' as const, 'creating' as const] } + }) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'SELECT * FROM global_account.workspace WHERE "mode" IN ($1, $2)', + ['active', 'creating'] + ) + }) + + it('should handle comparison operators', async () => { + await collection.find({ + processingAttempts: { $lt: 3 }, + lastProcessingTime: { $gt: 1000 } + }) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'SELECT * FROM global_account.workspace WHERE "processing_attempts" < $1 AND "last_processing_time" > $2', + [3, 1000] + ) + }) + + it('should apply sort', async () => { + await collection.find({ mode: 'active' as const }, { lastProcessingTime: 'descending', name: 'ascending' }) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'SELECT * FROM global_account.workspace WHERE "mode" = $1 ORDER BY "last_processing_time" DESC, "name" ASC', + ['active'] + ) + }) + + it('should apply limit', async () => { + await collection.find({ mode: 'active' as const }, undefined, 10) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'SELECT * FROM global_account.workspace WHERE "mode" = $1 LIMIT 10', + ['active'] + ) + }) + + it('should convert snake_case to camelCase in results', async () => { + mockClient.unsafe.mockResolvedValue([ + { + uuid: 'ws1', + mode: 'active', + name: 'Test', + processing_attempts: 1, + last_processing_time: 1000 + } + ]) + + const result = await collection.find({}) + + expect(result).toEqual([ + { + uuid: 'ws1', + mode: 'active', + name: 'Test', + processingAttempts: 1, + lastProcessingTime: 1000 + } + ]) + }) + }) + + describe('findOne', () => { + it('should use find with limit 1', async () => { + await collection.findOne({ uuid: 'ws1' as WorkspaceUuid }) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'SELECT * FROM global_account.workspace WHERE "uuid" = $1 LIMIT 1', + ['ws1'] + ) + }) + }) + + describe('insertOne', () => { + it('should generate insert query with returning', async () => { + mockClient.unsafe.mockResolvedValue([{ uuid: 'ws1' }]) + + const doc = { + mode: 'pending-creation' as const, + name: 'New Workspace' + } + + await collection.insertOne(doc) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'INSERT INTO global_account.workspace ("mode", "name") VALUES ($1, $2) RETURNING *', + ['pending-creation', 'New Workspace'] + ) + }) + }) + + describe('updateOne', () => { + it('should handle simple field updates', async () => { + await collection.updateOne({ uuid: 'ws1' as WorkspaceUuid }, { mode: 'creating' as const }) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'UPDATE global_account.workspace SET "mode" = $1 WHERE "uuid" = $2', + ['creating', 'ws1'] + ) + }) + + it('should handle increment operations', async () => { + await collection.updateOne({ uuid: 'ws1' as WorkspaceUuid }, { $inc: { processingAttempts: 1 } }) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'UPDATE global_account.workspace SET "processing_attempts" = "processing_attempts" + $1 WHERE "uuid" = $2', + [1, 'ws1'] + ) + }) + }) + + describe('deleteMany', () => { + it('should generate delete query', async () => { + await collection.deleteMany({ mode: 'deleted' as const }) + + expect(mockClient.unsafe).toHaveBeenCalledWith('DELETE FROM global_account.workspace WHERE "mode" = $1', [ + 'deleted' + ]) + }) + }) +}) + +describe('AccountPostgresDbCollection', () => { + let mockClient: any + let collection: AccountPostgresDbCollection + + beforeEach(() => { + mockClient = { + unsafe: jest.fn().mockResolvedValue([]) + } + + collection = new AccountPostgresDbCollection(mockClient as Sql) + }) + + describe('getTableName', () => { + it('should return correct table name', () => { + expect(collection.getTableName()).toBe('global_account.account') + }) + }) + + describe('getPasswordsTableName', () => { + it('should return correct passwords table name', () => { + expect(collection.getPasswordsTableName()).toBe('global_account.account_passwords') + }) + }) + + describe('find', () => { + it('should join with passwords table', async () => { + const mockResult = [ + { + uuid: 'acc1' as PersonUuid, + timezone: 'UTC', + locale: 'en', + hash: null, + salt: null + } + ] + mockClient.unsafe.mockResolvedValue(mockResult) + + const result = await collection.find({ uuid: 'acc1' as PersonUuid }) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + `SELECT * FROM ( + SELECT + a.uuid, + a.timezone, + a.locale, + p.hash, + p.salt + FROM global_account.account as a + LEFT JOIN global_account.account_passwords as p ON p.account_uuid = a.uuid + ) WHERE "uuid" = $1`, + ['acc1'] + ) + expect(result).toEqual(mockResult) + }) + + it('should convert buffer fields from database', async () => { + const mockResult = [ + { + uuid: 'acc1' as PersonUuid, + timezone: 'UTC', + locale: 'en', + hash: { 0: 1, 1: 2, 3: 3 }, // Simulating buffer data from DB + salt: { 0: 4, 1: 5, 2: 6 } + } + ] + mockClient.unsafe.mockResolvedValue(mockResult) + + const result = await collection.find({ uuid: 'acc1' as PersonUuid }) + + expect(result[0].hash).toBeInstanceOf(Buffer) + expect(result[0].salt).toBeInstanceOf(Buffer) + expect(Buffer.from(result[0].hash as any).toString('hex')).toBe(Buffer.from([1, 2, 3]).toString('hex')) + expect(Buffer.from(result[0].salt as any).toString('hex')).toBe(Buffer.from([4, 5, 6]).toString('hex')) + }) + + it('should throw error if querying by password fields', async () => { + await expect(collection.find({ hash: Buffer.from([]) })).rejects.toThrow( + 'Passwords are not allowed in find query conditions' + ) + await expect(collection.find({ salt: Buffer.from([]) })).rejects.toThrow( + 'Passwords are not allowed in find query conditions' + ) + }) + }) + + describe('insertOne', () => { + it('should prevent inserting password fields', async () => { + const doc = { + uuid: 'acc1' as PersonUuid, + hash: Buffer.from([]), + salt: Buffer.from([]) + } + + await expect(collection.insertOne(doc)).rejects.toThrow('Passwords are not allowed in insert query') + }) + + it('should allow inserting non-password fields', async () => { + const doc = { + uuid: 'acc1' as PersonUuid, + timezone: 'UTC', + locale: 'en' + } + mockClient.unsafe.mockResolvedValue([{ uuid: 'acc1' }]) + + await collection.insertOne(doc) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'INSERT INTO global_account.account ("uuid", "timezone", "locale") VALUES ($1, $2, $3) RETURNING *', + ['acc1', 'UTC', 'en'] + ) + }) + }) + + describe('updateOne', () => { + it('should prevent updating with password fields in query', async () => { + await expect(collection.updateOne({ hash: Buffer.from([]) }, { timezone: 'UTC' })).rejects.toThrow( + 'Passwords are not allowed in update query' + ) + }) + + it('should prevent updating password fields', async () => { + await expect( + collection.updateOne({ uuid: 'acc1' as PersonUuid }, { hash: Buffer.from([]), salt: Buffer.from([]) }) + ).rejects.toThrow('Passwords are not allowed in update query') + }) + + it('should allow updating non-password fields', async () => { + await collection.updateOne({ uuid: 'acc1' as PersonUuid }, { timezone: 'UTC', locale: 'en' }) + + expect(mockClient.unsafe).toHaveBeenCalledWith( + 'UPDATE global_account.account SET "timezone" = $1, "locale" = $2 WHERE "uuid" = $3', + ['UTC', 'en', 'acc1'] + ) + }) + }) + + describe('deleteMany', () => { + it('should prevent deleting by password fields', async () => { + await expect(collection.deleteMany({ hash: Buffer.from([]) })).rejects.toThrow( + 'Passwords are not allowed in delete query' + ) + }) + + it('should allow deleting by non-password fields', async () => { + await collection.deleteMany({ uuid: 'acc1' as PersonUuid }) + + expect(mockClient.unsafe).toHaveBeenCalledWith('DELETE FROM global_account.account WHERE "uuid" = $1', ['acc1']) + }) + }) +}) + +describe('PostgresAccountDB', () => { + let mockClient: any + let accountDb: PostgresAccountDB + let spyTag: jest.Mock + let spyValue: any = [] + + beforeEach(() => { + // Create a spy that returns a Promise + spyTag = jest.fn().mockImplementation(() => Promise.resolve(spyValue)) + + // Create base function that's also a tag function + const mock: any = Object.assign(spyTag, { + unsafe: jest.fn().mockResolvedValue([]), + begin: jest.fn().mockImplementation((callback) => callback(mock)) + }) + + mockClient = mock + accountDb = new PostgresAccountDB(mockClient) + }) + + afterEach(() => { + spyValue = [] + }) + + describe('init', () => { + it('should apply migrations in transaction', async () => { + spyValue = { + count: 1 + } + await accountDb.migrate('test_migration', 'CREATE TABLE test') + + expect(mockClient.begin).toHaveBeenCalled() + expect(mockClient).toHaveBeenCalledWith( + ['INSERT INTO _account_applied_migrations (identifier, ddl) VALUES (', ', ', ') ON CONFLICT DO NOTHING'], + 'test_migration', + 'CREATE TABLE test' + ) + expect(mockClient.unsafe).toHaveBeenCalledWith('CREATE TABLE test') + }) + }) + + describe('workspace operations', () => { + const accountId = 'acc1' as PersonUuid + const workspaceId = 'ws1' as WorkspaceUuid + const role = AccountRole.Owner + + describe('workspace member operations', () => { + it('should assign workspace member', async () => { + await accountDb.assignWorkspace(accountId, workspaceId, role) + + expect(mockClient).toHaveBeenCalledWith('global_account.workspace_members') + expect(mockClient).toHaveBeenCalledWith( + ['INSERT INTO ', ' (workspace_uuid, account_uuid, role) VALUES (', ', ', ', ', ')'], + expect.anything(), + workspaceId, + accountId, + role + ) + }) + + it('should unassign workspace member', async () => { + await accountDb.unassignWorkspace(accountId, workspaceId) + + expect(mockClient).toHaveBeenCalledWith('global_account.workspace_members') + expect(mockClient).toHaveBeenCalledWith( + ['DELETE FROM ', ' WHERE workspace_uuid = ', ' AND account_uuid = ', ''], + expect.anything(), + workspaceId, + accountId + ) + }) + + it('should update workspace role', async () => { + await accountDb.updateWorkspaceRole(accountId, workspaceId, role) + + expect(mockClient).toHaveBeenCalledWith('global_account.workspace_members') + expect(mockClient).toHaveBeenCalledWith( + ['UPDATE ', ' SET role = ', ' WHERE workspace_uuid = ', ' AND account_uuid = ', ''], + expect.anything(), + role, + workspaceId, + accountId + ) + }) + + it('should get workspace role', async () => { + mockClient.unsafe.mockResolvedValue([{ role }]) + + await accountDb.getWorkspaceRole(accountId, workspaceId) + + expect(mockClient).toHaveBeenCalledWith('global_account.workspace_members') + expect(mockClient).toHaveBeenCalledWith( + ['SELECT role FROM ', ' WHERE workspace_uuid = ', ' AND account_uuid = ', ''], + expect.anything(), + workspaceId, + accountId + ) + }) + + it('should get workspace members', async () => { + spyValue = [ + { account_uuid: 'acc1', role: AccountRole.Owner }, + { account_uuid: 'acc2', role: AccountRole.Maintainer } + ] + + const result = await accountDb.getWorkspaceMembers(workspaceId) + + expect(mockClient).toHaveBeenCalledWith('global_account.workspace_members') + expect(mockClient).toHaveBeenCalledWith( + ['SELECT account_uuid, role FROM ', ' WHERE workspace_uuid = ', ''], + expect.anything(), + workspaceId + ) + + expect(result).toEqual([ + { person: 'acc1', role: AccountRole.Owner }, + { person: 'acc2', role: AccountRole.Maintainer } + ]) + }) + }) + + describe('getAccountWorkspaces', () => { + it('should return workspaces with status and converted keys', async () => { + const mockWorkspaces = [ + { + uuid: workspaceId, + name: 'Test', + url: 'test', + status: { + mode: 'active', + version_major: 1, + version_minor: 0, + version_patch: 0, + is_disabled: false + } + } + ] + mockClient.unsafe.mockResolvedValue(mockWorkspaces) + + const res = await accountDb.getAccountWorkspaces(accountId) + + expect(mockClient.unsafe).toHaveBeenCalledWith(expect.any(String), [accountId]) + expect(res[0]).toEqual({ + uuid: workspaceId, + name: 'Test', + url: 'test', + status: { + mode: 'active', + versionMajor: 1, + versionMinor: 0, + versionPatch: 0, + isDisabled: false + } + }) + }) + }) + + describe('getPendingWorkspace', () => { + const version: Data = { major: 1, minor: 0, patch: 0 } + const processingTimeoutMs = 5000 + const NOW = 1234567890000 // Fixed timestamp + + beforeEach(() => { + jest.spyOn(Date, 'now').mockReturnValue(NOW) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should get pending creation workspace', async () => { + await accountDb.getPendingWorkspace('', version, 'create', processingTimeoutMs) + + expect(mockClient.unsafe.mock.calls[0][0].replace(/\s+/g, ' ')).toEqual( + `SELECT + w.uuid, + w.name, + w.url, + w.branding, + w.location, + w.region, + w.created_by, + w.created_on, + w.billing_account, + json_build_object( + 'mode', s.mode, + 'processing_progress', s.processing_progress, + 'version_major', s.version_major, + 'version_minor', s.version_minor, + 'version_patch', s.version_patch, + 'last_processing_time', s.last_processing_time, + 'last_visit', s.last_visit, + 'is_disabled', s.is_disabled, + 'processing_attempts', s.processing_attempts, + 'processing_message', s.processing_message, + 'backup_info', s.backup_info + ) status + FROM global_account.workspace as w + INNER JOIN global_account.workspace_status as s ON s.workspace_uuid = w.uuid + WHERE s.mode IN ('pending-creation', 'creating') + AND s.mode <> 'manual-creation' + AND (s.processing_attempts IS NULL OR s.processing_attempts <= 3) + AND (s.last_processing_time IS NULL OR s.last_processing_time < $1) + AND (w.region IS NULL OR w.region = '') + ORDER BY s.last_visit DESC + LIMIT 1 + FOR UPDATE SKIP LOCKED`.replace(/\s+/g, ' ') + ) + expect(mockClient.unsafe.mock.calls[0][1]).toEqual([NOW - processingTimeoutMs]) + }) + + // Should also verify update after fetch + it('should update processing attempts and time after fetch', async () => { + const wsUuid = 'ws1' + mockClient.unsafe.mockResolvedValueOnce([{ uuid: wsUuid }]) // Mock the fetch result + + await accountDb.getPendingWorkspace('', version, 'create', processingTimeoutMs) + + // Verify the update was called + expect(mockClient.unsafe.mock.calls[1][0].replace(/\s+/g, ' ')).toEqual( + `UPDATE global_account.workspace_status + SET processing_attempts = processing_attempts + 1, "last_processing_time" = $1 + WHERE workspace_uuid = $2`.replace(/\s+/g, ' ') + ) + expect(mockClient.unsafe.mock.calls[1][1]).toEqual([NOW, wsUuid]) + }) + }) + }) + + describe('password operations', () => { + const accountId = 'acc1' as PersonUuid + const hash: any = { + buffer: Buffer.from('hash') + } + const salt: any = { + buffer: Buffer.from('salt') + } + + it('should set password', async () => { + await accountDb.setPassword(accountId, hash, salt) + + expect(mockClient).toHaveBeenCalledWith('global_account.account_passwords') + expect(mockClient).toHaveBeenCalledWith( + ['UPSERT INTO ', ' (account_uuid, hash, salt) VALUES (', ', ', '::bytea, ', '::bytea)'], + expect.anything(), + accountId, + hash.buffer, + salt.buffer + ) + }) + + it('should reset password', async () => { + await accountDb.resetPassword(accountId) + + expect(mockClient).toHaveBeenCalledWith('global_account.account_passwords') + expect(mockClient).toHaveBeenCalledWith( + ['DELETE FROM ', ' WHERE account_uuid = ', ''], + expect.anything(), + accountId + ) + }) + }) +}) diff --git a/server/account/src/collections/mongo.ts b/server/account/src/collections/mongo.ts index 2192cb23bd..bb4aed8d7c 100644 --- a/server/account/src/collections/mongo.ts +++ b/server/account/src/collections/mongo.ts @@ -141,7 +141,13 @@ implements DbCollection { } async findOne (query: Query): Promise { - return await this.collection.findOne(query as Filter) + const doc = await this.collection.findOne(query as Filter) + if (doc === null) { + return null + } + + delete doc._id + return doc } async insertOne (data: Partial): Promise { @@ -189,7 +195,7 @@ export class AccountMongoDbCollection extends MongoDbCollection super('account', db, 'uuid') } - convertToObj (acc: Account): Account { + private convertToObj (acc: Account): Account { return { ...acc, hash: acc.hash != null ? Buffer.from(acc.hash.buffer) : acc.hash,