ci: Include THIRD_PARTY_LICENSES.md file with release (#18739)

This commit is contained in:
Artem Sorokin
2025-09-01 12:41:42 +02:00
committed by GitHub
parent daac88b3ec
commit 168ac0e9f2
16 changed files with 729 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
# Third-Party Licenses
**Note**: This is a placeholder file used during local development. The complete third-party licenses file is generated during production builds and contains detailed licensing information for all dependencies.
For the complete list of third-party licenses, please refer to the production build.

View File

@@ -0,0 +1,25 @@
import { CLI_DIR } from '@/constants';
import { Get, RestController } from '@n8n/decorators';
import { Request, Response } from 'express';
import { readFile } from 'fs/promises';
import { resolve } from 'path';
@RestController('/third-party-licenses')
export class ThirdPartyLicensesController {
/**
* Get third-party licenses content
* Requires authentication to access
*/
@Get('/')
async getThirdPartyLicenses(_: Request, res: Response) {
const licenseFile = resolve(CLI_DIR, 'THIRD_PARTY_LICENSES.md');
try {
const content = await readFile(licenseFile, 'utf-8');
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
res.send(content);
} catch {
res.status(404).send('Third-party licenses file not found');
}
}
}

View File

@@ -91,6 +91,7 @@ export class Server extends AbstractServer {
const { FrontendService } = await import('@/services/frontend.service');
this.frontendService = Container.get(FrontendService);
await import('@/controllers/module-settings.controller');
await import('@/controllers/third-party-licenses.controller');
}
this.presetCredentialsLoaded = false;

View File

@@ -0,0 +1,96 @@
import { createMember, createOwner } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';
jest.mock('fs/promises', () => ({
readFile: jest.fn(),
}));
import { readFile } from 'fs/promises';
const mockReadFile = readFile as jest.MockedFunction<typeof readFile>;
describe('ThirdPartyLicensesController', () => {
const testServer = setupTestServer({ endpointGroups: ['third-party-licenses'] });
let ownerAgent: SuperAgentTest;
let memberAgent: SuperAgentTest;
beforeAll(async () => {
const owner = await createOwner();
const member = await createMember();
ownerAgent = testServer.authAgentFor(owner);
memberAgent = testServer.authAgentFor(member);
});
describe('GET /third-party-licenses', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should require authentication', async () => {
await testServer.authlessAgent.get('/third-party-licenses').expect(401);
});
describe('when license file exists', () => {
beforeEach(() => {
mockReadFile.mockResolvedValue('# Third Party Licenses\n\nSome license content...');
});
it('should allow authenticated owner to get third-party licenses', async () => {
const response = await ownerAgent.get('/third-party-licenses');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toMatch(/text\/markdown/);
expect(response.text).toBe('# Third Party Licenses\n\nSome license content...');
});
it('should allow authenticated member to get third-party licenses', async () => {
const response = await memberAgent.get('/third-party-licenses');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toMatch(/text\/markdown/);
expect(response.text).toBe('# Third Party Licenses\n\nSome license content...');
});
});
describe('when license file does not exist', () => {
beforeEach(() => {
mockReadFile.mockRejectedValue(new Error('ENOENT: no such file or directory'));
});
it('should return 404 for authenticated owner', async () => {
const response = await ownerAgent.get('/third-party-licenses');
expect(response.status).toBe(404);
expect(response.text).toBe('Third-party licenses file not found');
});
it('should return 404 for authenticated member', async () => {
const response = await memberAgent.get('/third-party-licenses');
expect(response.status).toBe(404);
expect(response.text).toBe('Third-party licenses file not found');
});
});
describe('when file read fails with other errors', () => {
beforeEach(() => {
mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'));
});
it('should return 404 for permission errors', async () => {
const response = await ownerAgent.get('/third-party-licenses');
expect(response.status).toBe(404);
expect(response.text).toBe('Third-party licenses file not found');
});
});
describe('file path resolution', () => {
it('should request the correct file path', async () => {
mockReadFile.mockResolvedValue('test content');
await ownerAgent.get('/third-party-licenses');
expect(mockReadFile).toHaveBeenCalledWith(
expect.stringMatching(/THIRD_PARTY_LICENSES\.md$/),
'utf-8',
);
});
});
});
});

View File

@@ -43,8 +43,10 @@ type EndpointGroup =
| 'ai'
| 'folder'
| 'insights'
| 'data-store'
| 'module-settings'
| 'data-table'
| 'module-settings';
| 'third-party-licenses';
type ModuleName = 'insights' | 'external-secrets' | 'community-packages' | 'data-table';

View File

@@ -313,6 +313,10 @@ export const setupTestServer = ({
case 'module-settings':
await import('@/controllers/module-settings.controller');
break;
case 'third-party-licenses':
await import('@/controllers/third-party-licenses.controller');
break;
}
}

View File

@@ -123,6 +123,9 @@
"about.debug.message": "Copy debug information",
"about.debug.toast.title": "Debug info",
"about.debug.toast.message": "Copied debug info to clipboard",
"about.thirdPartyLicenses": "Third-Party Licenses",
"about.thirdPartyLicensesLink": "View all third-party licenses",
"about.thirdPartyLicenses.downloadError": "Failed to download third-party licenses file",
"askAi.dialog.title": "'Ask AI' is almost ready",
"askAi.dialog.body": "Were still applying the finishing touches. Soon, you will be able to <strong>automatically generate code from simple text prompts</strong>. Join the waitlist to get early access to this feature.",
"askAi.dialog.signup": "Join Waitlist",

View File

@@ -16,6 +16,7 @@ export * from './module-settings';
export * from './sso';
export type * from './tags';
export * from './templates';
export * from './third-party-licenses';
export * from './ui';
export * from './users';
export * from './versions';

View File

@@ -0,0 +1,10 @@
import type { IRestApiContext } from '../types';
import { request } from '../utils';
export async function getThirdPartyLicenses(context: IRestApiContext): Promise<string> {
return await request({
method: 'GET',
baseURL: context.baseUrl,
endpoint: '/third-party-licenses',
});
}

View File

@@ -7,6 +7,7 @@ import { useToast } from '@/composables/useToast';
import { useClipboard } from '@/composables/useClipboard';
import { useDebugInfo } from '@/composables/useDebugInfo';
import { useI18n } from '@n8n/i18n';
import { getThirdPartyLicenses } from '@n8n/rest-api-client';
const modalBus = createEventBus();
const toast = useToast();
@@ -19,6 +20,23 @@ const closeDialog = () => {
modalBus.emit('close');
};
const downloadThirdPartyLicenses = async () => {
try {
const thirdPartyLicenses = await getThirdPartyLicenses(rootStore.restApiContext);
const blob = new File([thirdPartyLicenses], 'THIRD_PARTY_LICENSES.md', {
type: 'text/markdown',
});
window.open(URL.createObjectURL(blob));
} catch (error) {
toast.showToast({
title: i18n.baseText('about.thirdPartyLicenses.downloadError'),
message: error.message,
type: 'error',
});
}
};
const copyDebugInfoToClipboard = async () => {
toast.showToast({
title: i18n.baseText('about.debug.toast.title'),
@@ -66,6 +84,16 @@ const copyDebugInfoToClipboard = async () => {
</n8n-link>
</el-col>
</el-row>
<el-row>
<el-col :span="8" class="info-name">
<n8n-text>{{ i18n.baseText('about.thirdPartyLicenses') }}</n8n-text>
</el-col>
<el-col :span="16">
<n8n-link @click="downloadThirdPartyLicenses">
{{ i18n.baseText('about.thirdPartyLicensesLink') }}
</n8n-link>
</el-col>
</el-row>
<el-row>
<el-col :span="8" class="info-name">
<n8n-text>{{ i18n.baseText('about.instanceID') }}</n8n-text>