mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
fix(core): Use class-validator with XSS check for survey answers (#10490)
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
parent
d5acde5ce4
commit
547a60642c
|
@ -1,4 +1,4 @@
|
||||||
import { ValidationError, validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||||
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||||
import type { TagEntity } from '@db/entities/TagEntity';
|
import type { TagEntity } from '@db/entities/TagEntity';
|
||||||
|
@ -9,7 +9,7 @@ import type {
|
||||||
UserUpdatePayload,
|
UserUpdatePayload,
|
||||||
} from '@/requests';
|
} from '@/requests';
|
||||||
import { BadRequestError } from './errors/response-errors/bad-request.error';
|
import { BadRequestError } from './errors/response-errors/bad-request.error';
|
||||||
import { NoXss } from '@/validators/no-xss.validator';
|
import type { PersonalizationSurveyAnswersV4 } from './controllers/survey-answers.dto';
|
||||||
|
|
||||||
export async function validateEntity(
|
export async function validateEntity(
|
||||||
entity:
|
entity:
|
||||||
|
@ -19,7 +19,8 @@ export async function validateEntity(
|
||||||
| User
|
| User
|
||||||
| UserUpdatePayload
|
| UserUpdatePayload
|
||||||
| UserRoleChangePayload
|
| UserRoleChangePayload
|
||||||
| UserSettingsUpdatePayload,
|
| UserSettingsUpdatePayload
|
||||||
|
| PersonalizationSurveyAnswersV4,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const errors = await validate(entity);
|
const errors = await validate(entity);
|
||||||
|
|
||||||
|
@ -37,37 +38,3 @@ export async function validateEntity(
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20;
|
export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20;
|
||||||
|
|
||||||
class StringWithNoXss {
|
|
||||||
@NoXss()
|
|
||||||
value: string;
|
|
||||||
|
|
||||||
constructor(value: string) {
|
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporary solution until we implement payload validation middleware
|
|
||||||
export async function validateRecordNoXss(record: Record<string, string>) {
|
|
||||||
const errors: ValidationError[] = [];
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(record)) {
|
|
||||||
const stringWithNoXss = new StringWithNoXss(value);
|
|
||||||
const validationErrors = await validate(stringWithNoXss);
|
|
||||||
|
|
||||||
if (validationErrors.length > 0) {
|
|
||||||
const error = new ValidationError();
|
|
||||||
error.property = key;
|
|
||||||
error.constraints = validationErrors[0].constraints;
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errors.length > 0) {
|
|
||||||
const errorMessages = errors
|
|
||||||
.map((error) => `${error.property}: ${Object.values(error.constraints ?? {}).join(', ')}`)
|
|
||||||
.join(' | ');
|
|
||||||
|
|
||||||
throw new BadRequestError(errorMessages);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -349,10 +349,40 @@ describe('MeController', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw BadRequestError on XSS attempt', async () => {
|
test.each([
|
||||||
const req = mock<MeRequest.SurveyAnswers>({
|
'automationGoalDevops',
|
||||||
body: { 'test-answer': '<script>alert("XSS")</script>' },
|
'companyIndustryExtended',
|
||||||
});
|
'otherCompanyIndustryExtended',
|
||||||
|
'automationGoalSm',
|
||||||
|
'usageModes',
|
||||||
|
])('should throw BadRequestError on XSS attempt for an array field %s', async (fieldName) => {
|
||||||
|
const req = mock<MeRequest.SurveyAnswers>();
|
||||||
|
req.body = {
|
||||||
|
version: 'v4',
|
||||||
|
personalization_survey_n8n_version: '1.0.0',
|
||||||
|
personalization_survey_submitted_at: new Date().toISOString(),
|
||||||
|
[fieldName]: ['<script>alert("XSS")</script>'],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError(BadRequestError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
'automationGoalDevopsOther',
|
||||||
|
'companySize',
|
||||||
|
'companyType',
|
||||||
|
'automationGoalSmOther',
|
||||||
|
'roleOther',
|
||||||
|
'reportedSource',
|
||||||
|
'reportedSourceOther',
|
||||||
|
])('should throw BadRequestError on XSS attempt for a string field %s', async (fieldName) => {
|
||||||
|
const req = mock<MeRequest.SurveyAnswers>();
|
||||||
|
req.body = {
|
||||||
|
version: 'v4',
|
||||||
|
personalization_survey_n8n_version: '1.0.0',
|
||||||
|
personalization_survey_submitted_at: new Date().toISOString(),
|
||||||
|
[fieldName]: '<script>alert("XSS")</script>',
|
||||||
|
};
|
||||||
|
|
||||||
await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError(BadRequestError);
|
await expect(controller.storeSurveyAnswers(req)).rejects.toThrowError(BadRequestError);
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { randomBytes } from 'crypto';
|
||||||
import { AuthService } from '@/auth/auth.service';
|
import { AuthService } from '@/auth/auth.service';
|
||||||
import { Delete, Get, Patch, Post, RestController } from '@/decorators';
|
import { Delete, Get, Patch, Post, RestController } from '@/decorators';
|
||||||
import { PasswordUtility } from '@/services/password.utility';
|
import { PasswordUtility } from '@/services/password.utility';
|
||||||
import { validateEntity, validateRecordNoXss } from '@/GenericHelpers';
|
import { validateEntity } from '@/GenericHelpers';
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import {
|
import {
|
||||||
AuthenticatedRequest,
|
AuthenticatedRequest,
|
||||||
|
@ -25,6 +25,7 @@ import { isApiEnabled } from '@/PublicApi';
|
||||||
import { EventService } from '@/events/event.service';
|
import { EventService } from '@/events/event.service';
|
||||||
import { MfaService } from '@/Mfa/mfa.service';
|
import { MfaService } from '@/Mfa/mfa.service';
|
||||||
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
import { InvalidMfaCodeError } from '@/errors/response-errors/invalid-mfa-code.error';
|
||||||
|
import { PersonalizationSurveyAnswersV4 } from './survey-answers.dto';
|
||||||
|
|
||||||
export const API_KEY_PREFIX = 'n8n_api_';
|
export const API_KEY_PREFIX = 'n8n_api_';
|
||||||
|
|
||||||
|
@ -195,7 +196,7 @@ export class MeController {
|
||||||
|
|
||||||
if (!personalizationAnswers) {
|
if (!personalizationAnswers) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'Request to store user personalization survey failed because of empty payload',
|
'Request to store user personalization survey failed because of undefined payload',
|
||||||
{
|
{
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
},
|
},
|
||||||
|
@ -203,12 +204,18 @@ export class MeController {
|
||||||
throw new BadRequestError('Personalization answers are mandatory');
|
throw new BadRequestError('Personalization answers are mandatory');
|
||||||
}
|
}
|
||||||
|
|
||||||
await validateRecordNoXss(personalizationAnswers);
|
const validatedAnswers = plainToInstance(
|
||||||
|
PersonalizationSurveyAnswersV4,
|
||||||
|
personalizationAnswers,
|
||||||
|
{ excludeExtraneousValues: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
await validateEntity(validatedAnswers);
|
||||||
|
|
||||||
await this.userRepository.save(
|
await this.userRepository.save(
|
||||||
{
|
{
|
||||||
id: req.user.id,
|
id: req.user.id,
|
||||||
personalizationAnswers,
|
personalizationAnswers: validatedAnswers,
|
||||||
},
|
},
|
||||||
{ transaction: false },
|
{ transaction: false },
|
||||||
);
|
);
|
||||||
|
@ -217,7 +224,7 @@ export class MeController {
|
||||||
|
|
||||||
this.eventService.emit('user-submitted-personalization-survey', {
|
this.eventService.emit('user-submitted-personalization-survey', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
answers: personalizationAnswers,
|
answers: validatedAnswers,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
109
packages/cli/src/controllers/survey-answers.dto.ts
Normal file
109
packages/cli/src/controllers/survey-answers.dto.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import { NoXss } from '@/validators/no-xss.validator';
|
||||||
|
import { Expose } from 'class-transformer';
|
||||||
|
import { IsString, IsArray, IsOptional, IsEmail, IsEnum } from 'class-validator';
|
||||||
|
import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class PersonalizationSurveyAnswersV4 implements IPersonalizationSurveyAnswersV4 {
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsEnum(['v4'])
|
||||||
|
version: 'v4';
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsString()
|
||||||
|
personalization_survey_submitted_at: string;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsString()
|
||||||
|
personalization_survey_n8n_version: string;
|
||||||
|
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@NoXss({ each: true })
|
||||||
|
@IsString({ each: true })
|
||||||
|
automationGoalDevops?: string[] | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
automationGoalDevopsOther?: string | null;
|
||||||
|
|
||||||
|
@NoXss({ each: true })
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
companyIndustryExtended?: string[] | null;
|
||||||
|
|
||||||
|
@NoXss({ each: true })
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ each: true })
|
||||||
|
otherCompanyIndustryExtended?: string[] | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companySize?: string | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companyType?: string | null;
|
||||||
|
|
||||||
|
@NoXss({ each: true })
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
automationGoalSm?: string[] | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
automationGoalSmOther?: string | null;
|
||||||
|
|
||||||
|
@NoXss({ each: true })
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
usageModes?: string[] | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
email?: string | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
role?: string | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
roleOther?: string | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reportedSource?: string | null;
|
||||||
|
|
||||||
|
@NoXss()
|
||||||
|
@Expose()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reportedSourceOther?: string | null;
|
||||||
|
}
|
|
@ -863,10 +863,10 @@ describe('TelemetryEventRelay', () => {
|
||||||
const event: RelayEventMap['user-submitted-personalization-survey'] = {
|
const event: RelayEventMap['user-submitted-personalization-survey'] = {
|
||||||
userId: 'user123',
|
userId: 'user123',
|
||||||
answers: {
|
answers: {
|
||||||
|
version: 'v4',
|
||||||
|
personalization_survey_n8n_version: '1.0.0',
|
||||||
|
personalization_survey_submitted_at: '2021-10-01T00:00:00.000Z',
|
||||||
companySize: '1-10',
|
companySize: '1-10',
|
||||||
workArea: 'IT',
|
|
||||||
automationGoal: 'Improve efficiency',
|
|
||||||
valueExpectation: 'Time savings',
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -874,10 +874,10 @@ describe('TelemetryEventRelay', () => {
|
||||||
|
|
||||||
expect(telemetry.track).toHaveBeenCalledWith('User responded to personalization questions', {
|
expect(telemetry.track).toHaveBeenCalledWith('User responded to personalization questions', {
|
||||||
user_id: 'user123',
|
user_id: 'user123',
|
||||||
|
version: 'v4',
|
||||||
|
personalization_survey_n8n_version: '1.0.0',
|
||||||
|
personalization_survey_submitted_at: '2021-10-01T00:00:00.000Z',
|
||||||
company_size: '1-10',
|
company_size: '1-10',
|
||||||
work_area: 'IT',
|
|
||||||
automation_goal: 'Improve efficiency',
|
|
||||||
value_expectation: 'Time savings',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
import type { AuthenticationMethod, IRun, IWorkflowBase } from 'n8n-workflow';
|
import type {
|
||||||
|
AuthenticationMethod,
|
||||||
|
IPersonalizationSurveyAnswersV4,
|
||||||
|
IRun,
|
||||||
|
IWorkflowBase,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces';
|
import type { IWorkflowDb, IWorkflowExecutionDataProcess } from '@/Interfaces';
|
||||||
import type { ProjectRole } from '@/databases/entities/ProjectRelation';
|
import type { ProjectRole } from '@/databases/entities/ProjectRelation';
|
||||||
import type { GlobalRole } from '@/databases/entities/User';
|
import type { GlobalRole } from '@/databases/entities/User';
|
||||||
|
@ -106,7 +111,7 @@ export type RelayEventMap = {
|
||||||
|
|
||||||
'user-submitted-personalization-survey': {
|
'user-submitted-personalization-survey': {
|
||||||
userId: string;
|
userId: string;
|
||||||
answers: Record<string, string>;
|
answers: IPersonalizationSurveyAnswersV4;
|
||||||
};
|
};
|
||||||
|
|
||||||
'user-deleted': {
|
'user-deleted': {
|
||||||
|
|
|
@ -945,11 +945,15 @@ export class TelemetryEventRelay extends EventRelay {
|
||||||
userId,
|
userId,
|
||||||
answers,
|
answers,
|
||||||
}: RelayEventMap['user-submitted-personalization-survey']) {
|
}: RelayEventMap['user-submitted-personalization-survey']) {
|
||||||
const camelCaseKeys = Object.keys(answers);
|
|
||||||
const personalizationSurveyData = { user_id: userId } as Record<string, string | string[]>;
|
const personalizationSurveyData = { user_id: userId } as Record<string, string | string[]>;
|
||||||
camelCaseKeys.forEach((camelCaseKey) => {
|
|
||||||
personalizationSurveyData[snakeCase(camelCaseKey)] = answers[camelCaseKey];
|
// ESlint is wrong here
|
||||||
});
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
for (const [camelCaseKey, value] of Object.entries(answers)) {
|
||||||
|
if (value) {
|
||||||
|
personalizationSurveyData[snakeCase(camelCaseKey)] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.telemetry.track('User responded to personalization questions', personalizationSurveyData);
|
this.telemetry.track('User responded to personalization questions', personalizationSurveyData);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import type {
|
||||||
INodeCredentials,
|
INodeCredentials,
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodeTypeNameVersion,
|
INodeTypeNameVersion,
|
||||||
|
IPersonalizationSurveyAnswersV4,
|
||||||
IUser,
|
IUser,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
|
@ -235,7 +236,7 @@ export declare namespace MeRequest {
|
||||||
{},
|
{},
|
||||||
{ currentPassword: string; newPassword: string; mfaCode?: string }
|
{ currentPassword: string; newPassword: string; mfaCode?: string }
|
||||||
>;
|
>;
|
||||||
export type SurveyAnswers = AuthenticatedRequest<{}, {}, Record<string, string> | {}>;
|
export type SurveyAnswers = AuthenticatedRequest<{}, {}, IPersonalizationSurveyAnswersV4>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserSetupPayload {
|
export interface UserSetupPayload {
|
||||||
|
|
|
@ -11,6 +11,9 @@ describe('NoXss', () => {
|
||||||
|
|
||||||
@NoXss()
|
@NoXss()
|
||||||
version = '';
|
version = '';
|
||||||
|
|
||||||
|
@NoXss({ each: true })
|
||||||
|
categories: string[] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const entity = new Entity();
|
const entity = new Entity();
|
||||||
|
@ -71,7 +74,7 @@ describe('NoXss', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Miscellanous strings', () => {
|
describe('Miscellaneous strings', () => {
|
||||||
const VALID_MISCELLANEOUS_STRINGS = ['CI/CD'];
|
const VALID_MISCELLANEOUS_STRINGS = ['CI/CD'];
|
||||||
|
|
||||||
for (const str of VALID_MISCELLANEOUS_STRINGS) {
|
for (const str of VALID_MISCELLANEOUS_STRINGS) {
|
||||||
|
@ -81,4 +84,34 @@ describe('NoXss', () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Array of strings', () => {
|
||||||
|
const VALID_STRING_ARRAYS = [
|
||||||
|
['cloud-infrastructure-orchestration', 'ci-cd', 'reporting'],
|
||||||
|
['automationGoalDevops', 'cloudComputing', 'containerization'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const arr of VALID_STRING_ARRAYS) {
|
||||||
|
test(`should allow array: ${JSON.stringify(arr)}`, async () => {
|
||||||
|
entity.categories = arr;
|
||||||
|
await expect(validate(entity)).resolves.toBeEmptyArray();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID_STRING_ARRAYS = [
|
||||||
|
['valid-string', '<script>alert("xss")</script>', 'another-valid-string'],
|
||||||
|
['<img src="x" onerror="alert(\'XSS\')">', 'valid-string'],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const arr of INVALID_STRING_ARRAYS) {
|
||||||
|
test(`should reject array containing invalid string: ${JSON.stringify(arr)}`, async () => {
|
||||||
|
entity.categories = arr;
|
||||||
|
const errors = await validate(entity);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
const [error] = errors;
|
||||||
|
expect(error.property).toEqual('categories');
|
||||||
|
expect(error.constraints).toEqual({ NoXss: 'Potentially malicious string' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,7 +4,9 @@ import { registerDecorator, ValidatorConstraint } from 'class-validator';
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'NoXss', async: false })
|
@ValidatorConstraint({ name: 'NoXss', async: false })
|
||||||
class NoXssConstraint implements ValidatorConstraintInterface {
|
class NoXssConstraint implements ValidatorConstraintInterface {
|
||||||
validate(value: string) {
|
validate(value: unknown) {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
value ===
|
value ===
|
||||||
xss(value, {
|
xss(value, {
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
import { IsNull } from '@n8n/typeorm';
|
import { IsNull } from '@n8n/typeorm';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import { randomString } from 'n8n-workflow';
|
|
||||||
|
|
||||||
import type { User } from '@db/entities/User';
|
import type { User } from '@db/entities/User';
|
||||||
import { UserRepository } from '@db/repositories/user.repository';
|
import { UserRepository } from '@db/repositories/user.repository';
|
||||||
|
@ -15,6 +14,7 @@ import { addApiKey, createOwner, createUser, createUserShell } from './shared/db
|
||||||
import type { SuperAgentTest } from './shared/types';
|
import type { SuperAgentTest } from './shared/types';
|
||||||
import { mockInstance } from '@test/mocking';
|
import { mockInstance } from '@test/mocking';
|
||||||
import { GlobalConfig } from '@n8n/config';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
|
import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow';
|
||||||
|
|
||||||
const testServer = utils.setupTestServer({ endpointGroups: ['me'] });
|
const testServer = utils.setupTestServer({ endpointGroups: ['me'] });
|
||||||
|
|
||||||
|
@ -145,16 +145,16 @@ describe('Owner shell', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /me/survey should succeed with valid inputs', async () => {
|
test('POST /me/survey should succeed with valid inputs', async () => {
|
||||||
const validPayloads = [SURVEY, {}];
|
const validPayloads = [SURVEY, EMPTY_SURVEY];
|
||||||
|
|
||||||
for (const validPayload of validPayloads) {
|
for (const validPayload of validPayloads) {
|
||||||
const response = await authOwnerShellAgent.post('/me/survey').send(validPayload);
|
const response = await authOwnerShellAgent.post('/me/survey').send(validPayload);
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.body).toEqual(SUCCESS_RESPONSE_BODY);
|
expect(response.body).toEqual(SUCCESS_RESPONSE_BODY);
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
const storedShellOwner = await Container.get(UserRepository).findOneOrFail({
|
const storedShellOwner = await Container.get(UserRepository).findOneOrFail({
|
||||||
where: { email: IsNull() },
|
where: { id: ownerShell.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(storedShellOwner.personalizationAnswers).toEqual(validPayload);
|
expect(storedShellOwner.personalizationAnswers).toEqual(validPayload);
|
||||||
|
@ -300,7 +300,7 @@ describe('Member', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('POST /me/survey should succeed with valid inputs', async () => {
|
test('POST /me/survey should succeed with valid inputs', async () => {
|
||||||
const validPayloads = [SURVEY, {}];
|
const validPayloads = [SURVEY, EMPTY_SURVEY];
|
||||||
|
|
||||||
for (const validPayload of validPayloads) {
|
for (const validPayload of validPayloads) {
|
||||||
const response = await authMemberAgent.post('/me/survey').send(validPayload);
|
const response = await authMemberAgent.post('/me/survey').send(validPayload);
|
||||||
|
@ -392,16 +392,31 @@ describe('Owner', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const SURVEY = [
|
const SURVEY: IPersonalizationSurveyAnswersV4 = {
|
||||||
'codingSkill',
|
version: 'v4',
|
||||||
'companyIndustry',
|
personalization_survey_submitted_at: '2024-08-21T13:05:51.709Z',
|
||||||
'companySize',
|
personalization_survey_n8n_version: '1.0.0',
|
||||||
'otherCompanyIndustry',
|
automationGoalDevops: ['test'],
|
||||||
'otherWorkArea',
|
automationGoalDevopsOther: 'test',
|
||||||
'workArea',
|
companyIndustryExtended: ['test'],
|
||||||
].reduce<Record<string, string>>((acc, cur) => {
|
otherCompanyIndustryExtended: ['test'],
|
||||||
return (acc[cur] = randomString(2, 10)), acc;
|
companySize: 'test',
|
||||||
}, {});
|
companyType: 'test',
|
||||||
|
automationGoalSm: ['test'],
|
||||||
|
automationGoalSmOther: 'test',
|
||||||
|
usageModes: ['test'],
|
||||||
|
email: 'test@email.com',
|
||||||
|
role: 'test',
|
||||||
|
roleOther: 'test',
|
||||||
|
reportedSource: 'test',
|
||||||
|
reportedSourceOther: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_SURVEY: IPersonalizationSurveyAnswersV4 = {
|
||||||
|
version: 'v4',
|
||||||
|
personalization_survey_submitted_at: '2024-08-21T13:05:51.709Z',
|
||||||
|
personalization_survey_n8n_version: '1.0.0',
|
||||||
|
};
|
||||||
|
|
||||||
const VALID_PATCH_ME_PAYLOADS = [
|
const VALID_PATCH_ME_PAYLOADS = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -48,6 +48,7 @@ import type {
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
INodeCredentialsDetails,
|
INodeCredentialsDetails,
|
||||||
StartNodeData,
|
StartNodeData,
|
||||||
|
IPersonalizationSurveyAnswersV4,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type { BulkCommand, Undoable } from '@/models/history';
|
import type { BulkCommand, Undoable } from '@/models/history';
|
||||||
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
|
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
|
||||||
|
@ -648,24 +649,6 @@ export type IPersonalizationSurveyAnswersV3 = {
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IPersonalizationSurveyAnswersV4 = {
|
|
||||||
version: 'v4';
|
|
||||||
automationGoalDevops?: string[] | null;
|
|
||||||
automationGoalDevopsOther?: string | null;
|
|
||||||
companyIndustryExtended?: string[] | null;
|
|
||||||
otherCompanyIndustryExtended?: string[] | null;
|
|
||||||
companySize?: string | null;
|
|
||||||
companyType?: string | null;
|
|
||||||
automationGoalSm?: string[] | null;
|
|
||||||
automationGoalSmOther?: string | null;
|
|
||||||
usageModes?: string[] | null;
|
|
||||||
email?: string | null;
|
|
||||||
role?: string | null;
|
|
||||||
roleOther?: string | null;
|
|
||||||
reportedSource?: string | null;
|
|
||||||
reportedSourceOther?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type IPersonalizationLatestVersion = IPersonalizationSurveyAnswersV4;
|
export type IPersonalizationLatestVersion = IPersonalizationSurveyAnswersV4;
|
||||||
|
|
||||||
export type IPersonalizationSurveyVersions =
|
export type IPersonalizationSurveyVersions =
|
||||||
|
|
|
@ -140,7 +140,6 @@ import {
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
import type { IFormInputs, IPersonalizationLatestVersion } from '@/Interface';
|
import type { IFormInputs, IPersonalizationLatestVersion } from '@/Interface';
|
||||||
import type { GenericValue } from 'n8n-workflow';
|
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
|
@ -696,19 +695,16 @@ export default defineComponent({
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const survey: Record<string, GenericValue> = {
|
const survey: IPersonalizationLatestVersion = {
|
||||||
...values,
|
...values,
|
||||||
version: SURVEY_VERSION,
|
version: SURVEY_VERSION,
|
||||||
personalization_survey_submitted_at: new Date().toISOString(),
|
personalization_survey_submitted_at: new Date().toISOString(),
|
||||||
personalization_survey_n8n_version: this.rootStore.versionCli,
|
personalization_survey_n8n_version: this.rootStore.versionCli,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.externalHooks.run(
|
await this.externalHooks.run('personalizationModal.onSubmit', survey);
|
||||||
'personalizationModal.onSubmit',
|
|
||||||
survey as IPersonalizationLatestVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.usersStore.submitPersonalizationSurvey(survey as IPersonalizationLatestVersion);
|
await this.usersStore.submitPersonalizationSurvey(survey);
|
||||||
|
|
||||||
this.posthogStore.setMetadata(survey, 'user');
|
this.posthogStore.setMetadata(survey, 'user');
|
||||||
|
|
||||||
|
|
|
@ -65,11 +65,11 @@ import type {
|
||||||
IPersonalizationSurveyAnswersV1,
|
IPersonalizationSurveyAnswersV1,
|
||||||
IPersonalizationSurveyAnswersV2,
|
IPersonalizationSurveyAnswersV2,
|
||||||
IPersonalizationSurveyAnswersV3,
|
IPersonalizationSurveyAnswersV3,
|
||||||
IPersonalizationSurveyAnswersV4,
|
|
||||||
IPersonalizationSurveyVersions,
|
IPersonalizationSurveyVersions,
|
||||||
IUser,
|
IUser,
|
||||||
ILogInStatus,
|
ILogInStatus,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
|
import type { IPersonalizationSurveyAnswersV4 } from 'n8n-workflow';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Utility functions used to handle users in n8n
|
Utility functions used to handle users in n8n
|
||||||
|
|
|
@ -2789,3 +2789,23 @@ export type Functionality = 'regular' | 'configuration-node' | 'pairedItem';
|
||||||
export type Result<T, E> = { ok: true; result: T } | { ok: false; error: E };
|
export type Result<T, E> = { ok: true; result: T } | { ok: false; error: E };
|
||||||
|
|
||||||
export type CallbackManager = CallbackManagerLC;
|
export type CallbackManager = CallbackManagerLC;
|
||||||
|
|
||||||
|
export type IPersonalizationSurveyAnswersV4 = {
|
||||||
|
version: 'v4';
|
||||||
|
personalization_survey_submitted_at: string;
|
||||||
|
personalization_survey_n8n_version: string;
|
||||||
|
automationGoalDevops?: string[] | null;
|
||||||
|
automationGoalDevopsOther?: string | null;
|
||||||
|
companyIndustryExtended?: string[] | null;
|
||||||
|
otherCompanyIndustryExtended?: string[] | null;
|
||||||
|
companySize?: string | null;
|
||||||
|
companyType?: string | null;
|
||||||
|
automationGoalSm?: string[] | null;
|
||||||
|
automationGoalSmOther?: string | null;
|
||||||
|
usageModes?: string[] | null;
|
||||||
|
email?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
roleOther?: string | null;
|
||||||
|
reportedSource?: string | null;
|
||||||
|
reportedSourceOther?: string | null;
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in a new issue