Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 92 additions & 1 deletion apps/server-nestjs/src/config/config.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -34,5 +34,96 @@ 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 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)
})

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- * * * * *',
// '?' 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 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)
})
})
})
14 changes: 9 additions & 5 deletions apps/server-nestjs/src/config/config.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -26,7 +26,7 @@ export class ProjectMembersController {
async list(
@Project() project: ProjectContext,
): Promise<Member[]> {
return (await this.projectMembers.list(project.id)).map(generateProjectMember)
return (await this.projectMembers.list(project.id)).map(makeProjectMember)
}

@Post()
Expand All @@ -40,7 +40,7 @@ export class ProjectMembersController {
): Promise<Member[]> {
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()
Expand All @@ -54,7 +54,7 @@ export class ProjectMembersController {
): Promise<Member[]> {
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')
Expand All @@ -68,6 +68,6 @@ export class ProjectMembersController {
): Promise<Member[]> {
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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { faker } from '@faker-js/faker'
import { describe, expect, it } from 'vitest'
import { makeProjectMemberWithUser, makeUser } from '../project/project-testing.utils'
import { makeProjectMember } from './project-members.utils'

describe('makeProjectMember', () => {
it('projects exactly the Member shape (id, roleIds, identity)', () => {
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 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')
expect(member).not.toHaveProperty('lastLogin')
})

it('preserves empty and multi-role arrays verbatim (order matters to callers)', () => {
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 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')
})
})
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
132 changes: 132 additions & 0 deletions apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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,
getNotArchivedProjectForUpdate,
getProject,
getProjectContext,
getProjectForUpsert,
getProjectNotArchived,
getProjectSlug,
listProjects,
listProjectsForDataExport,
listProjectSlugsForPrefix,
projectContextSelect,
projectForDataSelect,
projectForUpdateSelect,
projectForUpsertSelect,
projectIdSelect,
projectSelect,
projectSlugSelect,
updateProject,
} from './project-queries.utils'
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

function mockTx(): DeepMockProxy<Prisma.TransactionClient> {
return mockDeep<Prisma.TransactionClient>()
}

describe('project query helpers', () => {
it('getProject selects the full aggregate by id', async () => {
const tx = mockTx()
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()
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: projectId, status: { not: 'archived' } },
select: projectSelect,
})
})

it('listProjects ANDs the caller-supplied clauses', async () => {
const tx = mockTx()
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, [])
expect(tx.project.findMany).toHaveBeenCalledWith({ where: { AND: [] }, select: projectSelect })
})

it('listProjectSlugsForPrefix prefixes on slug only', async () => {
const tx = mockTx()
const prefix = faker.helpers.slugify('proj')
await listProjectSlugsForPrefix(tx, prefix)
expect(tx.project.findMany).toHaveBeenCalledWith({
where: { slug: { startsWith: prefix } },
select: { slug: true },
})
})

it('slug/context selects stay minimal', async () => {
const tx = mockTx()
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)
expect(tx.project.findMany).toHaveBeenCalledWith({ select: projectForDataSelect })
})

it('createProject returns only the id', async () => {
const tx = mockTx()
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()
const projectId = faker.string.uuid()
await getNotArchivedProjectForUpdate(tx, projectId)
await getProjectForUpsert(tx, projectId)
expect(tx.project.findFirst).toHaveBeenCalledWith({
where: { id: projectId, status: { not: 'archived' } },
select: projectForUpdateSelect,
})
expect(tx.project.findUnique).toHaveBeenCalledWith({ where: { id: projectId }, select: projectForUpsertSelect })
})

it('updateProject writes without narrowing the result', async () => {
const tx = mockTx()
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, projectId)).resolves.toEqual([
{ count: 2 },
{ count: 3 },
{ count: 5 },
])
expect(tx.repository.deleteMany).toHaveBeenCalledWith({ where: { projectId } })
expect(tx.environment.deleteMany).toHaveBeenCalledWith({ where: { projectId } })
expect(tx.deployment.deleteMany).toHaveBeenCalledWith({ where: { projectId } })
})
})
Loading
Loading