diff --git a/package.json b/package.json index 1d1801b584..f75505f928 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,7 @@ "@types/owasp-password-strength-test": "1.3.2", "@types/pg": "8.11.5", "@types/semver": "7.5.8", + "@types/slug": "^5.0.8", "@types/stoppable": "1.1.3", "@types/supertest": "6.0.2", "@types/type-is": "1.6.6", @@ -202,6 +203,7 @@ "nock": "13.5.4", "openapi-enforcer": "1.23.0", "proxyquire": "2.1.3", + "slug": "^9.0.0", "source-map-support": "0.5.21", "superagent": "9.0.2", "supertest": "6.3.4", diff --git a/src/lib/features/project/project-service.test.ts b/src/lib/features/project/project-service.test.ts index 4ca7f09b58..3e4429c543 100644 --- a/src/lib/features/project/project-service.test.ts +++ b/src/lib/features/project/project-service.test.ts @@ -1,3 +1,4 @@ +import fc from 'fast-check'; import { createTestConfig } from '../../../test/config/test-config'; import { BadDataError } from '../../error'; import { type IBaseEvent, RoleName, TEST_AUDIT_USER } from '../../types'; @@ -301,3 +302,29 @@ describe('enterprise extension: enable change requests', () => { ).resolves.toBeTruthy(); }); }); + +describe('project ID generation', () => { + const createService = () => { + const config = createTestConfig(); + const service = createFakeProjectService(config); + + return service; + }; + + test('the name that comes out is always url friendly', () => { + const service = createService(); + fc.assert( + fc.property(fc.string(), (name) => { + const projectId = service.generateProjectId(name); + expect(projectId).toMatch(encodeURIComponent(projectId)); + }), + ); + }); + + test('it adds a 12 character hex to the end of the id', () => { + const service = createService(); + + const projectId = service.generateProjectId('one'); + expect(projectId).toMatch(/^one-[a-f0-9]{12}$/); + }); +}); diff --git a/src/lib/features/project/project-service.ts b/src/lib/features/project/project-service.ts index e18ebcd146..f8e818d505 100644 --- a/src/lib/features/project/project-service.ts +++ b/src/lib/features/project/project-service.ts @@ -1,5 +1,6 @@ import { subDays } from 'date-fns'; import { ValidationError } from 'joi'; +import slug from 'slug'; import type { IAuditUser, IUser } from '../../types/user'; import type { AccessService, @@ -60,7 +61,7 @@ import type { import type FeatureToggleService from '../feature-toggle/feature-toggle-service'; import IncompatibleProjectError from '../../error/incompatible-project-error'; import ProjectWithoutOwnerError from '../../error/project-without-owner-error'; -import { arraysHaveSameItems } from '../../util'; +import { arraysHaveSameItems, randomId } from '../../util'; import type { GroupService } from '../../services/group-service'; import type { IGroupRole } from '../../types/group'; import type { FavoritesService } from '../../services/favorites-service'; @@ -294,6 +295,13 @@ export default class ProjectService { } } + generateProjectId(name: string): string { + const urlFriendly = slug(name); + const tail = randomId().slice(-12); + const id = `${urlFriendly}-${tail}`; + return id; + } + async createProject( newProject: CreateProject, user: IUser, diff --git a/yarn.lock b/yarn.lock index 211cd39656..21085f2bfd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1668,6 +1668,11 @@ "@types/mime" "*" "@types/node" "*" +"@types/slug@^5.0.8": + version "5.0.8" + resolved "https://registry.yarnpkg.com/@types/slug/-/slug-5.0.8.tgz#76d40f033a8a99793f3dcbfb3f187ab9014f9d47" + integrity sha512-mblTWR1OST257k1gZ3QvqG+ERSr8Ea6dyM1FH6Jtm4jeXi0/r0/95VNctofuiywPxCVQuE8AuFoqmvJ4iVUlXQ== + "@types/ssri@*": version "7.1.1" resolved "https://registry.yarnpkg.com/@types/ssri/-/ssri-7.1.1.tgz#2a2c94abf0d3a8c3b07bb4ff08142dd571407bb5" @@ -6598,6 +6603,11 @@ slice-ansi@^7.0.0: ansi-styles "^6.2.1" is-fullwidth-code-point "^5.0.0" +slug@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/slug/-/slug-9.0.0.tgz#68f968a79ce5156c6606b7b2e233ed0ffab94bdf" + integrity sha512-ixytnHlpHPWM56heaGgYe/M8tDAcpJcsg/zBuyElbFDOORzMGOeP3Te6iJBRVYu3WQEiWLQPb70Gh9ig/sZgGQ== + slugify@~1.4.7: version "1.4.7" resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.4.7.tgz#e42359d505afd84a44513280868e31202a79a628" @@ -6793,16 +6803,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6841,14 +6842,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -7475,7 +7469,7 @@ wordwrap@>=0.0.2: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -7493,15 +7487,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"