diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts index 155c025349..e80845e3c8 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,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)).toThrow(z.ZodError) + expect(() => flag(truthySchema.default('true')).parse(1)).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) + }) }) }) 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 new file mode 100644 index 0000000000..a450f86583 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-members/project-members.utils.spec.ts @@ -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') + }) +}) 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 new file mode 100644 index 0000000000..c430382d31 --- /dev/null +++ b/apps/server-nestjs/src/modules/project/project-queries.utils.spec.ts @@ -0,0 +1,134 @@ +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' +import { makeCreateProjectBody } from './project-testing.utils' +import { generateProjectCreateInput } from './project.utils' + +function mockTx(): DeepMockProxy { + return mockDeep() +} + +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 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' } }, + 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 = generateProjectCreateInput(makeCreateProjectBody(), faker.string.uuid(), faker.helpers.slugify('project')) + 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: Prisma.ProjectUpdateInput = { name: faker.company.name() } + 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 } }) + }) +}) 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..4e934ab96d 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,53 @@ 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('/') + }) + + 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)', () => { + 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/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 new file mode 100644 index 0000000000..45eb02af81 --- /dev/null +++ b/apps/server-nestjs/src/utils/http.utils.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { makeResponse } from './http-testing.utils' +import { getErrorHttpDetails, getErrorResponseStatus } from './http.utils' + +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: 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({}) + }) + + 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: makeResponse(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: 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', + }, + }) + 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: makeResponse(401, 'https://a'), + cause: { response: makeResponse(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'] }) + }) + }) +})