From 712e74247543b021429527c41eeba3ce0f3b9cf3 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 19:11:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=9D=B4=EB=A0=A5=EC=9D=98=20null=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20500=20=EC=88=98=EC=A0=95=20+=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20Query=20null=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=EC=A0=84=EC=88=98=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 FE가 알림 이력 '전체' 필터를 type: null, targetKind: null로 보내면 service가 null을 그대로 repository로 넘겨 Prisma where에 type: null이 들어가 500(Argument `type` must not be null). 매장·상품·배너 isActive(#434)에 이은 두 번째 같은 유형이라, 이번에는 admin Query 전수를 SDL에서 읽어 실제 앱으로 보내는 게이트로 막는다. - adminNotificationBroadcasts: type·targetKind를 ?? undefined로 정규화, DTO를 | null로 선언해 다음 누락은 tsc가 잡게 함 - 전수 점검: input 객체를 받는 admin Query 17개, nullable 입력 필드 70자리 - 고침 2(알림 이력 type·targetKind) - 그대로 둠 68: 페이지 limit·cursor 27, keyword trim 7, ID parseOptionalId 13, 기본값 false 불리언 4, 날짜 toDate 5, repository가 truthy·일치 비교로 거르는 enum 8, 명시적 null = 전체인 신고 status 1, 이미 정규화된 isActive 3 - 게이트 src/test/admin-query-null-inputs.spec.ts - nullable 필드 전부 null, input 인자 자체 null 두 경우를 main.ts와 같은 파이프·필터·가드·실DB로 보내 5xx·INTERNAL_ERROR 없음, 리졸버까지 닿아 data가 옴을 단언(17 + 16건) - 대상 수 17 고정, 입력 오류는 VALIDATION_FAILED(400)로 나가는지 대조, 판정기·입력 생성기 반증 - 반증 실측: 알림 정규화를 되돌리면 adminNotificationBroadcasts 1건만 실패(운영과 같은 Prisma 오류), isActive 3자리를 되돌리면 adminStores·adminProducts·adminBanners 3건 실패 - createTestingModuleWithRealDb가 PrismaService를 override해 AppModule 트리도 실DB 테스트 클라이언트를 쓰게 함 - 회귀: 알림 service spec·DTO spec에 null 필터 케이스 추가 --- ...-notification-broadcast-list.input.spec.ts | 1 + ...admin-notification-broadcast-list.input.ts | 4 +- .../notification-admin.service.spec.ts | 7 + .../services/notification-admin.service.ts | 5 +- src/test/admin-query-null-inputs.spec.ts | 373 ++++++++++++++++++ src/test/modules/testing-module.builder.ts | 6 +- 6 files changed, 392 insertions(+), 4 deletions(-) create mode 100644 src/test/admin-query-null-inputs.spec.ts diff --git a/src/features/notification/dto/inputs/admin-notification-broadcast-list.input.spec.ts b/src/features/notification/dto/inputs/admin-notification-broadcast-list.input.spec.ts index f3ac5ed9..9aa790d9 100644 --- a/src/features/notification/dto/inputs/admin-notification-broadcast-list.input.spec.ts +++ b/src/features/notification/dto/inputs/admin-notification-broadcast-list.input.spec.ts @@ -20,6 +20,7 @@ describe('AdminNotificationBroadcastListInput', () => { { limit: 100, cursor: '12', type: 'MARKETING', targetKind: 'ALL_USERS' }, ], ['ACCOUNT_IDS 필터', { type: 'SYSTEM', targetKind: 'ACCOUNT_IDS' }], + ['null 필터(전체)', { type: null, targetKind: null }], ])('%s는 통과한다', async (_label, plain) => { expect(await invalidProps(plain)).toEqual([]); }); diff --git a/src/features/notification/dto/inputs/admin-notification-broadcast-list.input.ts b/src/features/notification/dto/inputs/admin-notification-broadcast-list.input.ts index 1b7602f0..09bff17d 100644 --- a/src/features/notification/dto/inputs/admin-notification-broadcast-list.input.ts +++ b/src/features/notification/dto/inputs/admin-notification-broadcast-list.input.ts @@ -11,9 +11,9 @@ import { export class AdminNotificationBroadcastListInput extends CursorInput { @IsOptional() @IsIn(ADMIN_NOTIFICATION_TYPES) - type?: AdminNotificationTypeValue; + type?: AdminNotificationTypeValue | null; @IsOptional() @IsIn(ADMIN_NOTIFICATION_TARGET_KINDS) - targetKind?: AdminNotificationTargetKindValue; + targetKind?: AdminNotificationTargetKindValue | null; } diff --git a/src/features/notification/services/notification-admin.service.spec.ts b/src/features/notification/services/notification-admin.service.spec.ts index 8c12c071..361d5721 100644 --- a/src/features/notification/services/notification-admin.service.spec.ts +++ b/src/features/notification/services/notification-admin.service.spec.ts @@ -523,6 +523,13 @@ describe('AdminNotificationService (real DB)', () => { targetAccountIds: [], skippedAccountIds: [], }); + + // GraphQL nullable 인자에 명시적 null이 오면 필터 없음과 같다 + const nullFilters = await service.adminNotificationBroadcasts(actor, { + type: null, + targetKind: null, + }); + expect(nullFilters.totalCount).toBe(5); }); it.each(['abc', '-1', '1.5', '18446744073709551616'])( diff --git a/src/features/notification/services/notification-admin.service.ts b/src/features/notification/services/notification-admin.service.ts index a5c77838..39291b97 100644 --- a/src/features/notification/services/notification-admin.service.ts +++ b/src/features/notification/services/notification-admin.service.ts @@ -142,7 +142,10 @@ export class AdminNotificationService extends AdminBaseService { limit: input?.limit ?? null, cursor: input?.cursor != null ? parseIdCursor(input.cursor) : null, }); - const filter = { type: input?.type, targetKind: input?.targetKind }; + const filter = { + type: input?.type ?? undefined, + targetKind: input?.targetKind ?? undefined, + }; const [rows, totalCount] = await Promise.all([ this.repo.listBroadcasts({ ...filter, ...normalized }), this.repo.countBroadcasts(filter), diff --git a/src/test/admin-query-null-inputs.spec.ts b/src/test/admin-query-null-inputs.spec.ts new file mode 100644 index 00000000..2611de58 --- /dev/null +++ b/src/test/admin-query-null-inputs.spec.ts @@ -0,0 +1,373 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + BadRequestException, + type INestApplication, + ValidationPipe, +} from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import type { ValidationError } from 'class-validator'; +import { + buildSchema, + getNamedType, + type GraphQLFormattedError, + type GraphQLInputObjectType, + type GraphQLInputType, + type GraphQLSchema, + isEnumType, + isInputObjectType, + isLeafType, + isListType, + isNonNullType, +} from 'graphql'; +import request from 'supertest'; +import type { App } from 'supertest/types'; + +import { AppModule } from '@/app.module'; +import type { PrismaClient } from '@/generated/prisma/client'; +import { TokenBlacklistService } from '@/global/auth/blacklist'; +import { HttpExceptionFilter } from '@/global/filters/global-exception.filter'; +import { GraphQLExceptionFilter } from '@/global/filters/graphql-exception.filter'; +import { CustomLoggerService } from '@/global/logger/custom-logger.service'; +import { MetricsService } from '@/global/metrics'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { getTestRedisUrl } from '@/test/db/redis-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +// 관리자 FE는 '전체' 필터를 필드 생략이 아니라 null로 보낸다. nullable 입력의 null이 Prisma where까지 내려가면 +// "Argument must not be null"로 500이 난다(매장·상품·배너 isActive, 알림 이력 type·targetKind). 입력 공간은 SDL에서 읽어 +// admin 접두 Query 전수에 nullable 필드를 전부 null로 채워 실제 앱(파이프·필터·가드·실DB)으로 보낸다. + +const FEATURES_DIR = join(__dirname, '..', 'features'); +const ADMIN_QUERY_COUNT = 17; + +/** 필수 스칼라의 최소 유효값. 새 스칼라가 필수 필드로 오면 표에 없다고 던져 결정을 강제한다 */ +const MINIMAL_SCALARS: Record = { + ID: '1', + String: 'x', + Int: 1, + Float: 1, + Boolean: false, + DateTime: '2026-01-01T00:00:00.000Z', +}; + +function collectSdl(dir: string): string { + return readdirSync(dir, { withFileTypes: true }) + .map((entry) => { + const full = join(dir, entry.name); + if (entry.isDirectory()) return collectSdl(full); + return entry.name.endsWith('.graphql') ? readFileSync(full, 'utf8') : ''; + }) + .join('\n'); +} + +/** nullable은 종류와 무관하게 null, 필수는 최소 유효값(입력 객체는 재귀) */ +function minimalValue(type: GraphQLInputType): unknown { + if (!isNonNullType(type)) return null; + const inner = type.ofType; + if (isListType(inner)) return []; + if (isEnumType(inner)) return inner.getValues()[0].name; + if (isInputObjectType(inner)) return nullFilledInput(inner); + if (!(inner.name in MINIMAL_SCALARS)) { + throw new Error( + `필수 스칼라 ${inner.name}의 최소값이 MINIMAL_SCALARS에 없다`, + ); + } + return MINIMAL_SCALARS[inner.name]; +} + +function nullFilledInput( + type: GraphQLInputObjectType, +): Record { + return Object.fromEntries( + Object.values(type.getFields()).map((f) => [f.name, minimalValue(f.type)]), + ); +} + +interface NullInputCase { + field: string; + query: string; + /** 입력 객체 인자는 필드를 null로 채운 객체, 나머지 인자는 minimalValue */ + fieldsNull: Record; + /** nullable 입력 객체 인자 자체를 null로. 그런 인자가 없으면 null */ + argNull: Record | null; +} + +function adminNullInputCases(schema: GraphQLSchema): NullInputCase[] { + return Object.values(schema.getQueryType()?.getFields() ?? {}) + .filter( + (f) => + f.name.startsWith('admin') && + f.args.some((a) => isInputObjectType(getNamedType(a.type))), + ) + .map((f) => { + const defs = f.args.map((a) => `$${a.name}: ${String(a.type)}`); + const uses = f.args.map((a) => `${a.name}: $${a.name}`); + const selection = isLeafType(getNamedType(f.type)) + ? '' + : ' { __typename }'; + const fieldsNull = Object.fromEntries( + f.args.map((a) => { + const named = getNamedType(a.type); + return [ + a.name, + isInputObjectType(named) + ? nullFilledInput(named) + : minimalValue(a.type), + ]; + }), + ); + const nullableInputArgs = f.args.filter( + (a) => !isNonNullType(a.type) && isInputObjectType(a.type), + ); + return { + field: f.name, + query: `query(${defs.join(', ')}) { ${f.name}(${uses.join(', ')})${selection} }`, + fieldsNull, + argNull: + nullableInputArgs.length === 0 + ? null + : { + ...fieldsNull, + ...Object.fromEntries( + nullableInputArgs.map((a) => [a.name, null]), + ), + }, + }; + }); +} + +interface GraphQLBody { + data?: Record | null; + errors?: GraphQLFormattedError[]; +} + +/** 판정: HTTP 5xx, 또는 응답 오류 중 INTERNAL_ERROR·statusCode 5xx */ +function serverErrorsOf(status: number, body: GraphQLBody): string[] { + const found = status >= 500 ? [`HTTP ${status}`] : []; + for (const error of body.errors ?? []) { + const code = error.extensions?.code; + const statusCode = error.extensions?.statusCode; + if ( + code === 'INTERNAL_ERROR' || + (typeof statusCode === 'number' && statusCode >= 500) + ) { + found.push(`${String(code)}(${String(statusCode)}): ${error.message}`); + } + } + return found; +} + +const ENV: Record = { + // PrismaService는 실DB 테스트 클라이언트로 override — 설정 검증만 통과시킨다 + DATABASE_URL: 'mysql://unused:unused@localhost:3306/unused', + RABBITMQ_URL: 'amqp://guest:guest@localhost:5672', + OIDC_GOOGLE_ISSUER_URL: 'https://accounts.google.com', + OIDC_GOOGLE_CLIENT_ID: 'gate', + OIDC_GOOGLE_CLIENT_SECRET: 'gate', + OIDC_KAKAO_ISSUER_URL: 'https://kauth.kakao.com', + OIDC_KAKAO_CLIENT_ID: 'gate', + OIDC_KAKAO_CLIENT_SECRET: 'gate', + OUTBOX_DISPATCH_ENABLED: 'false', +}; + +describe('관리자 Query nullable 입력 null 전수 (real DB)', () => { + const cases = adminNullInputCases(buildSchema(collectSdl(FEATURES_DIR))); + const restored: Array<[string, string | undefined]> = []; + let app: INestApplication; + let prisma: PrismaClient; + let token: string; + + beforeAll(async () => { + for (const [key, value] of Object.entries({ + ...ENV, + REDIS_URL: getTestRedisUrl(), + })) { + restored.push([key, process.env[key]]); + process.env[key] = value; + } + const { module, prisma: p } = await createTestingModuleWithRealDb({ + imports: [AppModule.forRole('api')], + }); + prisma = p; + app = module.createNestApplication>(); + // main.ts와 같은 전역 파이프·필터 — 응답 코드가 운영과 같아야 판정이 맞다 + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + exceptionFactory: (errors: ValidationError[]) => + new BadRequestException({ + message: errors.map((e) => ({ + property: e.property, + constraints: e.constraints ?? {}, + })), + }), + }), + ); + const logger = app.get(CustomLoggerService); + app.useGlobalFilters( + new HttpExceptionFilter( + app.get(HttpAdapterHost).httpAdapter, + logger, + new GraphQLExceptionFilter(logger, app.get(MetricsService)), + ), + ); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + for (const [key, value] of restored) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + beforeEach(async () => { + await truncateAll(); + // 블랙리스트 정상 경로(표식 있음) — 미구축이면 DB 폴백과 경보로 빠진다 + const blacklist = app.get(TokenBlacklistService); + await blacklist.markReady(await blacklist.generation()); + const admin = await createAccount(prisma, { account_type: 'ADMIN' }); + token = app + .get(JwtService) + .sign({ sub: admin.id.toString(), typ: 'access', role: 'ADMIN' }); + }); + + async function post( + query: string, + variables: Record, + ): Promise<{ status: number; body: GraphQLBody }> { + const res = await request(app.getHttpServer()) + .post('/graphql') + .set('Authorization', `Bearer ${token}`) + .send({ query, variables }); + return { + status: res.status, + body: res.body as GraphQLBody, + }; + } + + it(`input 객체 인자를 받는 admin 접두 Query는 ${ADMIN_QUERY_COUNT}개다(표가 비거나 모르게 늘지 않는다)`, () => { + expect(cases.map((c) => c.field)).toHaveLength(ADMIN_QUERY_COUNT); + }); + + it.each(cases)( + '$field: nullable 필드를 전부 null로 보내도 5xx·INTERNAL_ERROR가 없다', + async ({ field, query, fieldsNull }) => { + const { status, body } = await post(query, fieldsNull); + expect(serverErrorsOf(status, body)).toEqual([]); + // 인증·검증에서 막혀 리졸버에 닿지 않은 응답은 통과로 치지 않는다 + expect(body).toEqual({ data: { [field]: expect.anything() } }); + }, + ); + + it.each(cases.filter((c) => c.argNull !== null))( + '$field: nullable input 인자 자체를 null로 보내도 5xx·INTERNAL_ERROR가 없다', + async ({ field, query, argNull }) => { + const { status, body } = await post(query, argNull!); + expect(serverErrorsOf(status, body)).toEqual([]); + expect(body).toEqual({ data: { [field]: expect.anything() } }); + }, + ); + + describe('검출기 반증', () => { + it('입력 오류는 같은 경로에서 VALIDATION_FAILED(400)로 나가 판정에 걸리지 않는다(파이프·필터 배선 대조)', async () => { + const target = cases.find( + (c) => c.field === 'adminNotificationBroadcasts', + )!; + const { status, body } = await post(target.query, { + input: { limit: 0 }, + }); + expect(body.errors?.[0]?.extensions).toMatchObject({ + code: 'VALIDATION_FAILED', + statusCode: 400, + }); + expect(serverErrorsOf(status, body)).toEqual([]); + }); + + it('판정기는 INTERNAL_ERROR·statusCode 5xx·HTTP 5xx를 잡고 4xx는 넘긴다', () => { + const error = (code: string, statusCode: number) => ({ + message: 'm', + extensions: { code, statusCode }, + }); + expect( + serverErrorsOf(200, { errors: [error('INTERNAL_ERROR', 500)] }), + ).toHaveLength(1); + expect( + serverErrorsOf(200, { errors: [error('SOMETHING', 503)] }), + ).toHaveLength(1); + expect(serverErrorsOf(502, {})).toEqual(['HTTP 502']); + expect( + serverErrorsOf(200, { + errors: [error('VALIDATION_FAILED', 400), error('ADMIN_ONLY', 403)], + }), + ).toEqual([]); + }); + + it('입력 생성기는 nullable 필드를 종류와 무관하게 null로, 필수 필드를 최소값으로 채운다', () => { + const generated = adminNullInputCases( + buildSchema(` + scalar DateTime + enum Kind { A B } + input Nested { at: DateTime! } + input SampleInput { + limit: Int = 20 + kind: Kind + flag: Boolean = false + ids: [ID!] + nested: Nested + requiredKind: Kind! + requiredNested: Nested! + } + type Page { total: Int! } + type Query { + adminSample(input: SampleInput): Page! + adminScalarOnly(id: ID!): Int + sellerSample(input: SampleInput): Page! + } + `), + ); + // admin 접두가 아니거나 input 객체 인자가 없는 필드는 빠진다 + expect(generated).toEqual([ + { + field: 'adminSample', + query: + 'query($input: SampleInput) { adminSample(input: $input) { __typename } }', + fieldsNull: { + input: { + limit: null, + kind: null, + flag: null, + ids: null, + nested: null, + requiredKind: 'A', + requiredNested: { at: '2026-01-01T00:00:00.000Z' }, + }, + }, + argNull: { input: null }, + }, + ]); + }); + + it('필수 필드에 표에 없는 스칼라가 오면 던진다', () => { + expect(() => + adminNullInputCases( + buildSchema(` + scalar Money + input MoneyInput { amount: Money! } + type Query { adminMoney(input: MoneyInput): Int } + `), + ), + ).toThrow('필수 스칼라 Money의 최소값이 MINIMAL_SCALARS에 없다'); + }); + }); +}); diff --git a/src/test/modules/testing-module.builder.ts b/src/test/modules/testing-module.builder.ts index b153df0d..678e574d 100644 --- a/src/test/modules/testing-module.builder.ts +++ b/src/test/modules/testing-module.builder.ts @@ -9,6 +9,7 @@ import { getTestPrismaClient } from '@/test/db/prisma-test-client'; /** * 실DB(Testcontainers) Prisma 클라이언트를 PrismaService 위치에 주입한다. RequestContextService(ALS)도 기본 * 제공한다 — AuditLogRepository 등 요청 컨텍스트를 주입받는 provider가 어디서나 resolve 되도록(run() 밖이면 빈 컨텍스트). + * imports에 PrismaModule을 품은 모듈 트리(AppModule)가 와도 같은 클라이언트를 쓰도록 override도 건다. */ export async function createTestingModuleWithRealDb( metadata: ModuleMetadata, @@ -25,7 +26,10 @@ export async function createTestingModuleWithRealDb( }, RequestContextService, ], - }).compile(); + }) + .overrideProvider(PrismaService) + .useValue(prisma) + .compile(); return { module, prisma }; } From 62b84415747b85bd14a37a2e72151582ace79d8d Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Sun, 4 Oct 2026 19:13:15 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EA=B3=B5=EA=B0=9C=20categories?= =?UTF-8?q?=EC=9D=98=20type=20null=20500=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전수 점검 중 발견: categories(input: { type: null })이 category_type: null을 Prisma에 넘겨 500(홈 칩·카테고리 진입 화면이 쓰는 조회) - service ?? undefined 정규화 + DTO | null - 회귀: type null이면 전체(수정 전 'category_type must not be null'로 실패 확인) --- src/features/product/dto/inputs/categories.input.ts | 2 +- .../product/services/product-category.service.spec.ts | 9 +++++++++ .../product/services/product-category.service.ts | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/features/product/dto/inputs/categories.input.ts b/src/features/product/dto/inputs/categories.input.ts index b9727e4d..531ffcaf 100644 --- a/src/features/product/dto/inputs/categories.input.ts +++ b/src/features/product/dto/inputs/categories.input.ts @@ -3,5 +3,5 @@ import { IsIn, IsOptional } from 'class-validator'; export class CategoriesInput { @IsOptional() @IsIn(['EVENT', 'STYLE', 'OTHER']) - type?: 'EVENT' | 'STYLE' | 'OTHER'; + type?: 'EVENT' | 'STYLE' | 'OTHER' | null; } diff --git a/src/features/product/services/product-category.service.spec.ts b/src/features/product/services/product-category.service.spec.ts index 052e475b..0c26a4ef 100644 --- a/src/features/product/services/product-category.service.spec.ts +++ b/src/features/product/services/product-category.service.spec.ts @@ -65,6 +65,15 @@ describe('ProductCategoryService (real DB)', () => { ]); }); + it('type이 null이면 미지정과 같이 전체를 반환한다', async () => { + await createCategory(prisma, { category_type: 'EVENT', name: '생일' }); + await createCategory(prisma, { category_type: 'STYLE', name: '입체' }); + + const result = await service.categories({ type: null }); + + expect(result.map((c) => c.name)).toEqual(['생일', '입체']); + }); + it('type 지정 시 해당 타입만 반환한다', async () => { await createCategory(prisma, { category_type: 'EVENT', name: '생일' }); await createCategory(prisma, { category_type: 'STYLE', name: '입체' }); diff --git a/src/features/product/services/product-category.service.ts b/src/features/product/services/product-category.service.ts index d51f41f6..485fe7c4 100644 --- a/src/features/product/services/product-category.service.ts +++ b/src/features/product/services/product-category.service.ts @@ -9,7 +9,8 @@ export class ProductCategoryService { constructor(private readonly repo: ProductRepository) {} async categories(input?: CategoriesInput): Promise { - const rows = await this.repo.listCategories(input?.type); + // FE가 '전체'를 type: null로 보내도 Prisma where에 null이 닿지 않게 + const rows = await this.repo.listCategories(input?.type ?? undefined); return rows.map((row) => ({ id: row.id.toString(), name: row.name,