Files
automatisch/packages/backend/src/models/user.test.js
2024-11-21 17:14:56 +03:00

832 lines
24 KiB
JavaScript

import { describe, it, expect, vi } from 'vitest';
import { DateTime, 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 emailQueue from '../queues/email.js';
import {
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../helpers/remove-job-configuration.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();
});
it.todo('softRemoveAssociations');
it('sendResetPasswordEmail should generate reset password token and queue to send reset password email', async () => {
vi.useFakeTimers();
const date = new Date(2024, 10, 12, 14, 33, 0, 0);
vi.setSystemTime(date);
const user = await createUser();
const generateResetPasswordTokenSpy = vi
.spyOn(user, 'generateResetPasswordToken')
.mockReturnValue();
const emailQueueAddSpy = vi.spyOn(emailQueue, 'add').mockResolvedValue();
await user.sendResetPasswordEmail();
const refetchedUser = await user.$query();
const jobName = `Reset Password Email - ${user.id}`;
const jobPayload = {
email: refetchedUser.email,
subject: 'Reset Password',
template: 'reset-password-instructions.ee',
params: {
token: refetchedUser.resetPasswordToken,
webAppUrl: appConfig.webAppUrl,
fullName: refetchedUser.fullName,
},
};
const jobOptions = {
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
};
expect(generateResetPasswordTokenSpy).toHaveBeenCalledOnce();
expect(emailQueueAddSpy).toHaveBeenCalledWith(
jobName,
jobPayload,
jobOptions
);
vi.useRealTimers();
});
describe('isResetPasswordTokenValid', () => {
it('should return true when resetPasswordTokenSentAt is within the next four hours', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 12, hour: 16, minute: 30 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = new User();
user.resetPasswordTokenSentAt = '2024-11-12T13:31:00.000Z';
expect(user.isResetPasswordTokenValid()).toBe(true);
vi.useRealTimers();
});
it('should return false when there is no resetPasswordTokenSentAt', async () => {
const user = new User();
expect(user.isResetPasswordTokenValid()).toBe(false);
});
it('should return false when resetPasswordTokenSentAt is older than four hours', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 12, hour: 16, minute: 30 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = new User();
user.resetPasswordTokenSentAt = '2024-11-12T12:29:00.000Z';
expect(user.isResetPasswordTokenValid()).toBe(false);
vi.useRealTimers();
});
});
it('sendInvitationEmail should generate invitation token and queue to send invitation email', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 12, hour: 17, minute: 10 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = await createUser();
const generateInvitationTokenSpy = vi
.spyOn(user, 'generateInvitationToken')
.mockReturnValue();
const emailQueueAddSpy = vi.spyOn(emailQueue, 'add').mockResolvedValue();
await user.sendInvitationEmail();
const refetchedUser = await user.$query();
const jobName = `Invitation Email - ${refetchedUser.id}`;
const jobPayload = {
email: refetchedUser.email,
subject: 'You are invited!',
template: 'invitation-instructions',
params: {
fullName: refetchedUser.fullName,
acceptInvitationUrl: refetchedUser.acceptInvitationUrl,
},
};
const jobOptions = {
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
};
expect(generateInvitationTokenSpy).toHaveBeenCalledOnce();
expect(emailQueueAddSpy).toHaveBeenCalledWith(
jobName,
jobPayload,
jobOptions
);
vi.useRealTimers();
});
describe('isInvitationTokenValid', () => {
it('should return truen when invitationTokenSentAt is within the next four hours', async () => {
vi.useFakeTimers();
const date = new Date(2024, 10, 14, 14, 30, 0, 0);
vi.setSystemTime(date);
const user = new User();
user.invitationTokenSentAt = '2024-11-14T13:31:00.000Z';
expect(user.isInvitationTokenValid()).toBe(true);
vi.useRealTimers();
});
it('should return false when there is no invitationTokenSentAt', async () => {
const user = new User();
expect(user.isInvitationTokenValid()).toBe(false);
});
it('should return false when invitationTokenSentAt is older than seventy two hours', async () => {
vi.useFakeTimers();
const date = new Date(2024, 10, 14, 14, 30, 0, 0);
vi.setSystemTime(date);
const user = new User();
user.invitationTokenSentAt = '2024-11-11T14:20:00.000Z';
expect(user.isInvitationTokenValid()).toBe(false);
vi.useRealTimers();
});
});
});