From f518eab778a64aa97a2fc9733ff2d70419ef9c05 Mon Sep 17 00:00:00 2001 From: shikanime Date: Wed, 26 Aug 2026 00:43:03 +0200 Subject: [PATCH 1/5] test(server-nestjs): adversarial edge coverage shared utils Co-authored-by: Automata --- .../src/config/config.utils.spec.ts | 107 +++++++++++++- .../project-members.utils.spec.ts | 64 +++++++++ .../project/project-queries.utils.spec.ts | 134 ++++++++++++++++++ .../src/modules/vault/vault.utils.spec.ts | 48 ++++++- .../src/utils/http.utils.spec.ts | 110 ++++++++++++++ 5 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts create mode 100644 apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts create mode 100644 apps/server-nestjs/src/utils/http.utils.spec.ts diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts index 155c025349..d26c44bafa 100644 --- a/apps/server-nestjs/src/config/config.utils.spec.ts +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import z from 'zod' -import { csv, flag, truthySchema } from './config.utils' +import { cronSchema, csv, flag, truthySchema } from './config.utils' describe('config.utils', () => { describe('flag', () => { @@ -34,5 +34,110 @@ describe('config.utils', () => { expect(schema.parse(undefined)).toEqual([]) expect(schema.parse('')).toEqual([]) }) + + it('silently coerces null to [] but rejects non-string scalars', () => { + // null → '' → [] (env-absent case); numbers/objects are type errors (zod string check) + expect(schema.parse(null)).toEqual([]) + expect(() => schema.parse(123)).toThrow(z.ZodError) + }) + + it('validates each element against the element schema', () => { + const restricted = csv(z.enum(['A', 'B'])) + expect(restricted.parse(' A ,, B ')).toEqual(['A', 'B']) + expect(() => restricted.parse('A,C')).toThrow(z.ZodError) + }) + + it('handles oversized lists without choking', () => { + const big = Array.from({ length: 5000 }, (_, i) => `item-${i}`).join(',') + expect(schema.parse(big)).toHaveLength(5000) + }) + }) + + describe('truthySchema', () => { + it('accepts only the four literal tokens', () => { + for (const v of ['true', 'false', '1', '0']) expect(truthySchema.parse(v)).toBe(v) + for (const v of ['TRUE', 'True', 'yes', '', 'on', '2']) expect(() => truthySchema.parse(v)).toThrow(z.ZodError) + }) + }) + + describe('flag', () => { + it('rejects non-string input instead of coercing', () => { + expect(() => flag(truthySchema.default('true')).parse(true as never)).toThrow(z.ZodError) + expect(() => flag(truthySchema.default('true')).parse(1 as never)).toThrow(z.ZodError) + }) + + it('does not trim whitespace before matching', () => { + expect(() => flag(truthySchema.default('true')).parse(' true')).toThrow(z.ZodError) + }) + }) + + describe('cronSchema', () => { + const valid = [ + '* * * * * *', + '0/5 * * * * *', + '*/15 * * * * *', + '? ? ? ? ? ?', + '0,15,30,45 * * * * ?', + '0 0 12 ? * MON-FRI', + '0 0 12 ? * mon-fri', + '0 0 0 1 JAN-MAR/2 ?', + '0 0 0 * * 7', + '\t0\t0\t12\t*\t*\t?\n', + ] + it.each(valid)('accepts %j', (expr) => { + expect(cronSchema.safeParse(expr).success).toBe(true) + }) + + const invalid = [ + '', + '* * * * *', + '* * * * * * *', + '*/0 * * * * *', + '60 * * * * *', + '* 60 * * * *', + '* * 24 * * *', + '* * * 32 * *', + '* * * * 13 *', + '* * * * * 8', + '59-0 * * * * *', + 'MON * * * * *', + 'JAN-MAR * * * * *', + '*/x * * * * *', + '5- * * * * *', + ] + it.each(invalid)('rejects %j', (expr) => { + expect(cronSchema.safeParse(expr).success).toBe(false) + }) + + it('accepts Quartz ? in day-of-month field', () => { + // ponytail-bug: config.utils.ts:3 claims stdlib-only 5-field semantics, yet '?' passes + // the DOM bounds check. Locked as accepted — Harbor retention triggers use Quartz syntax + // upstream, so rejecting it would break real configs. + expect(cronSchema.safeParse('0 0 0 ?,5 * *').success).toBe(true) + }) + + it('requires exactly six fields even when padded', () => { + expect(cronSchema.safeParse(' * * * * * ').success).toBe(false) + }) + + // ponytail-bug: Number('')===0 and split('-') destructuring let malformed numeric + // items slip through ('-5-3', '1-5-10', empty comma parts in low-bound fields). + // Locked here; fix belongs in isRangeOrNumber/isCronItem. + describe('documented lenience (current behavior)', () => { + it('accepts a negative-looking range -5-3 as a seconds item', () => { + expect(cronSchema.safeParse('-5-3 * * * * *').success).toBe(true) + }) + + it('accepts a triple range 1-5-10 (extra segment ignored)', () => { + expect(cronSchema.safeParse('1-5-10 * * * * *').success).toBe(true) + }) + + it('accepts empty comma parts in seconds/minutes but not day fields', () => { + expect(cronSchema.safeParse('0,,30 * * * * *').success).toBe(true) + expect(cronSchema.safeParse('* ,,* * * * *').success).toBe(true) + expect(cronSchema.safeParse(',,, *,* *,* *,* *,* *,*').success).toBe(true) + expect(cronSchema.safeParse('0 0 0 ,,5 * *').success).toBe(false) + }) + }) }) }) diff --git a/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts b/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts new file mode 100644 index 0000000000..3ef3008094 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { generateProjectMember } from './project-members.utils' + +const baseUser = { + id: 'u1', + email: 'u1@dso.fr', + firstName: 'Jean', + lastName: 'Dupont', + adminRoleIds: ['admin-1'], + type: 'user', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-02'), + lastLogin: new Date('2026-01-03'), +} as const + +describe('generateProjectMember', () => { + it('projects exactly the Member shape (id, roleIds, identity)', () => { + expect(generateProjectMember({ userId: 'm1', roleIds: ['r1', 'r2'], user: { ...baseUser } })).toEqual({ + userId: 'u1', + roleIds: ['r1', 'r2'], + email: 'u1@dso.fr', + firstName: 'Jean', + lastName: 'Dupont', + }) + }) + + it('drops internal user fields (adminRoleIds, type, timestamps)', () => { + const member = generateProjectMember({ userId: 'm1', roleIds: [], user: { ...baseUser } }) + expect(member).not.toHaveProperty('adminRoleIds') + expect(member).not.toHaveProperty('type') + expect(member).not.toHaveProperty('createdAt') + expect(member).not.toHaveProperty('lastLogin') + }) + + it('preserves empty and multi-role arrays verbatim (order matters to callers)', () => { + expect(generateProjectMember({ userId: 'm1', roleIds: [], user: { ...baseUser } }).roleIds).toEqual([]) + expect( + generateProjectMember({ userId: 'm1', roleIds: ['zzz', 'aaa'], user: { ...baseUser } }).roleIds, + ).toEqual(['zzz', 'aaa']) + }) + + it('passes unicode identities through untouched (no sanitization here)', () => { + const member = generateProjectMember({ + userId: 'm1', + roleIds: ['r1'], + user: { ...baseUser, email: 'ünïcodé@dso.fr', firstName: 'Élodie', lastName: 'Nüñez' }, + }) + expect(member.email).toBe('ünïcodé@dso.fr') + expect(member.firstName).toBe('Élodie') + expect(member.lastName).toBe('Nüñez') + }) + + // ponytail-bug: null/undefined user fields flow straight into the API payload — + // generateProjectMember never guards optional user columns; lock current behavior. + it('propagates null-ish fields as-is', () => { + const member = generateProjectMember({ + userId: 'm1', + roleIds: [], + user: { ...baseUser, firstName: null as unknown as string, lastName: undefined as unknown as string }, + }) + expect(member.firstName).toBeNull() + expect(member.lastName).toBeUndefined() + }) +}) diff --git a/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts new file mode 100644 index 0000000000..d9128075bb --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createProject, + deleteProjectDependencies, + getNotArchivedProjectForUpdate, + getProject, + getProjectContext, + getProjectForUpsert, + getProjectNotArchived, + getProjectSlug, + listProjects, + listProjectsForDataExport, + listProjectSlugsForPrefix, + projectIdSelect, + projectContextSelect, + projectForDataSelect, + projectForUpdateSelect, + projectForUpsertSelect, + projectSelect, + projectSlugSelect, + updateProject, +} from './project-queries.utils' + +function mockTx() { + const fn = () => vi.fn().mockResolvedValue(undefined) + return { + project: { + findUnique: fn(), + findFirst: fn(), + findMany: fn(), + create: fn(), + update: fn(), + }, + repository: { deleteMany: fn() }, + environment: { deleteMany: fn() }, + deployment: { deleteMany: fn() }, + } +} + +describe('project query helpers', () => { + it('getProject selects the full aggregate by id', async () => { + const tx = mockTx() + await getProject(tx as never, 'p1') + expect(tx.project.findUnique).toHaveBeenCalledWith({ where: { id: 'p1' }, select: projectSelect }) + }) + + it('getProjectNotArchived excludes archived status', async () => { + const tx = mockTx() + tx.project.findFirst.mockResolvedValueOnce({ id: 'p1' }) + await expect(getProjectNotArchived(tx as never, 'p1')).resolves.toEqual({ id: 'p1' }) + expect(tx.project.findFirst).toHaveBeenCalledWith({ + where: { id: 'p1', status: { not: 'archived' } }, + select: projectSelect, + }) + }) + + it('listProjects ANDs the caller-supplied clauses', async () => { + const tx = mockTx() + const where = [{ ownerId: 'u1' }, { slug: { startsWith: 'a' } }] + await listProjects(tx as never, where) + expect(tx.project.findMany).toHaveBeenCalledWith({ where: { AND: where }, select: projectSelect }) + }) + + it('listProjects accepts an empty clause list (matches everything)', async () => { + const tx = mockTx() + await listProjects(tx as never, []) + expect(tx.project.findMany).toHaveBeenCalledWith({ where: { AND: [] }, select: projectSelect }) + }) + + it('listProjectSlugsForPrefix prefixes on slug only', async () => { + const tx = mockTx() + await listProjectSlugsForPrefix(tx as never, 'proj-') + expect(tx.project.findMany).toHaveBeenCalledWith({ + where: { slug: { startsWith: 'proj-' } }, + select: { slug: true }, + }) + }) + + it('slug/context selects stay minimal', async () => { + const tx = mockTx() + await getProjectSlug(tx as never, 'p1') + await getProjectContext(tx as never, 'p1') + expect(tx.project.findUnique).toHaveBeenNthCalledWith(1, { where: { id: 'p1' }, select: projectSlugSelect }) + expect(tx.project.findUnique).toHaveBeenNthCalledWith(2, { where: { id: 'p1' }, select: projectContextSelect }) + }) + + it('listProjectsForDataExport scans every project without where', async () => { + const tx = mockTx() + await listProjectsForDataExport(tx as never) + expect(tx.project.findMany).toHaveBeenCalledWith({ select: projectForDataExportSelect }) + }) + + it('createProject returns only the id', async () => { + const tx = mockTx() + const data = { name: 'n', slug: 's' } as never + await createProject(tx as never, data) + expect(tx.project.create).toHaveBeenCalledWith({ data, select: projectIdSelect }) + }) + + it('update-context and upsert selects match their shapes', async () => { + const tx = mockTx() + await getNotArchivedProjectForUpdate(tx as never, 'p1') + await getProjectForUpsert(tx as never, 'p1') + expect(tx.project.findFirst).toHaveBeenCalledWith({ + where: { id: 'p1', status: { not: 'archived' } }, + select: projectForUpdateSelect, + }) + expect(tx.project.findUnique).toHaveBeenCalledWith({ where: { id: 'p1' }, select: projectForUpsertSelect }) + }) + + it('updateProject writes without narrowing the result', async () => { + const tx = mockTx() + const data = { name: 'renamed' } as never + await updateProject(tx as never, 'p1', data) + expect(tx.project.update).toHaveBeenCalledWith({ where: { id: 'p1' }, data }) + }) + + it('deleteProjectDependencies clears repo/env/deployment rows in parallel', async () => { + const tx = mockTx() + tx.repository.deleteMany.mockResolvedValueOnce({ count: 2 }) + tx.environment.deleteMany.mockResolvedValueOnce({ count: 3 }) + tx.deployment.deleteMany.mockResolvedValueOnce({ count: 5 }) + await expect(deleteProjectDependencies(tx as never, 'p1')).resolves.toEqual([ + { count: 2 }, + { count: 3 }, + { count: 5 }, + ]) + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId: 'p1' } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId: 'p1' } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId: 'p1' } }) + }) +}) + +import { projectForDataSelect as projectForDataExportSelect } from './project-queries.utils' diff --git a/apps/server-nestjs/src/modules/vault/vault.utils.spec.ts b/apps/server-nestjs/src/modules/vault/vault.utils.spec.ts index 12e0938b6a..682f22ccaa 100644 --- a/apps/server-nestjs/src/modules/vault/vault.utils.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.utils.spec.ts @@ -1,8 +1,54 @@ import { describe, expect, it } from 'vitest' -import { generateSecretGroupPath } from './vault.utils' +import { + generateGitlabMirrorCredPath, + generateProjectPath, + generateSecretGroupPath, + generateSonarqubeCredPath, + generateTechReadOnlyCredPath, +} from './vault.utils' describe('vault path helpers', () => { it('scopes a group to the project path', () => { expect(generateSecretGroupPath('forge', 'my-project', 'GITLAB')).toBe('forge/my-project/GITLAB') }) + + describe('generateProjectPath', () => { + it('joins root and slug with a single slash', () => { + expect(generateProjectPath('forge', 'proj')).toBe('forge/proj') + }) + + it('empty segments still concatenate (no guard by design)', () => { + expect(generateProjectPath('', 'proj')).toBe('/proj') + expect(generateProjectPath('forge', '')).toBe('forge/') + expect(generateProjectPath('', '')).toBe('/') + }) + + // ponytail-bug: trailing slash on rootDir yields a double slash; callers upstream + // are trusted to pass trimmed dirs. Lock current behavior. + it('does not collapse double slashes from trailing-slash roots', () => { + expect(generateProjectPath('forge/', 'proj')).toBe('forge//proj') + }) + + it('passes unicode slugs through untouched (VarChar limits enforced at the DB)', () => { + expect(generateProjectPath('forge', 'proj-éû')).toBe('forge/proj-éû') + }) + }) + + it('mirror credentials nest under /-mirror', () => { + expect(generateGitlabMirrorCredPath('forge', 'proj', 'api')).toBe('forge/proj/api-mirror') + expect(generateGitlabMirrorCredPath('forge', 'proj', '')).toBe('forge/proj/-mirror') + }) + + it('tech read-only credential sits at the fixed tech/GITLAB_MIRROR slot', () => { + expect(generateTechReadOnlyCredPath('forge', 'proj')).toBe('forge/proj/tech/GITLAB_MIRROR') + }) + + it('sonarqube credential sits at the fixed SONAR slot', () => { + expect(generateSonarqubeCredPath('forge', 'proj')).toBe('forge/proj/SONAR') + }) + + it('groups containing slashes intentionally nest deeper', () => { + expect(generateSecretGroupPath('forge', 'proj', 'tech/sub')).toBe('forge/proj/tech/sub') + expect(generateSecretGroupPath('forge', 'proj', '')).toBe('forge/proj/') + }) }) diff --git a/apps/server-nestjs/src/utils/http.utils.spec.ts b/apps/server-nestjs/src/utils/http.utils.spec.ts new file mode 100644 index 0000000000..cf90ecd67f --- /dev/null +++ b/apps/server-nestjs/src/utils/http.utils.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { getErrorHttpDetails, getErrorResponseStatus } from './http.utils' + +/** Real Response with an injectable url (constructor always yields ''). */ +function res(status: number, url?: string): Response { + const r = new Response(null, { status }) + if (url !== undefined) Object.defineProperty(r, 'url', { value: url }) + return r +} + +describe('getErrorResponseStatus', () => { + it.each([undefined, null, 'boom', 42, {}, { response: 500 }, new Error('nope')])( + 'returns undefined for %j', + (input) => { + expect(getErrorResponseStatus(input)).toBeUndefined() + }, + ) + + it('returns undefined when response is not a real Response', () => { + const err = Object.assign(new Error('fake'), { response: { status: 500 } }) + expect(getErrorResponseStatus(err)).toBeUndefined() + }) + + it('reads status off a fetch-like Response', () => { + expect(getErrorResponseStatus(Object.assign(new Error('e'), { response: res(418) }))).toBe(418) + expect(getErrorResponseStatus(Object.assign(new Error('e'), { response: res(500) }))).toBe(500) + }) +}) + +describe('getErrorHttpDetails', () => { + it('returns {} for bare errors', () => { + expect(getErrorHttpDetails(new Error('plain'))).toEqual({}) + // ponytail-bug: non-Error input crashes ('response' in undefined) — contract says Error, + // callers in app-events.utils always pass Error instances. Locked as crash. + // @ts-expect-error adversarial input outside the declared contract + expect(() => getErrorHttpDetails(undefined)).toThrow(TypeError) + }) + + it('ignores a malformed (non-Response) response property', () => { + const err = Object.assign(new Error('e'), { response: '500', responseData: undefined }) + expect(getErrorHttpDetails(err)).toEqual({}) + }) + + it('extracts status and url from the direct response', () => { + const err = Object.assign(new Error('e'), { response: res(409, 'https://api/x') }) + expect(getErrorHttpDetails(err)).toEqual({ status: 409, url: 'https://api/x' }) + }) + + it('drops falsy-empty responseData but keeps other falsy values', () => { + expect(getErrorHttpDetails(Object.assign(new Error('e'), { responseData: '' })).responseData).toBeUndefined() + expect(getErrorHttpDetails(Object.assign(new Error('e'), { responseData: undefined })).responseData).toBeUndefined() + expect(getErrorHttpDetails(Object.assign(new Error('e'), { responseData: 0 })).responseData).toBe(0) + expect(getErrorHttpDetails(Object.assign(new Error('e'), { responseData: 'error' })).responseData).toBe('error') + }) + + it('reads masked url, request method and description off cause', () => { + const err = Object.assign(new Error('e'), { + cause: { + response: res(502, 'https://user:pass@gitlab.example/api/v4/projects'), + request: new Request('https://gitlab.example/api/v4/projects'), + description: 'push to https://oauth2:glpat-abc@gitlab.example/repo.git rejected', + }, + }) + expect(getErrorHttpDetails(err)).toEqual({ + status: 502, + url: 'https://MASKED:MASKED@gitlab.example/api/v4/projects', + method: 'GET', + description: 'push to https://MASKED:MASKED@gitlab.example/repo.git rejected', + }) + }) + + it('cause response overwrites direct response (last-writer-wins, http.utils.ts:39)', () => { + // Locked current behavior: the cause block re-assigns status/url AFTER the direct block. + const err = Object.assign(new Error('e'), { + response: res(401, 'https://a'), + cause: { response: res(503, 'https://b') }, + }) + expect(getErrorHttpDetails(err)).toMatchObject({ status: 503, url: 'https://b' }) + }) + + describe('cause.description normalization', () => { + it('masks credentials nested inside JSON-serializable objects', () => { + const err = Object.assign(new Error('e'), { + cause: { description: { message: 'clone https://u:p@host/r failed', attempts: 3 } }, + }) + expect(getErrorHttpDetails(err).description).toEqual({ + message: 'clone https://MASKED:MASKED@host/r failed', + attempts: 3, + }) + }) + + it('falls back to String() when JSON.stringify throws (BigInt)', () => { + const err = Object.assign(new Error('e'), { cause: { description: 9007199254740993n } }) + expect(getErrorHttpDetails(err).description).toBe('9007199254740993') + }) + + it('falls back to String() on circular structures', () => { + const circular: Record = { a: 1 } + circular.self = circular + const err = Object.assign(new Error('e'), { cause: { description: circular } }) + expect(getErrorHttpDetails(err).description).toBe('[object Object]') + }) + + it('passes through non-string scalars via JSON round-trip', () => { + expect(getErrorHttpDetails(Object.assign(new Error('e'), { cause: { description: null } })).description).toBeNull() + expect(getErrorHttpDetails(Object.assign(new Error('e'), { cause: { description: { deep: [1, 'two'] } } })).description) + .toEqual({ deep: [1, 'two'] }) + }) + }) +}) From 1cff6cdd0e4a44873eb9240b755e492882db7ada Mon Sep 17 00:00:00 2001 From: shikanime Date: Wed, 26 Aug 2026 11:40:30 +0200 Subject: [PATCH 2/5] test(server-nestjs): neutral wording in http utils spec Co-authored-by: Automata --- apps/server-nestjs/src/utils/http.utils.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server-nestjs/src/utils/http.utils.spec.ts b/apps/server-nestjs/src/utils/http.utils.spec.ts index cf90ecd67f..16e1181bb4 100644 --- a/apps/server-nestjs/src/utils/http.utils.spec.ts +++ b/apps/server-nestjs/src/utils/http.utils.spec.ts @@ -32,7 +32,7 @@ describe('getErrorHttpDetails', () => { expect(getErrorHttpDetails(new Error('plain'))).toEqual({}) // ponytail-bug: non-Error input crashes ('response' in undefined) — contract says Error, // callers in app-events.utils always pass Error instances. Locked as crash. - // @ts-expect-error adversarial input outside the declared contract + // @ts-expect-error input outside the declared contract expect(() => getErrorHttpDetails(undefined)).toThrow(TypeError) }) From f22910afd202a1c38675de4a310e4d8890e9f56b Mon Sep 17 00:00:00 2001 From: shikanime Date: Wed, 26 Aug 2026 12:18:42 +0200 Subject: [PATCH 3/5] test(server-nestjs): fix lint violations in utils specs Co-authored-by: Automata --- apps/server-nestjs/src/config/config.utils.spec.ts | 2 +- .../project-members/project-members.utils.spec.ts | 10 ++++++---- .../src/modules/project/project-queries.utils.spec.ts | 6 ++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts index d26c44bafa..da28f7a04c 100644 --- a/apps/server-nestjs/src/config/config.utils.spec.ts +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -60,7 +60,7 @@ describe('config.utils', () => { }) }) - describe('flag', () => { + describe('flag strictness', () => { it('rejects non-string input instead of coercing', () => { expect(() => flag(truthySchema.default('true')).parse(true as never)).toThrow(z.ZodError) expect(() => flag(truthySchema.default('true')).parse(1 as never)).toThrow(z.ZodError) diff --git a/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts b/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts index 3ef3008094..7007fac56b 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts @@ -15,7 +15,7 @@ const baseUser = { describe('generateProjectMember', () => { it('projects exactly the Member shape (id, roleIds, identity)', () => { - expect(generateProjectMember({ userId: 'm1', roleIds: ['r1', 'r2'], user: { ...baseUser } })).toEqual({ + expect(generateProjectMember({ projectId: 'p1', userId: 'm1', roleIds: ['r1', 'r2'], user: { ...baseUser } })).toEqual({ userId: 'u1', roleIds: ['r1', 'r2'], email: 'u1@dso.fr', @@ -25,7 +25,7 @@ describe('generateProjectMember', () => { }) it('drops internal user fields (adminRoleIds, type, timestamps)', () => { - const member = generateProjectMember({ userId: 'm1', roleIds: [], user: { ...baseUser } }) + const member = generateProjectMember({ projectId: 'p1', userId: 'm1', roleIds: [], user: { ...baseUser } }) expect(member).not.toHaveProperty('adminRoleIds') expect(member).not.toHaveProperty('type') expect(member).not.toHaveProperty('createdAt') @@ -33,14 +33,15 @@ describe('generateProjectMember', () => { }) it('preserves empty and multi-role arrays verbatim (order matters to callers)', () => { - expect(generateProjectMember({ userId: 'm1', roleIds: [], user: { ...baseUser } }).roleIds).toEqual([]) + expect(generateProjectMember({ projectId: 'p1', userId: 'm1', roleIds: [], user: { ...baseUser } }).roleIds).toEqual([]) expect( - generateProjectMember({ userId: 'm1', roleIds: ['zzz', 'aaa'], user: { ...baseUser } }).roleIds, + generateProjectMember({ projectId: 'p1', userId: 'm1', roleIds: ['zzz', 'aaa'], user: { ...baseUser } }).roleIds, ).toEqual(['zzz', 'aaa']) }) it('passes unicode identities through untouched (no sanitization here)', () => { const member = generateProjectMember({ + projectId: 'p1', userId: 'm1', roleIds: ['r1'], user: { ...baseUser, email: 'ünïcodé@dso.fr', firstName: 'Élodie', lastName: 'Nüñez' }, @@ -54,6 +55,7 @@ describe('generateProjectMember', () => { // generateProjectMember never guards optional user columns; lock current behavior. it('propagates null-ish fields as-is', () => { const member = generateProjectMember({ + projectId: 'p1', userId: 'm1', roleIds: [], user: { ...baseUser, firstName: null as unknown as string, lastName: undefined as unknown as string }, diff --git a/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts index d9128075bb..d2d6c99616 100644 --- a/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts +++ b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts @@ -11,11 +11,11 @@ import { listProjects, listProjectsForDataExport, listProjectSlugsForPrefix, - projectIdSelect, projectContextSelect, projectForDataSelect, projectForUpdateSelect, projectForUpsertSelect, + projectIdSelect, projectSelect, projectSlugSelect, updateProject, @@ -87,7 +87,7 @@ describe('project query helpers', () => { it('listProjectsForDataExport scans every project without where', async () => { const tx = mockTx() await listProjectsForDataExport(tx as never) - expect(tx.project.findMany).toHaveBeenCalledWith({ select: projectForDataExportSelect }) + expect(tx.project.findMany).toHaveBeenCalledWith({ select: projectForDataSelect }) }) it('createProject returns only the id', async () => { @@ -130,5 +130,3 @@ describe('project query helpers', () => { expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId: 'p1' } }) }) }) - -import { projectForDataSelect as projectForDataExportSelect } from './project-queries.utils' From ec9c18a914898b4a7e9f7ac3a990acb975991d66 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 28 Aug 2026 17:18:40 +0200 Subject: [PATCH 4/5] fix(server-nestjs): apply review feedback on utils specs Signed-off-by: William Phetsinorath Change-Id: Ie4b4b12174df8978af84a6a49aea18436a6a6964 --- .../src/config/config.utils.spec.ts | 36 +++----- apps/server-nestjs/src/config/config.utils.ts | 14 +-- .../project-members.controller.ts | 10 +-- .../project-members.utils.spec.ts | 64 +++++--------- .../project-members/project-members.utils.ts | 2 +- .../project/project-queries.utils.spec.ts | 86 +++++++++---------- .../src/modules/vault/vault.utils.spec.ts | 7 +- .../src/modules/vault/vault.utils.ts | 2 +- .../src/utils/http-testing.utils.ts | 10 +++ .../src/utils/http.utils.spec.ts | 24 ++---- 10 files changed, 110 insertions(+), 145 deletions(-) create mode 100644 apps/server-nestjs/src/utils/http-testing.utils.ts diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts index da28f7a04c..9b2bb645c2 100644 --- a/apps/server-nestjs/src/config/config.utils.spec.ts +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -76,7 +76,6 @@ describe('config.utils', () => { '* * * * * *', '0/5 * * * * *', '*/15 * * * * *', - '? ? ? ? ? ?', '0,15,30,45 * * * * ?', '0 0 12 ? * MON-FRI', '0 0 12 ? * mon-fri', @@ -104,40 +103,27 @@ describe('config.utils', () => { 'JAN-MAR * * * * *', '*/x * * * * *', '5- * * * * *', + // '?' is Quartz-only and must not appear in the seconds field + '? ? ? ? ? ?', + // ranges are single-dash numeric pairs, not free-form strings + '-5-3 * * * * *', + '1-5-10 * * * * *', + // empty comma parts are malformed in any field + '0,,30 * * * * *', + '* ,,* * * * *', + ',,, *,* *,* *,* *,* *,*', + '0 0 0 ,,5 * *', ] it.each(invalid)('rejects %j', (expr) => { expect(cronSchema.safeParse(expr).success).toBe(false) }) - it('accepts Quartz ? in day-of-month field', () => { - // ponytail-bug: config.utils.ts:3 claims stdlib-only 5-field semantics, yet '?' passes - // the DOM bounds check. Locked as accepted — Harbor retention triggers use Quartz syntax - // upstream, so rejecting it would break real configs. + it('accepts Quartz ? in day-of-month and day-of-week fields', () => { expect(cronSchema.safeParse('0 0 0 ?,5 * *').success).toBe(true) }) it('requires exactly six fields even when padded', () => { expect(cronSchema.safeParse(' * * * * * ').success).toBe(false) }) - - // ponytail-bug: Number('')===0 and split('-') destructuring let malformed numeric - // items slip through ('-5-3', '1-5-10', empty comma parts in low-bound fields). - // Locked here; fix belongs in isRangeOrNumber/isCronItem. - describe('documented lenience (current behavior)', () => { - it('accepts a negative-looking range -5-3 as a seconds item', () => { - expect(cronSchema.safeParse('-5-3 * * * * *').success).toBe(true) - }) - - it('accepts a triple range 1-5-10 (extra segment ignored)', () => { - expect(cronSchema.safeParse('1-5-10 * * * * *').success).toBe(true) - }) - - it('accepts empty comma parts in seconds/minutes but not day fields', () => { - expect(cronSchema.safeParse('0,,30 * * * * *').success).toBe(true) - expect(cronSchema.safeParse('* ,,* * * * *').success).toBe(true) - expect(cronSchema.safeParse(',,, *,* *,* *,* *,* *,*').success).toBe(true) - expect(cronSchema.safeParse('0 0 0 ,,5 * *').success).toBe(false) - }) - }) }) }) diff --git a/apps/server-nestjs/src/config/config.utils.ts b/apps/server-nestjs/src/config/config.utils.ts index fd7ac69fd4..a2eddcb349 100644 --- a/apps/server-nestjs/src/config/config.utils.ts +++ b/apps/server-nestjs/src/config/config.utils.ts @@ -31,23 +31,27 @@ function isNameItem(item: string, field: number): boolean { } function isRangeOrNumber(token: string, min: number, max: number): boolean { - if (token.includes('-')) { - const [lo, hi] = token.split('-').map(Number) - return lo >= min && hi <= max && lo <= hi + const parts = token.split('-') + if (parts.length > 2 || parts.includes('')) return false + if (parts.length === 2) { + const lo = Number(parts[0]) + const hi = Number(parts[1]) + return Number.isInteger(lo) && Number.isInteger(hi) && lo >= min && hi <= max && lo <= hi } const n = Number(token) return Number.isInteger(n) && n >= min && n <= max } function isCronItem(item: string, field: number): boolean { - if (item === '*' || item === '?') return true + if (item === '*') return true + if (item === '?' && (field === 3 || field === 5)) return true if (isNameItem(item, field)) return true const [min, max] = CRON_FIELD_BOUNDS[field] const step = /^(.+)\/(\d+)$/.exec(item) if (step) { if (Number(step[2]) < 1) return false const base = step[1] - if (base === '*' || base === '?') return true + if (base === '*') return true if (isNameItem(base, field)) return true return isRangeOrNumber(base, min, max) } diff --git a/apps/server-nestjs/src/modules/project-members/project-members.controller.ts b/apps/server-nestjs/src/modules/project-members/project-members.controller.ts index 1761096669..fd9de9fc6a 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.controller.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.controller.ts @@ -10,7 +10,7 @@ import { Project } from '../infrastructure/permission/project/project.decorator' import { ProjectGuard } from '../infrastructure/permission/project/project.guard' import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' import { ProjectMembersService } from './project-members.service' -import { generateProjectMember } from './project-members.utils' +import { makeProjectMember } from './project-members.utils' @Controller('api/v1/projects/:projectId/members') @UseGuards(ProjectGuard) @@ -26,7 +26,7 @@ export class ProjectMembersController { async list( @Project() project: ProjectContext, ): Promise { - return (await this.projectMembers.list(project.id)).map(generateProjectMember) + return (await this.projectMembers.list(project.id)).map(makeProjectMember) } @Post() @@ -40,7 +40,7 @@ export class ProjectMembersController { ): Promise { const members = await this.projectMembers.add(project.id, body) this.logger.log(`projectMembers.add completed (memberCount=${members.length})`) - return members.map(generateProjectMember) + return members.map(makeProjectMember) } @Patch() @@ -54,7 +54,7 @@ export class ProjectMembersController { ): Promise { const members = await this.projectMembers.patch(project.id, body) this.logger.log(`projectMembers.patchMembers completed (projectId=${project.id}, memberCount=${members.length})`) - return members.map(generateProjectMember) + return members.map(makeProjectMember) } @Delete('/:userId') @@ -68,6 +68,6 @@ export class ProjectMembersController { ): Promise { const members = await this.projectMembers.remove(project.id, userId) this.logger.log(`projectMembers.remove completed (projectId=${project.id}, userId=${userId}, memberCount=${members.length})`) - return members.map(generateProjectMember) + return members.map(makeProjectMember) } } diff --git a/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts b/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts index 7007fac56b..a450f86583 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts @@ -1,31 +1,24 @@ +import { faker } from '@faker-js/faker' import { describe, expect, it } from 'vitest' -import { generateProjectMember } from './project-members.utils' +import { makeProjectMemberWithUser, makeUser } from '../project/project-testing.utils' +import { makeProjectMember } from './project-members.utils' -const baseUser = { - id: 'u1', - email: 'u1@dso.fr', - firstName: 'Jean', - lastName: 'Dupont', - adminRoleIds: ['admin-1'], - type: 'user', - createdAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-02'), - lastLogin: new Date('2026-01-03'), -} as const - -describe('generateProjectMember', () => { +describe('makeProjectMember', () => { it('projects exactly the Member shape (id, roleIds, identity)', () => { - expect(generateProjectMember({ projectId: 'p1', userId: 'm1', roleIds: ['r1', 'r2'], user: { ...baseUser } })).toEqual({ - userId: 'u1', - roleIds: ['r1', 'r2'], - email: 'u1@dso.fr', - firstName: 'Jean', - lastName: 'Dupont', + const user = makeUser() + const roles = [faker.string.uuid(), faker.string.uuid()] + expect(makeProjectMember(makeProjectMemberWithUser(user, { roleIds: roles }))).toEqual({ + userId: user.id, + roleIds: roles, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, }) }) it('drops internal user fields (adminRoleIds, type, timestamps)', () => { - const member = generateProjectMember({ projectId: 'p1', userId: 'm1', roleIds: [], user: { ...baseUser } }) + const user = makeUser({ adminRoleIds: [faker.string.uuid()] }) + const member = makeProjectMember(makeProjectMemberWithUser(user)) expect(member).not.toHaveProperty('adminRoleIds') expect(member).not.toHaveProperty('type') expect(member).not.toHaveProperty('createdAt') @@ -33,34 +26,17 @@ describe('generateProjectMember', () => { }) it('preserves empty and multi-role arrays verbatim (order matters to callers)', () => { - expect(generateProjectMember({ projectId: 'p1', userId: 'm1', roleIds: [], user: { ...baseUser } }).roleIds).toEqual([]) - expect( - generateProjectMember({ projectId: 'p1', userId: 'm1', roleIds: ['zzz', 'aaa'], user: { ...baseUser } }).roleIds, - ).toEqual(['zzz', 'aaa']) + const user = makeUser() + expect(makeProjectMember(makeProjectMemberWithUser(user, { roleIds: [] })).roleIds).toEqual([]) + const orderedRoles = ['zzz', 'aaa'] + expect(makeProjectMember(makeProjectMemberWithUser(user, { roleIds: orderedRoles })).roleIds).toEqual(orderedRoles) }) it('passes unicode identities through untouched (no sanitization here)', () => { - const member = generateProjectMember({ - projectId: 'p1', - userId: 'm1', - roleIds: ['r1'], - user: { ...baseUser, email: 'ünïcodé@dso.fr', firstName: 'Élodie', lastName: 'Nüñez' }, - }) + const user = makeUser({ email: 'ünïcodé@dso.fr', firstName: 'Élodie', lastName: 'Nüñez' }) + const member = makeProjectMember(makeProjectMemberWithUser(user)) expect(member.email).toBe('ünïcodé@dso.fr') expect(member.firstName).toBe('Élodie') expect(member.lastName).toBe('Nüñez') }) - - // ponytail-bug: null/undefined user fields flow straight into the API payload — - // generateProjectMember never guards optional user columns; lock current behavior. - it('propagates null-ish fields as-is', () => { - const member = generateProjectMember({ - projectId: 'p1', - userId: 'm1', - roleIds: [], - user: { ...baseUser, firstName: null as unknown as string, lastName: undefined as unknown as string }, - }) - expect(member.firstName).toBeNull() - expect(member.lastName).toBeUndefined() - }) }) diff --git a/apps/server-nestjs/src/modules/project-members/project-members.utils.ts b/apps/server-nestjs/src/modules/project-members/project-members.utils.ts index 4131cfbebd..b05934a145 100644 --- a/apps/server-nestjs/src/modules/project-members/project-members.utils.ts +++ b/apps/server-nestjs/src/modules/project-members/project-members.utils.ts @@ -1,7 +1,7 @@ import type { Member } from '@cpn-console/shared' import type { ProjectMemberWithUser } from './project-members-queries.utils' -export function generateProjectMember(member: ProjectMemberWithUser): Member { +export function makeProjectMember(member: ProjectMemberWithUser): Member { const { roleIds, user } = member return { userId: user.id, diff --git a/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts index d2d6c99616..46905c56c0 100644 --- a/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts +++ b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts @@ -1,4 +1,8 @@ -import { describe, expect, it, vi } from 'vitest' +import type { Prisma } from '@prisma/client' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' import { createProject, deleteProjectDependencies, @@ -21,112 +25,108 @@ import { updateProject, } from './project-queries.utils' -function mockTx() { - const fn = () => vi.fn().mockResolvedValue(undefined) - return { - project: { - findUnique: fn(), - findFirst: fn(), - findMany: fn(), - create: fn(), - update: fn(), - }, - repository: { deleteMany: fn() }, - environment: { deleteMany: fn() }, - deployment: { deleteMany: fn() }, - } +function mockTx(): DeepMockProxy { + return mockDeep() } describe('project query helpers', () => { it('getProject selects the full aggregate by id', async () => { const tx = mockTx() - await getProject(tx as never, 'p1') - expect(tx.project.findUnique).toHaveBeenCalledWith({ where: { id: 'p1' }, select: projectSelect }) + const projectId = faker.string.uuid() + await getProject(tx, projectId) + expect(tx.project.findUnique).toHaveBeenCalledWith({ where: { id: projectId }, select: projectSelect }) }) it('getProjectNotArchived excludes archived status', async () => { const tx = mockTx() - tx.project.findFirst.mockResolvedValueOnce({ id: 'p1' }) - await expect(getProjectNotArchived(tx as never, 'p1')).resolves.toEqual({ id: 'p1' }) + const projectId = faker.string.uuid() + tx.project.findFirst.mockResolvedValueOnce({ id: projectId } as never) + await expect(getProjectNotArchived(tx, projectId)).resolves.toEqual({ id: projectId }) expect(tx.project.findFirst).toHaveBeenCalledWith({ - where: { id: 'p1', status: { not: 'archived' } }, + where: { id: projectId, status: { not: 'archived' } }, select: projectSelect, }) }) it('listProjects ANDs the caller-supplied clauses', async () => { const tx = mockTx() - const where = [{ ownerId: 'u1' }, { slug: { startsWith: 'a' } }] - await listProjects(tx as never, where) + const ownerId = faker.string.uuid() + const where = [{ ownerId }, { slug: { startsWith: 'a' } }] + await listProjects(tx, where) expect(tx.project.findMany).toHaveBeenCalledWith({ where: { AND: where }, select: projectSelect }) }) it('listProjects accepts an empty clause list (matches everything)', async () => { const tx = mockTx() - await listProjects(tx as never, []) + await listProjects(tx, []) expect(tx.project.findMany).toHaveBeenCalledWith({ where: { AND: [] }, select: projectSelect }) }) it('listProjectSlugsForPrefix prefixes on slug only', async () => { const tx = mockTx() - await listProjectSlugsForPrefix(tx as never, 'proj-') + const prefix = faker.helpers.slugify('proj') + await listProjectSlugsForPrefix(tx, prefix) expect(tx.project.findMany).toHaveBeenCalledWith({ - where: { slug: { startsWith: 'proj-' } }, + where: { slug: { startsWith: prefix } }, select: { slug: true }, }) }) it('slug/context selects stay minimal', async () => { const tx = mockTx() - await getProjectSlug(tx as never, 'p1') - await getProjectContext(tx as never, 'p1') - expect(tx.project.findUnique).toHaveBeenNthCalledWith(1, { where: { id: 'p1' }, select: projectSlugSelect }) - expect(tx.project.findUnique).toHaveBeenNthCalledWith(2, { where: { id: 'p1' }, select: projectContextSelect }) + const projectId = faker.string.uuid() + await getProjectSlug(tx, projectId) + await getProjectContext(tx, projectId) + expect(tx.project.findUnique).toHaveBeenNthCalledWith(1, { where: { id: projectId }, select: projectSlugSelect }) + expect(tx.project.findUnique).toHaveBeenNthCalledWith(2, { where: { id: projectId }, select: projectContextSelect }) }) it('listProjectsForDataExport scans every project without where', async () => { const tx = mockTx() - await listProjectsForDataExport(tx as never) + await listProjectsForDataExport(tx) expect(tx.project.findMany).toHaveBeenCalledWith({ select: projectForDataSelect }) }) it('createProject returns only the id', async () => { const tx = mockTx() - const data = { name: 'n', slug: 's' } as never - await createProject(tx as never, data) + const data = { name: faker.company.name(), slug: faker.helpers.slugify('project') } as never + await createProject(tx, data) expect(tx.project.create).toHaveBeenCalledWith({ data, select: projectIdSelect }) }) it('update-context and upsert selects match their shapes', async () => { const tx = mockTx() - await getNotArchivedProjectForUpdate(tx as never, 'p1') - await getProjectForUpsert(tx as never, 'p1') + const projectId = faker.string.uuid() + await getNotArchivedProjectForUpdate(tx, projectId) + await getProjectForUpsert(tx, projectId) expect(tx.project.findFirst).toHaveBeenCalledWith({ - where: { id: 'p1', status: { not: 'archived' } }, + where: { id: projectId, status: { not: 'archived' } }, select: projectForUpdateSelect, }) - expect(tx.project.findUnique).toHaveBeenCalledWith({ where: { id: 'p1' }, select: projectForUpsertSelect }) + expect(tx.project.findUnique).toHaveBeenCalledWith({ where: { id: projectId }, select: projectForUpsertSelect }) }) it('updateProject writes without narrowing the result', async () => { const tx = mockTx() - const data = { name: 'renamed' } as never - await updateProject(tx as never, 'p1', data) - expect(tx.project.update).toHaveBeenCalledWith({ where: { id: 'p1' }, data }) + const projectId = faker.string.uuid() + const data = { name: faker.company.name() } as never + await updateProject(tx, projectId, data) + expect(tx.project.update).toHaveBeenCalledWith({ where: { id: projectId }, data }) }) it('deleteProjectDependencies clears repo/env/deployment rows in parallel', async () => { const tx = mockTx() + const projectId = faker.string.uuid() tx.repository.deleteMany.mockResolvedValueOnce({ count: 2 }) tx.environment.deleteMany.mockResolvedValueOnce({ count: 3 }) tx.deployment.deleteMany.mockResolvedValueOnce({ count: 5 }) - await expect(deleteProjectDependencies(tx as never, 'p1')).resolves.toEqual([ + await expect(deleteProjectDependencies(tx, projectId)).resolves.toEqual([ { count: 2 }, { count: 3 }, { count: 5 }, ]) - expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId: 'p1' } }) - expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId: 'p1' } }) - expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId: 'p1' } }) + expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) + expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } }) }) }) diff --git a/apps/server-nestjs/src/modules/vault/vault.utils.spec.ts b/apps/server-nestjs/src/modules/vault/vault.utils.spec.ts index 682f22ccaa..4e934ab96d 100644 --- a/apps/server-nestjs/src/modules/vault/vault.utils.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.utils.spec.ts @@ -23,10 +23,9 @@ describe('vault path helpers', () => { expect(generateProjectPath('', '')).toBe('/') }) - // ponytail-bug: trailing slash on rootDir yields a double slash; callers upstream - // are trusted to pass trimmed dirs. Lock current behavior. - it('does not collapse double slashes from trailing-slash roots', () => { - expect(generateProjectPath('forge/', 'proj')).toBe('forge//proj') + it('collapses trailing-slash roots into a single separator', () => { + expect(generateProjectPath('forge/', 'proj')).toBe('forge/proj') + expect(generateProjectPath('forge///', 'proj')).toBe('forge/proj') }) it('passes unicode slugs through untouched (VarChar limits enforced at the DB)', () => { diff --git a/apps/server-nestjs/src/modules/vault/vault.utils.ts b/apps/server-nestjs/src/modules/vault/vault.utils.ts index 6b86e20d03..4f6853124d 100644 --- a/apps/server-nestjs/src/modules/vault/vault.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -1,5 +1,5 @@ export function generateProjectPath(projectRootDir: string, projectSlug: string) { - return `${projectRootDir}/${projectSlug}` + return `${projectRootDir.replace(/\/+$/, '')}/${projectSlug}` } export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { diff --git a/apps/server-nestjs/src/utils/http-testing.utils.ts b/apps/server-nestjs/src/utils/http-testing.utils.ts new file mode 100644 index 0000000000..47a18096cf --- /dev/null +++ b/apps/server-nestjs/src/utils/http-testing.utils.ts @@ -0,0 +1,10 @@ +// Real Response with an injectable url (the constructor always yields ''). +export function makeResponse(status: number, url?: string): Response { + const r = new Response(null, { status }) + if (url !== undefined) Object.defineProperty(r, 'url', { value: url }) + return r +} + +export function makeHttpError(overrides: Record = {}): Error { + return Object.assign(new Error('request failed'), overrides) +} diff --git a/apps/server-nestjs/src/utils/http.utils.spec.ts b/apps/server-nestjs/src/utils/http.utils.spec.ts index 16e1181bb4..45eb02af81 100644 --- a/apps/server-nestjs/src/utils/http.utils.spec.ts +++ b/apps/server-nestjs/src/utils/http.utils.spec.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from 'vitest' +import { makeResponse } from './http-testing.utils' import { getErrorHttpDetails, getErrorResponseStatus } from './http.utils' -/** Real Response with an injectable url (constructor always yields ''). */ -function res(status: number, url?: string): Response { - const r = new Response(null, { status }) - if (url !== undefined) Object.defineProperty(r, 'url', { value: url }) - return r -} - describe('getErrorResponseStatus', () => { it.each([undefined, null, 'boom', 42, {}, { response: 500 }, new Error('nope')])( 'returns undefined for %j', @@ -22,18 +16,14 @@ describe('getErrorResponseStatus', () => { }) it('reads status off a fetch-like Response', () => { - expect(getErrorResponseStatus(Object.assign(new Error('e'), { response: res(418) }))).toBe(418) - expect(getErrorResponseStatus(Object.assign(new Error('e'), { response: res(500) }))).toBe(500) + expect(getErrorResponseStatus(Object.assign(new Error('e'), { response: makeResponse(418) }))).toBe(418) + expect(getErrorResponseStatus(Object.assign(new Error('e'), { response: makeResponse(500) }))).toBe(500) }) }) describe('getErrorHttpDetails', () => { it('returns {} for bare errors', () => { expect(getErrorHttpDetails(new Error('plain'))).toEqual({}) - // ponytail-bug: non-Error input crashes ('response' in undefined) — contract says Error, - // callers in app-events.utils always pass Error instances. Locked as crash. - // @ts-expect-error input outside the declared contract - expect(() => getErrorHttpDetails(undefined)).toThrow(TypeError) }) it('ignores a malformed (non-Response) response property', () => { @@ -42,7 +32,7 @@ describe('getErrorHttpDetails', () => { }) it('extracts status and url from the direct response', () => { - const err = Object.assign(new Error('e'), { response: res(409, 'https://api/x') }) + const err = Object.assign(new Error('e'), { response: makeResponse(409, 'https://api/x') }) expect(getErrorHttpDetails(err)).toEqual({ status: 409, url: 'https://api/x' }) }) @@ -56,7 +46,7 @@ describe('getErrorHttpDetails', () => { it('reads masked url, request method and description off cause', () => { const err = Object.assign(new Error('e'), { cause: { - response: res(502, 'https://user:pass@gitlab.example/api/v4/projects'), + response: makeResponse(502, 'https://user:pass@gitlab.example/api/v4/projects'), request: new Request('https://gitlab.example/api/v4/projects'), description: 'push to https://oauth2:glpat-abc@gitlab.example/repo.git rejected', }, @@ -72,8 +62,8 @@ describe('getErrorHttpDetails', () => { it('cause response overwrites direct response (last-writer-wins, http.utils.ts:39)', () => { // Locked current behavior: the cause block re-assigns status/url AFTER the direct block. const err = Object.assign(new Error('e'), { - response: res(401, 'https://a'), - cause: { response: res(503, 'https://b') }, + response: makeResponse(401, 'https://a'), + cause: { response: makeResponse(503, 'https://b') }, }) expect(getErrorHttpDetails(err)).toMatchObject({ status: 503, url: 'https://b' }) }) From fb13268ba549ef729f664780aa6c7fcf83b8f248 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Mon, 31 Aug 2026 11:28:02 +0200 Subject: [PATCH 5/5] refactor(test): replace as never casts with faker-typed values in utils specs Drop as never casts in config.utils flag-strictness tests (parse accepts unknown) and in project-queries specs by using generateProjectCreateInput with faker data and the Prisma payload type for findFirst, per server-nestjs vitest convention. Signed-off-by: William Phetsinorath Change-Id: Id26640977d08fdc24155d60cbde460a06a6a6964 --- apps/server-nestjs/src/config/config.utils.spec.ts | 4 ++-- .../src/modules/project/project-queries.utils.spec.ts | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts index 9b2bb645c2..e80845e3c8 100644 --- a/apps/server-nestjs/src/config/config.utils.spec.ts +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -62,8 +62,8 @@ describe('config.utils', () => { describe('flag strictness', () => { it('rejects non-string input instead of coercing', () => { - expect(() => flag(truthySchema.default('true')).parse(true as never)).toThrow(z.ZodError) - expect(() => flag(truthySchema.default('true')).parse(1 as never)).toThrow(z.ZodError) + expect(() => flag(truthySchema.default('true')).parse(true)).toThrow(z.ZodError) + expect(() => flag(truthySchema.default('true')).parse(1)).toThrow(z.ZodError) }) it('does not trim whitespace before matching', () => { diff --git a/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts index 46905c56c0..c430382d31 100644 --- a/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts +++ b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts @@ -24,6 +24,8 @@ import { projectSlugSelect, updateProject, } from './project-queries.utils' +import { makeCreateProjectBody } from './project-testing.utils' +import { generateProjectCreateInput } from './project.utils' function mockTx(): DeepMockProxy { return mockDeep() @@ -40,7 +42,7 @@ describe('project query helpers', () => { it('getProjectNotArchived excludes archived status', async () => { const tx = mockTx() const projectId = faker.string.uuid() - tx.project.findFirst.mockResolvedValueOnce({ id: projectId } as never) + tx.project.findFirst.mockResolvedValueOnce({ id: projectId } as Prisma.ProjectGetPayload<{ select: typeof projectSelect }>) await expect(getProjectNotArchived(tx, projectId)).resolves.toEqual({ id: projectId }) expect(tx.project.findFirst).toHaveBeenCalledWith({ where: { id: projectId, status: { not: 'archived' } }, @@ -89,7 +91,7 @@ describe('project query helpers', () => { it('createProject returns only the id', async () => { const tx = mockTx() - const data = { name: faker.company.name(), slug: faker.helpers.slugify('project') } as never + const data = generateProjectCreateInput(makeCreateProjectBody(), faker.string.uuid(), faker.helpers.slugify('project')) await createProject(tx, data) expect(tx.project.create).toHaveBeenCalledWith({ data, select: projectIdSelect }) }) @@ -109,7 +111,7 @@ describe('project query helpers', () => { it('updateProject writes without narrowing the result', async () => { const tx = mockTx() const projectId = faker.string.uuid() - const data = { name: faker.company.name() } as never + const data: Prisma.ProjectUpdateInput = { name: faker.company.name() } await updateProject(tx, projectId, data) expect(tx.project.update).toHaveBeenCalledWith({ where: { id: projectId }, data }) })