1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00

feat: normalize urls in feature links (#9911)

This commit is contained in:
Mateusz Kwasniewski 2025-05-06 19:08:04 +02:00 committed by GitHub
parent 28373f5e37
commit 20a80142d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 94 additions and 26 deletions

View File

@ -153,6 +153,7 @@
"murmurhash3js": "^3.0.1", "murmurhash3js": "^3.0.1",
"mustache": "^4.1.0", "mustache": "^4.1.0",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
"normalize-url": "^6.1.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"owasp-password-strength-test": "^1.3.0", "owasp-password-strength-test": "^1.3.0",
"parse-database-url": "^0.3.0", "parse-database-url": "^0.3.0",

View File

@ -1,7 +1,7 @@
import { createFakeFeatureLinkService } from './createFeatureLinkService'; import { createFakeFeatureLinkService } from './createFeatureLinkService';
import type { IAuditUser, IUnleashConfig } from '../../types'; import type { IAuditUser, IUnleashConfig } from '../../types';
import getLogger from '../../../test/fixtures/no-logger'; import getLogger from '../../../test/fixtures/no-logger';
import { NotFoundError } from '../../error'; import { BadDataError, NotFoundError } from '../../error';
test('create, update and delete feature link', async () => { test('create, update and delete feature link', async () => {
const { featureLinkStore, featureLinkService } = const { featureLinkStore, featureLinkService } =
@ -16,18 +16,22 @@ test('create, update and delete feature link', async () => {
); );
expect(link).toMatchObject({ expect(link).toMatchObject({
featureName: 'feature', featureName: 'feature',
url: 'example.com', url: 'https://example.com',
title: 'some title', title: 'some title',
}); });
const newLink = await featureLinkService.updateLink( const newLink = await featureLinkService.updateLink(
{ projectId: 'default', linkId: link.id }, { projectId: 'default', linkId: link.id },
{ title: 'new title', url: 'example1.com', featureName: 'feature' }, {
title: 'new title',
url: 'https://example1.com',
featureName: 'feature',
},
{} as IAuditUser, {} as IAuditUser,
); );
expect(newLink).toMatchObject({ expect(newLink).toMatchObject({
featureName: 'feature', featureName: 'feature',
url: 'example1.com', url: 'https://example1.com',
title: 'new title', title: 'new title',
}); });
@ -39,22 +43,55 @@ test('create, update and delete feature link', async () => {
}); });
test('cannot delete/update non existent link', async () => { test('cannot delete/update non existent link', async () => {
const { featureLinkStore, featureLinkService } = const { featureLinkService } = createFakeFeatureLinkService({
createFakeFeatureLinkService({
getLogger, getLogger,
} as unknown as IUnleashConfig); } as unknown as IUnleashConfig);
await expect( await expect(
featureLinkService.updateLink( featureLinkService.updateLink(
{ projectId: 'default', linkId: 'nonexitent' }, { projectId: 'default', linkId: 'nonexistent' },
{ title: 'new title', url: 'example1.com', featureName: 'feature' }, {
title: 'new title',
url: 'https://example1.com',
featureName: 'feature',
},
{} as IAuditUser, {} as IAuditUser,
), ),
).rejects.toThrow(NotFoundError); ).rejects.toThrow(NotFoundError);
await expect( await expect(
featureLinkService.deleteLink( featureLinkService.deleteLink(
{ projectId: 'default', linkId: 'nonexitent' }, { projectId: 'default', linkId: 'nonexistent' },
{} as IAuditUser, {} as IAuditUser,
), ),
).rejects.toThrow(NotFoundError); ).rejects.toThrow(NotFoundError);
}); });
test('cannot create/update invalid link', async () => {
const { featureLinkService } = createFakeFeatureLinkService({
getLogger,
} as unknown as IUnleashConfig);
await expect(
featureLinkService.createLink(
'irrelevant',
{
featureName: 'irrelevant',
url: '%example.com',
title: 'irrelevant',
},
{} as IAuditUser,
),
).rejects.toThrow(BadDataError);
await expect(
featureLinkService.updateLink(
{ projectId: 'irrelevant', linkId: 'irrelevant' },
{
title: 'irrelevant',
url: '%example.com',
featureName: 'irrelevant',
},
{} as IAuditUser,
),
).rejects.toThrow(BadDataError);
});

View File

@ -1,17 +1,18 @@
import type { Logger } from '../../logger'; import type { Logger } from '../../logger';
import { import {
type IUnleashConfig,
type IAuditUser,
FeatureLinkAddedEvent, FeatureLinkAddedEvent,
FeatureLinkUpdatedEvent,
FeatureLinkRemovedEvent, FeatureLinkRemovedEvent,
FeatureLinkUpdatedEvent,
type IAuditUser,
type IUnleashConfig,
} from '../../types'; } from '../../types';
import type { import type {
IFeatureLink, IFeatureLink,
IFeatureLinkStore, IFeatureLinkStore,
} from './feature-link-store-type'; } from './feature-link-store-type';
import type EventService from '../events/event-service'; import type EventService from '../events/event-service';
import { NotFoundError } from '../../error'; import { BadDataError, NotFoundError } from '../../error';
import normalizeUrl from 'normalize-url';
interface IFeatureLinkStoreObj { interface IFeatureLinkStoreObj {
featureLinkStore: IFeatureLinkStore; featureLinkStore: IFeatureLinkStore;
@ -36,18 +37,31 @@ export default class FeatureLinkService {
return this.featureLinkStore.getAll(); return this.featureLinkStore.getAll();
} }
private normalize(url: string) {
try {
return normalizeUrl(url, { defaultProtocol: 'https:' });
} catch (e) {
throw new BadDataError(`Invalid URL: ${url}`);
}
}
async createLink( async createLink(
projectId: string, projectId: string,
newLink: Omit<IFeatureLink, 'id'>, newLink: Omit<IFeatureLink, 'id'>,
auditUser: IAuditUser, auditUser: IAuditUser,
): Promise<IFeatureLink> { ): Promise<IFeatureLink> {
const link = await this.featureLinkStore.insert(newLink); const normalizedUrl = this.normalize(newLink.url);
const link = await this.featureLinkStore.insert({
...newLink,
url: normalizedUrl,
});
await this.eventService.storeEvent( await this.eventService.storeEvent(
new FeatureLinkAddedEvent({ new FeatureLinkAddedEvent({
featureName: newLink.featureName, featureName: newLink.featureName,
project: projectId, project: projectId,
data: { url: newLink.url, title: newLink.title }, data: { url: normalizedUrl, title: newLink.title },
auditUser, auditUser,
}), }),
); );
@ -60,19 +74,24 @@ export default class FeatureLinkService {
updatedLink: Omit<IFeatureLink, 'id'>, updatedLink: Omit<IFeatureLink, 'id'>,
auditUser: IAuditUser, auditUser: IAuditUser,
): Promise<IFeatureLink> { ): Promise<IFeatureLink> {
const normalizedUrl = this.normalize(updatedLink.url);
const preData = await this.featureLinkStore.get(linkId); const preData = await this.featureLinkStore.get(linkId);
if (!preData) { if (!preData) {
throw new NotFoundError(`Could not find link with id ${linkId}`); throw new NotFoundError(`Could not find link with id ${linkId}`);
} }
const link = await this.featureLinkStore.update(linkId, updatedLink); const link = await this.featureLinkStore.update(linkId, {
...updatedLink,
url: normalizedUrl,
});
await this.eventService.storeEvent( await this.eventService.storeEvent(
new FeatureLinkUpdatedEvent({ new FeatureLinkUpdatedEvent({
featureName: updatedLink.featureName, featureName: updatedLink.featureName,
project: projectId, project: projectId,
data: { url: link.url, title: link.title }, data: { url: normalizedUrl, title: link.title },
preData: { url: preData.url, title: preData.title }, preData: { url: preData.url, title: preData.title },
auditUser, auditUser,
}), }),

View File

@ -92,14 +92,14 @@ test('should manage feature links', async () => {
const links = await featureLinkStore.getAll(); const links = await featureLinkStore.getAll();
expect(links).toMatchObject([ expect(links).toMatchObject([
{ {
url: 'example.com', url: 'https://example.com',
title: 'feature link', title: 'feature link',
featureName: 'my_feature', featureName: 'my_feature',
}, },
]); ]);
const { body } = await app.getProjectFeatures('default', 'my_feature'); const { body } = await app.getProjectFeatures('default', 'my_feature');
expect(body.links).toMatchObject([ expect(body.links).toMatchObject([
{ id: links[0].id, title: 'feature link', url: 'example.com' }, { id: links[0].id, title: 'feature link', url: 'https://example.com' },
]); ]);
await updatedLink('my_feature', links[0].id, { await updatedLink('my_feature', links[0].id, {
@ -110,7 +110,7 @@ test('should manage feature links', async () => {
const updatedLinks = await featureLinkStore.getAll(); const updatedLinks = await featureLinkStore.getAll();
expect(updatedLinks).toMatchObject([ expect(updatedLinks).toMatchObject([
{ {
url: 'example_updated.com', url: 'https://example_updated.com',
title: 'feature link updated', title: 'feature link updated',
featureName: 'my_feature', featureName: 'my_feature',
}, },
@ -127,7 +127,7 @@ test('should manage feature links', async () => {
type: 'feature-link-removed', type: 'feature-link-removed',
data: null, data: null,
preData: { preData: {
url: 'example_updated.com', url: 'https://example_updated.com',
title: 'feature link updated', title: 'feature link updated',
}, },
featureName: 'my_feature', featureName: 'my_feature',
@ -135,14 +135,17 @@ test('should manage feature links', async () => {
}, },
{ {
type: 'feature-link-updated', type: 'feature-link-updated',
data: { url: 'example_updated.com', title: 'feature link updated' }, data: {
preData: { url: 'example.com', title: 'feature link' }, url: 'https://example_updated.com',
title: 'feature link updated',
},
preData: { url: 'https://example.com', title: 'feature link' },
featureName: 'my_feature', featureName: 'my_feature',
project: 'default', project: 'default',
}, },
{ {
type: 'feature-link-added', type: 'feature-link-added',
data: { url: 'example.com', title: 'feature link' }, data: { url: 'https://example.com', title: 'feature link' },
preData: null, preData: null,
featureName: 'my_feature', featureName: 'my_feature',
project: 'default', project: 'default',

View File

@ -7100,6 +7100,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"normalize-url@npm:^6.1.0":
version: 6.1.0
resolution: "normalize-url@npm:6.1.0"
checksum: 10c0/95d948f9bdd2cfde91aa786d1816ae40f8262946e13700bf6628105994fe0ff361662c20af3961161c38a119dc977adeb41fc0b41b1745eb77edaaf9cb22db23
languageName: node
linkType: hard
"npm-run-path@npm:^4.0.1": "npm-run-path@npm:^4.0.1":
version: 4.0.1 version: 4.0.1
resolution: "npm-run-path@npm:4.0.1" resolution: "npm-run-path@npm:4.0.1"
@ -9457,6 +9464,7 @@ __metadata:
mustache: "npm:^4.1.0" mustache: "npm:^4.1.0"
nock: "npm:13.5.6" nock: "npm:13.5.6"
nodemailer: "npm:^6.9.9" nodemailer: "npm:^6.9.9"
normalize-url: "npm:^6.1.0"
openapi-enforcer: "npm:1.23.0" openapi-enforcer: "npm:1.23.0"
openapi-types: "npm:^12.1.3" openapi-types: "npm:^12.1.3"
owasp-password-strength-test: "npm:^1.3.0" owasp-password-strength-test: "npm:^1.3.0"