Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master' into CAT-455-port-over-m…
Browse files Browse the repository at this point in the history
…ore-dtos-part-4
  • Loading branch information
netroy committed Dec 27, 2024
2 parents e7dc5da + 7ea6c8b commit 0d01e39
Show file tree
Hide file tree
Showing 54 changed files with 1,300 additions and 317 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Try n8n instantly with [npx](https://docs.n8n.io/hosting/installation/npm/) (req

Or deploy with [Docker](https://docs.n8n.io/hosting/installation/docker/):

`docker run -it --rm --name n8n -p 5678:5678 docker.n8n.io/n8n-io/n8n`
`docker run -it --rm --name n8n -p 5678:5678 docker.n8n.io/n8nio/n8n`

Access the editor at http://localhost:5678

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { nanoId } from 'minifaker';

import { AiFreeCreditsRequestDto } from '../ai-free-credits-request.dto';
import 'minifaker/locales/en';

describe('AiChatRequestDto', () => {
it('should succeed if projectId is a valid nanoid', () => {
const validRequest = {
projectId: nanoId.nanoid(),
};

const result = AiFreeCreditsRequestDto.safeParse(validRequest);

expect(result.success).toBe(true);
});

it('should succeed if no projectId is sent', () => {
const result = AiFreeCreditsRequestDto.safeParse({});

expect(result.success).toBe(true);
});

it('should fail is projectId invalid value', () => {
const validRequest = {
projectId: '',
};

const result = AiFreeCreditsRequestDto.safeParse(validRequest);

expect(result.success).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';

export class AiFreeCreditsRequestDto extends Z.class({
projectId: z.string().min(1).optional(),
}) {}
1 change: 1 addition & 0 deletions packages/@n8n/api-types/src/dto/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { AiAskRequestDto } from './ai/ai-ask-request.dto';
export { AiChatRequestDto } from './ai/ai-chat-request.dto';
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto';

export { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
Expand Down
4 changes: 4 additions & 0 deletions packages/@n8n/api-types/src/frontend-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ export interface FrontendSettings {
pruneTime: number;
licensePruneTime: number;
};
aiCredits: {
enabled: boolean;
credits: number;
};
pruning?: {
isEnabled: boolean;
maxAge: number;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
"@n8n/permissions": "workspace:*",
"@n8n/task-runner": "workspace:*",
"@n8n/typeorm": "0.3.20-12",
"@n8n_io/ai-assistant-sdk": "1.12.0",
"@n8n_io/ai-assistant-sdk": "1.13.0",
"@n8n_io/license-sdk": "2.13.1",
"@oclif/core": "4.0.7",
"@rudderstack/rudder-sdk-node": "2.0.9",
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/__tests__/project.test-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { nanoId, date, firstName, lastName, email } from 'minifaker';
import 'minifaker/locales/en';

import type { Project, ProjectType } from '@/databases/entities/project';

type RawProjectData = Pick<Project, 'name' | 'type' | 'createdAt' | 'updatedAt' | 'id'>;

const projectName = `${firstName()} ${lastName()} <${email}>`;

export const createRawProjectData = (payload: Partial<RawProjectData>): Project => {
return {
createdAt: date(),
updatedAt: date(),
id: nanoId.nanoid(),
name: projectName,
type: 'personal' as ProjectType,
...payload,
} as Project;
};
5 changes: 5 additions & 0 deletions packages/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const LICENSE_FEATURES = {
AI_ASSISTANT: 'feat:aiAssistant',
ASK_AI: 'feat:askAi',
COMMUNITY_NODES_CUSTOM_REGISTRY: 'feat:communityNodes:customRegistry',
AI_CREDITS: 'feat:aiCredits',
} as const;

export const LICENSE_QUOTAS = {
Expand All @@ -101,6 +102,7 @@ export const LICENSE_QUOTAS = {
USERS_LIMIT: 'quota:users',
WORKFLOW_HISTORY_PRUNE_LIMIT: 'quota:workflowHistoryPrune',
TEAM_PROJECT_LIMIT: 'quota:maxTeamProjects',
AI_CREDITS: 'quota:aiCredits',
} as const;
export const UNLIMITED_LICENSE_QUOTA = -1;

Expand Down Expand Up @@ -174,3 +176,6 @@ export const WsStatusCodes = {
CloseAbnormal: 1006,
CloseInvalidData: 1007,
} as const;

export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits';
export const OPEN_AI_API_CREDENTIAL_TYPE = 'openAiApi';
3 changes: 2 additions & 1 deletion packages/cli/src/controllers/__tests__/ai.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import { AiController, type FlushableResponse } from '../ai.controller';

describe('AiController', () => {
const aiService = mock<AiService>();
const controller = new AiController(aiService);

const controller = new AiController(aiService, mock(), mock());

const request = mock<AuthenticatedRequest>({
user: { id: 'user123' },
Expand Down
49 changes: 47 additions & 2 deletions packages/cli/src/controllers/ai.controller.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
import { AiChatRequestDto, AiApplySuggestionRequestDto, AiAskRequestDto } from '@n8n/api-types';
import {
AiChatRequestDto,
AiApplySuggestionRequestDto,
AiAskRequestDto,
AiFreeCreditsRequestDto,
} from '@n8n/api-types';
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { Response } from 'express';
import { strict as assert } from 'node:assert';
import { WritableStream } from 'node:stream/web';

import { FREE_AI_CREDITS_CREDENTIAL_NAME, OPEN_AI_API_CREDENTIAL_TYPE } from '@/constants';
import { CredentialsService } from '@/credentials/credentials.service';
import { Body, Post, RestController } from '@/decorators';
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
import type { CredentialRequest } from '@/requests';
import { AuthenticatedRequest } from '@/requests';
import { AiService } from '@/services/ai.service';
import { UserService } from '@/services/user.service';

export type FlushableResponse = Response & { flush: () => void };

@RestController('/ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
constructor(
private readonly aiService: AiService,
private readonly credentialsService: CredentialsService,
private readonly userService: UserService,
) {}

@Post('/chat', { rateLimit: { limit: 100 } })
async chat(req: AuthenticatedRequest, res: FlushableResponse, @Body payload: AiChatRequestDto) {
Expand Down Expand Up @@ -64,4 +77,36 @@ export class AiController {
throw new InternalServerError(e.message, e);
}
}

@Post('/free-credits')
async aiCredits(req: AuthenticatedRequest, _: Response, @Body payload: AiFreeCreditsRequestDto) {
try {
const aiCredits = await this.aiService.createFreeAiCredits(req.user);

const credentialProperties: CredentialRequest.CredentialProperties = {
name: FREE_AI_CREDITS_CREDENTIAL_NAME,
type: OPEN_AI_API_CREDENTIAL_TYPE,
data: {
apiKey: aiCredits.apiKey,
url: aiCredits.url,
},
isManaged: true,
projectId: payload?.projectId,
};

const newCredential = await this.credentialsService.createCredential(
credentialProperties,
req.user,
);

await this.userService.updateSettings(req.user.id, {
userClaimedAiCredits: true,
});

return newCredential;
} catch (e) {
assert(e instanceof Error);
throw new InternalServerError(e.message, e);
}
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/controllers/e2e.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export class E2EController {
[LICENSE_FEATURES.AI_ASSISTANT]: false,
[LICENSE_FEATURES.COMMUNITY_NODES_CUSTOM_REGISTRY]: false,
[LICENSE_FEATURES.ASK_AI]: false,
[LICENSE_FEATURES.AI_CREDITS]: false,
};

private numericFeatures: Record<NumericLicenseFeature, number> = {
Expand All @@ -108,6 +109,7 @@ export class E2EController {
[LICENSE_QUOTAS.USERS_LIMIT]: -1,
[LICENSE_QUOTAS.WORKFLOW_HISTORY_PRUNE_LIMIT]: -1,
[LICENSE_QUOTAS.TEAM_PROJECT_LIMIT]: 0,
[LICENSE_QUOTAS.AI_CREDITS]: 0,
};

constructor(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { mock } from 'jest-mock-extended';

import { createRawProjectData } from '@/__tests__/project.test-data';
import type { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import type { EventService } from '@/events/event.service';
import type { AuthenticatedRequest } from '@/requests';

import { createdCredentialsWithScopes, createNewCredentialsPayload } from './credentials.test-data';
import { CredentialsController } from '../credentials.controller';
import type { CredentialsService } from '../credentials.service';

describe('CredentialsController', () => {
const eventService = mock<EventService>();
const credentialsService = mock<CredentialsService>();
const sharedCredentialsRepository = mock<SharedCredentialsRepository>();

const credentialsController = new CredentialsController(
mock(),
credentialsService,
mock(),
mock(),
mock(),
mock(),
mock(),
sharedCredentialsRepository,
mock(),
eventService,
);

let req: AuthenticatedRequest;
beforeAll(() => {
req = { user: { id: '123' } } as AuthenticatedRequest;
});

describe('createCredentials', () => {
it('it should create new credentials and emit "credentials-created"', async () => {
// Arrange

const newCredentialsPayload = createNewCredentialsPayload();

req.body = newCredentialsPayload;

const { data, ...payloadWithoutData } = newCredentialsPayload;

const createdCredentials = createdCredentialsWithScopes(payloadWithoutData);

const projectOwningCredentialData = createRawProjectData({
id: newCredentialsPayload.projectId,
});

credentialsService.createCredential.mockResolvedValue(createdCredentials);

sharedCredentialsRepository.findCredentialOwningProject.mockResolvedValue(
projectOwningCredentialData,
);

// Act

const newApiKey = await credentialsController.createCredentials(req);

// Assert

expect(credentialsService.createCredential).toHaveBeenCalledWith(
newCredentialsPayload,
req.user,
);
expect(sharedCredentialsRepository.findCredentialOwningProject).toHaveBeenCalledWith(
createdCredentials.id,
);
expect(eventService.emit).toHaveBeenCalledWith('credentials-created', {
user: expect.objectContaining({ id: req.user.id }),
credentialId: createdCredentials.id,
credentialType: createdCredentials.type,
projectId: projectOwningCredentialData.id,
projectType: projectOwningCredentialData.type,
publicApi: false,
});

expect(newApiKey).toEqual(createdCredentials);
});
});
});
69 changes: 69 additions & 0 deletions packages/cli/src/credentials/__tests__/credentials.service.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { mock } from 'jest-mock-extended';
import { nanoId, date } from 'minifaker';
import { CREDENTIAL_EMPTY_VALUE, type ICredentialType } from 'n8n-workflow';

import { CREDENTIAL_BLANKING_VALUE } from '@/constants';
import type { CredentialTypes } from '@/credential-types';
import { CredentialsService } from '@/credentials/credentials.service';
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
import type { AuthenticatedRequest } from '@/requests';

import { createNewCredentialsPayload, credentialScopes } from './credentials.test-data';

let req = { user: { id: '123' } } as AuthenticatedRequest;

describe('CredentialsService', () => {
const credType = mock<ICredentialType>({
Expand Down Expand Up @@ -68,4 +74,67 @@ describe('CredentialsService', () => {
});
});
});

describe('createCredential', () => {
it('it should create new credentials and return with scopes', async () => {
// Arrange

const encryptedData = 'encryptedData';

const newCredentialPayloadData = createNewCredentialsPayload();

const newCredential = mock<CredentialsEntity>({
name: newCredentialPayloadData.name,
data: JSON.stringify(newCredentialPayloadData.data),
type: newCredentialPayloadData.type,
});

const encryptedDataResponse = {
name: newCredentialPayloadData.name,
type: newCredentialPayloadData.type,
updatedAt: date(),
data: encryptedData,
};

const saveCredentialsResponse = {
id: nanoId.nanoid(),
name: newCredentialPayloadData.name,
type: newCredentialPayloadData.type,
updatedAt: encryptedDataResponse.updatedAt,
createdAt: date(),
data: encryptedDataResponse.data,
isManaged: false,
shared: undefined,
};

service.prepareCreateData = jest.fn().mockReturnValue(newCredential);
service.createEncryptedData = jest.fn().mockImplementation(() => encryptedDataResponse);
service.save = jest.fn().mockResolvedValue(saveCredentialsResponse);
service.getCredentialScopes = jest.fn().mockReturnValue(credentialScopes);

// Act

const createdCredential = await service.createCredential(newCredentialPayloadData, req.user);

// Assert

expect(service.prepareCreateData).toHaveBeenCalledWith(newCredentialPayloadData);
expect(service.createEncryptedData).toHaveBeenCalledWith(null, newCredential);
expect(service.save).toHaveBeenCalledWith(
newCredential,
encryptedDataResponse,
req.user,
newCredentialPayloadData.projectId,
);
expect(service.getCredentialScopes).toHaveBeenCalledWith(
req.user,
saveCredentialsResponse.id,
);

expect(createdCredential).toEqual({
...saveCredentialsResponse,
scopes: credentialScopes,
});
});
});
});
Loading

0 comments on commit 0d01e39

Please sign in to comment.