import { describe, it, expect, vi } from 'vitest'; import { Duration } from 'luxon'; import appConfig from '../config/app.js'; import Base from './base.js'; import AccessToken from './access-token.js'; import Connection from './connection.js'; import Execution from './execution.js'; import Flow from './flow.js'; import Identity from './identity.ee.js'; import Permission from './permission.js'; import Role from './role.js'; import Step from './step.js'; import Subscription from './subscription.ee.js'; import UsageData from './usage-data.ee.js'; import User from './user.js'; import deleteUserQueue from '../queues/delete-user.ee.js'; import { createUser } from '../../test/factories/user.js'; import { createConnection } from '../../test/factories/connection.js'; import { createRole } from '../../test/factories/role.js'; import { createPermission } from '../../test/factories/permission.js'; import { createFlow } from '../../test/factories/flow.js'; import { createStep } from '../../test/factories/step.js'; import { createExecution } from '../../test/factories/execution.js'; describe('User model', () => { it('tableName should return correct name', () => { expect(User.tableName).toBe('users'); }); it('jsonSchema should have correct validations', () => { expect(User.jsonSchema).toMatchSnapshot(); }); describe('relationMappings', () => { it('should return correct associations', () => { const relationMappings = User.relationMappings(); const expectedRelations = { accessTokens: { relation: Base.HasManyRelation, modelClass: AccessToken, join: { from: 'users.id', to: 'access_tokens.user_id', }, }, connections: { relation: Base.HasManyRelation, modelClass: Connection, join: { from: 'users.id', to: 'connections.user_id', }, }, flows: { relation: Base.HasManyRelation, modelClass: Flow, join: { from: 'users.id', to: 'flows.user_id', }, }, steps: { relation: Base.ManyToManyRelation, modelClass: Step, join: { from: 'users.id', through: { from: 'flows.user_id', to: 'flows.id', }, to: 'steps.flow_id', }, }, executions: { relation: Base.ManyToManyRelation, modelClass: Execution, join: { from: 'users.id', through: { from: 'flows.user_id', to: 'flows.id', }, to: 'executions.flow_id', }, }, usageData: { relation: Base.HasManyRelation, modelClass: UsageData, join: { from: 'usage_data.user_id', to: 'users.id', }, }, currentUsageData: { relation: Base.HasOneRelation, modelClass: UsageData, join: { from: 'usage_data.user_id', to: 'users.id', }, filter: expect.any(Function), }, subscriptions: { relation: Base.HasManyRelation, modelClass: Subscription, join: { from: 'subscriptions.user_id', to: 'users.id', }, }, currentSubscription: { relation: Base.HasOneRelation, modelClass: Subscription, join: { from: 'subscriptions.user_id', to: 'users.id', }, filter: expect.any(Function), }, role: { relation: Base.HasOneRelation, modelClass: Role, join: { from: 'roles.id', to: 'users.role_id', }, }, permissions: { relation: Base.HasManyRelation, modelClass: Permission, join: { from: 'users.role_id', to: 'permissions.role_id', }, }, identities: { relation: Base.HasManyRelation, modelClass: Identity, join: { from: 'identities.user_id', to: 'users.id', }, }, }; expect(relationMappings).toStrictEqual(expectedRelations); }); it('currentUsageData should return the current usage data', () => { const relations = User.relationMappings(); const firstSpy = vi.fn(); const limitSpy = vi.fn().mockImplementation(() => ({ first: firstSpy, })); const orderBySpy = vi.fn().mockImplementation(() => ({ limit: limitSpy, })); relations.currentUsageData.filter({ orderBy: orderBySpy }); expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc'); expect(limitSpy).toHaveBeenCalledWith(1); expect(firstSpy).toHaveBeenCalledOnce(); }); it('currentSubscription should return the current subscription', () => { const relations = User.relationMappings(); const firstSpy = vi.fn(); const limitSpy = vi.fn().mockImplementation(() => ({ first: firstSpy, })); const orderBySpy = vi.fn().mockImplementation(() => ({ limit: limitSpy, })); relations.currentSubscription.filter({ orderBy: orderBySpy }); expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc'); expect(limitSpy).toHaveBeenCalledWith(1); expect(firstSpy).toHaveBeenCalledOnce(); }); }); it('virtualAttributes should return correct attributes', () => { const virtualAttributes = User.virtualAttributes; const expectedAttributes = ['acceptInvitationUrl']; expect(virtualAttributes).toStrictEqual(expectedAttributes); }); it('acceptInvitationUrl should return accept invitation page URL with invitation token', async () => { const user = new User(); user.invitationToken = 'invitation-token'; vi.spyOn(appConfig, 'webAppUrl', 'get').mockReturnValue( 'https://automatisch.io' ); expect(user.acceptInvitationUrl).toBe( 'https://automatisch.io/accept-invitation?token=invitation-token' ); }); describe('authenticate', () => { it('should create and return the token for correct email and password', async () => { const user = await createUser({ email: 'test-user@automatisch.io', password: 'sample-password', }); const token = await User.authenticate( 'test-user@automatisch.io', 'sample-password' ); const persistedToken = await AccessToken.query().findOne({ userId: user.id, }); expect(token).toBe(persistedToken.token); }); it('should return undefined for existing email and incorrect password', async () => { await createUser({ email: 'test-user@automatisch.io', password: 'sample-password', }); const token = await User.authenticate( 'test-user@automatisch.io', 'wrong-password' ); expect(token).toBe(undefined); }); it('should return undefined for non-existing email', async () => { await createUser({ email: 'test-user@automatisch.io', password: 'sample-password', }); const token = await User.authenticate('non-existing-user@automatisch.io'); expect(token).toBe(undefined); }); }); describe('authorizedFlows', () => { it('should return user flows with isCreator condition', async () => { const userRole = await createRole({ name: 'User' }); await createPermission({ roleId: userRole.id, subject: 'Flow', action: 'read', conditions: ['isCreator'], }); const user = await createUser({ roleId: userRole.id }); const userWithRoleAndPermissions = await user .$query() .withGraphFetched({ role: true, permissions: true }); const userFlow = await createFlow({ userId: user.id }); await createFlow(); expect(await userWithRoleAndPermissions.authorizedFlows).toStrictEqual([ userFlow, ]); }); it('should return all flows without isCreator condition', async () => { const userRole = await createRole({ name: 'User' }); await createPermission({ roleId: userRole.id, subject: 'Flow', action: 'read', conditions: [], }); const user = await createUser({ roleId: userRole.id }); const userWithRoleAndPermissions = await user .$query() .withGraphFetched({ role: true, permissions: true }); const userFlow = await createFlow({ userId: user.id }); const anotherUserFlow = await createFlow(); expect(await userWithRoleAndPermissions.authorizedFlows).toStrictEqual([ userFlow, anotherUserFlow, ]); }); it('should throw an authorization error without Flow read permission', async () => { const user = new User(); expect(() => user.authorizedFlows).toThrowError( 'The user is not authorized!' ); }); }); describe('authorizedSteps', () => { it('should return user steps with isCreator condition', async () => { const userRole = await createRole({ name: 'User' }); await createPermission({ roleId: userRole.id, subject: 'Flow', action: 'read', conditions: ['isCreator'], }); const user = await createUser({ roleId: userRole.id }); const userWithRoleAndPermissions = await user .$query() .withGraphFetched({ role: true, permissions: true }); const userFlow = await createFlow({ userId: user.id }); const userFlowStep = await createStep({ flowId: userFlow.id }); const anotherUserFlow = await createFlow(); await createStep({ flowId: anotherUserFlow.id }); expect(await userWithRoleAndPermissions.authorizedSteps).toStrictEqual([ userFlowStep, ]); }); it('should return all steps without isCreator condition', async () => { const userRole = await createRole({ name: 'User' }); await createPermission({ roleId: userRole.id, subject: 'Flow', action: 'read', conditions: [], }); const user = await createUser({ roleId: userRole.id }); const userWithRoleAndPermissions = await user .$query() .withGraphFetched({ role: true, permissions: true }); const userFlow = await createFlow({ userId: user.id }); const userFlowStep = await createStep({ flowId: userFlow.id }); const anotherUserFlow = await createFlow(); const anotherUserFlowStep = await createStep({ flowId: anotherUserFlow.id, }); expect(await userWithRoleAndPermissions.authorizedSteps).toStrictEqual([ userFlowStep, anotherUserFlowStep, ]); }); it('should throw an authorization error without Flow read permission', async () => { const user = new User(); expect(() => user.authorizedSteps).toThrowError( 'The user is not authorized!' ); }); }); describe('authorizedConnections', () => { it('should return user connections with isCreator condition', async () => { const userRole = await createRole({ name: 'User' }); await createPermission({ roleId: userRole.id, subject: 'Connection', action: 'read', conditions: ['isCreator'], }); const user = await createUser({ roleId: userRole.id }); const userWithRoleAndPermissions = await user .$query() .withGraphFetched({ role: true, permissions: true }); const userConnection = await createConnection({ userId: user.id }); await createConnection(); expect( await userWithRoleAndPermissions.authorizedConnections ).toStrictEqual([userConnection]); }); it('should return all connections without isCreator condition', async () => { const userRole = await createRole({ name: 'User' }); await createPermission({ roleId: userRole.id, subject: 'Connection', action: 'read', conditions: [], }); const user = await createUser({ roleId: userRole.id }); const userWithRoleAndPermissions = await user .$query() .withGraphFetched({ role: true, permissions: true }); const userConnection = await createConnection({ userId: user.id }); const anotherUserConnection = await createConnection(); expect( await userWithRoleAndPermissions.authorizedConnections ).toStrictEqual([userConnection, anotherUserConnection]); }); it('should throw an authorization error without Connection read permission', async () => { const user = new User(); expect(() => user.authorizedConnections).toThrowError( 'The user is not authorized!' ); }); }); describe('authorizedExecutions', () => { it('should return user executions with isCreator condition', async () => { const userRole = await createRole({ name: 'User' }); await createPermission({ roleId: userRole.id, subject: 'Execution', action: 'read', conditions: ['isCreator'], }); const user = await createUser({ roleId: userRole.id }); const userWithRoleAndPermissions = await user .$query() .withGraphFetched({ role: true, permissions: true }); const userFlow = await createFlow({ userId: user.id }); const userFlowExecution = await createExecution({ flowId: userFlow.id }); await createExecution(); expect( await userWithRoleAndPermissions.authorizedExecutions ).toStrictEqual([userFlowExecution]); }); it('should return all executions without isCreator condition', async () => { const userRole = await createRole({ name: 'User' }); await createPermission({ roleId: userRole.id, subject: 'Execution', action: 'read', conditions: [], }); const user = await createUser({ roleId: userRole.id }); const userWithRoleAndPermissions = await user .$query() .withGraphFetched({ role: true, permissions: true }); const userFlow = await createFlow({ userId: user.id }); const userFlowExecution = await createExecution({ flowId: userFlow.id }); const anotherUserFlowExecution = await createExecution(); expect( await userWithRoleAndPermissions.authorizedExecutions ).toStrictEqual([userFlowExecution, anotherUserFlowExecution]); }); it('should throw an authorization error without Execution read permission', async () => { const user = new User(); expect(() => user.authorizedExecutions).toThrowError( 'The user is not authorized!' ); }); }); describe('login', () => { it('should return true when the given password matches with the user password', async () => { const user = await createUser({ password: 'sample-password' }); expect(await user.login('sample-password')).toBe(true); }); it('should return false when the given password does not match with the user password', async () => { const user = await createUser({ password: 'sample-password' }); expect(await user.login('wrong-password')).toBe(false); }); }); it('generateResetPasswordToken should persist a random reset password token with the current date', async () => { vi.useFakeTimers(); const date = new Date(2024, 10, 11, 15, 17, 0, 0); vi.setSystemTime(date); const user = await createUser({ resetPasswordToken: null, resetPasswordTokenSentAt: null, }); await user.generateResetPasswordToken(); const refetchedUser = await user.$query(); expect(refetchedUser.resetPasswordToken.length).toBe(128); expect(refetchedUser.resetPasswordTokenSentAt).toStrictEqual(date); vi.useRealTimers(); }); it('generateInvitationToken should persist a random invitation token with the current date', async () => { vi.useFakeTimers(); const date = new Date(2024, 10, 11, 15, 26, 0, 0); vi.setSystemTime(date); const user = await createUser({ invitationToken: null, invitationTokenSentAt: null, }); await user.generateInvitationToken(); const refetchedUser = await user.$query(); expect(refetchedUser.invitationToken.length).toBe(128); expect(refetchedUser.invitationTokenSentAt).toStrictEqual(date); vi.useRealTimers(); }); it('resetPassword should persist given password and remove reset password token', async () => { const user = await createUser({ resetPasswordToken: 'reset-password-token', resetPasswordTokenSentAt: '2024-11-11T12:26:00.000Z', }); await user.resetPassword('new-password'); const refetchedUser = await user.$query(); expect(refetchedUser.resetPasswordToken).toBe(null); expect(refetchedUser.resetPasswordTokenSentAt).toBe(null); expect(await refetchedUser.login('new-password')).toBe(true); }); it('acceptInvitation should persist given password, set user active and remove invitation token', async () => { const user = await createUser({ invitationToken: 'invitation-token', invitationTokenSentAt: '2024-11-11T12:26:00.000Z', status: 'invited', }); await user.acceptInvitation('new-password'); const refetchedUser = await user.$query(); expect(refetchedUser.invitationToken).toBe(null); expect(refetchedUser.invitationTokenSentAt).toBe(null); expect(refetchedUser.status).toBe('active'); }); describe('updatePassword', () => { it('should update password when the given current password matches with the user password', async () => { const user = await createUser({ password: 'sample-password' }); const updatedUser = await user.updatePassword({ currentPassword: 'sample-password', password: 'new-password', }); expect(await updatedUser.login('new-password')).toBe(true); }); it('should throw validation error when the given current password does not match with the user password', async () => { const user = await createUser({ password: 'sample-password' }); await expect( user.updatePassword({ currentPassword: 'wrong-password', password: 'new-password', }) ).rejects.toThrowError('currentPassword: is incorrect.'); }); }); it('softRemove should soft remove the user, its associations and queue it for hard deletion in 30 days', async () => { vi.useFakeTimers(); const date = new Date(2024, 10, 12, 12, 50, 0, 0); vi.setSystemTime(date); const user = await createUser(); const softRemoveAssociationsSpy = vi .spyOn(user, 'softRemoveAssociations') .mockReturnValue(); const deleteUserQueueAddSpy = vi .spyOn(deleteUserQueue, 'add') .mockResolvedValue(); await user.softRemove(); const refetchedSoftDeletedUser = await user.$query().withSoftDeleted(); const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis(); const jobName = `Delete user - ${user.id}`; const jobPayload = { id: user.id }; const jobOptions = { delay: millisecondsFor30Days, }; expect(softRemoveAssociationsSpy).toHaveBeenCalledOnce(); expect(refetchedSoftDeletedUser.deletedAt).toStrictEqual(date); expect(deleteUserQueueAddSpy).toHaveBeenCalledWith( jobName, jobPayload, jobOptions ); vi.useRealTimers(); }); });