diff --git a/src/features/region/repositories/region-incheon-reorg.migration.spec.ts b/src/features/region/repositories/region-incheon-reorg.migration.spec.ts index 06bea082..2e277b87 100644 --- a/src/features/region/repositories/region-incheon-reorg.migration.spec.ts +++ b/src/features/region/repositories/region-incheon-reorg.migration.spec.ts @@ -31,130 +31,6 @@ const NEW = { geomdan: 'sgg-28290', }; -// 행정안전부 법정동·행정동 코드(2026-09-30) 기준 떨어져 나간 구의 동 전부 -const YEONGJONG_DONGS = [ - '중산동', - '운남동', - '운서동', - '운북동', - '을왕동', - '남북동', - '덕교동', - '무의동', - '영종동', - '영종1동', - '영종2동', - '운서1동', - '운서2동', - '용유동', -]; -const GEOMDAN_DONGS = [ - '백석동', - '마전동', - '당하동', - '원당동', - '대곡동', - '금곡동', - '왕길동', - '불로동', - '검단동', - '불로대곡동', - '아라1동', - '아라2동', -]; -// 아라뱃길 남측 필지가 검암동·경서동(서해구)으로 넘어가 개편 전 이름만으로는 승계 구를 정할 수 없는 동 -const AMBIGUOUS_DONGS = ['시천동', '오류동', '오류왕길동']; - -type DongKind = 'split' | 'ambiguous' | 'remain' | 'other'; -const BLANKS: [string, string | null][] = [ - ['null', null], - ['빈칸', ''], - ['공백만', ' '], -]; -const OLD_DISTRICTS = { - [OLD.jung]: { - name: '중구', - split: NEW.yeongjong, - remain: NEW.jemulpo, - neighborhoods: [ - ['운서동', 'split'], - [' 운서동 ', 'split'], - ['신포동', 'remain'], - ['테스트동', 'other'], - ] as [string, DongKind][], - addresses: [ - ['인천 중구 영종대로 85 (운서동)', 'split'], - ['인천 중구 신포동 1', 'remain'], - ['인천 중구 공항로 272', 'other'], - ] as [string, DongKind][], - }, - [OLD.seo]: { - name: '서구', - split: NEW.geomdan, - remain: NEW.seohae, - neighborhoods: [ - ['마전동', 'split'], - [' 원당동 ', 'split'], - ['시천동', 'ambiguous'], - ['청라동', 'remain'], - ['테스트동', 'other'], - ] as [string, DongKind][], - addresses: [ - ['인천 서구 마전동 100', 'split'], - ['인천 서구 오류동 1', 'ambiguous'], - ['인천 서구 청라동 1', 'remain'], - ['인천 서구 서곶로 1', 'other'], - ] as [string, DongKind][], - }, -}; - -/** 판정 규칙의 기대값. 모호한 동은 옮기지 않고 옛 구에 남는다. */ -function expectedSuccessor(oldSlug: string, kind: DongKind): string { - const district = OLD_DISTRICTS[oldSlug]; - if (kind === 'split') return district.split; - if (kind === 'ambiguous') return oldSlug; - return district.remain; -} - -const DECISION_TABLE: [string, string, string | null, string, string][] = [ - ...Object.entries(OLD_DISTRICTS).flatMap(([oldSlug, d]) => [ - // 동 칸이 비어 있지 않으면 주소와 상관없이 동 칸으로 정한다 - ...d.neighborhoods.flatMap(([dong, kind]) => - d.addresses.map( - ([address]): [string, string, string | null, string, string] => [ - `${d.name} / 동 칸 '${dong}' / 주소 '${address}'`, - oldSlug, - dong, - address, - expectedSuccessor(oldSlug, kind), - ], - ), - ), - // 동 칸이 비었으면 주소로 정한다 - ...BLANKS.flatMap(([blankLabel, blank]) => - d.addresses.map( - ([address, kind]): [string, string, string | null, string, string] => [ - `${d.name} / 동 칸 ${blankLabel} / 주소 '${address}'`, - oldSlug, - blank, - address, - expectedSuccessor(oldSlug, kind), - ], - ), - ), - ]), - // 동구는 전부 제물포구로 갔다 - ...([null, '송림동', '운서동', '마전동'] as (string | null)[]).map( - (dong): [string, string, string | null, string, string] => [ - `동구 / 동 칸 '${String(dong)}' → 제물포구`, - OLD.dong, - dong, - '인천 동구 운서동 1', - NEW.jemulpo, - ], - ), -]; - describe('region_incheon_reorg 마이그레이션 (real DB)', () => { let prisma: PrismaClient; @@ -277,115 +153,56 @@ describe('region_incheon_reorg 마이그레이션 (real DB)', () => { ]); }); - it.each(YEONGJONG_DONGS)( - '중구 매장의 동이 %s이면 영종구로 옮긴다', - async (dong) => { - const ids = await seedBeforeReorg(); - const store = await createStore(prisma, { - region_id: ids[OLD.jung], - address_neighborhood: dong, - address_full: '인천 중구 공항로 272', - }); - - await runMigration(); - - expect(await regionSlugOfStore(store.id)).toBe(NEW.yeongjong); - }, - ); - - it.each(GEOMDAN_DONGS)( - '서구 매장의 동이 %s이면 검단구로 옮긴다', - async (dong) => { - const ids = await seedBeforeReorg(); - const store = await createStore(prisma, { - region_id: ids[OLD.seo], - address_neighborhood: dong, - address_full: '인천 서구 서곶로 1', - }); - - await runMigration(); - - expect(await regionSlugOfStore(store.id)).toBe(NEW.geomdan); - }, - ); - - it.each(AMBIGUOUS_DONGS)( - '서구 매장의 동이 %s이면 옮기지 않고 옛 서구를 비활성으로만 남긴다', - async (dong) => { - const ids = await seedBeforeReorg(); - const store = await createStore(prisma, { - region_id: ids[OLD.seo], - address_neighborhood: dong, - address_full: '인천 서구 정서진로 1', - }); - - await runMigration(); - - expect(await regionSlugOfStore(store.id)).toBe(OLD.seo); - const seo = await prisma.region.findUniqueOrThrow({ - where: { slug: OLD.seo }, - }); - expect(seo.is_active).toBe(false); - expect(seo.deleted_at).toBeNull(); - }, - ); - - // 판정 입력 공간 전수: 옛 구 × 동 칸 종류 × 주소 종류. 동 칸이 비어 있지 않으면 동 칸만, 비었을 때만 주소를 본다 - it.each(DECISION_TABLE)( - '%s', - async (_label, oldSlug, neighborhood, addressFull, expected) => { - const ids = await seedBeforeReorg(); - const store = await createStore(prisma, { - region_id: ids[oldSlug], - address_full: addressFull, - }); - await prisma.store.update({ - where: { id: store.id }, - data: { address_neighborhood: neighborhood }, - }); - - await runMigration(); - - expect(await regionSlugOfStore(store.id)).toBe(expected); - }, - ); - + // 동 칸이 비어 있지 않으면 동 칸만, 비었을 때만 주소를 본다 it.each([ - ['지번 주소', OLD.jung, '인천 중구 운서동 2850-1', NEW.yeongjong], + ['동구는 전부 제물포구', OLD.dong, '송림동', '', NEW.jemulpo], + ['중구 영종 동', OLD.jung, '운서동', '', NEW.yeongjong], + ['중구 나머지 동', OLD.jung, '신포동', '', NEW.jemulpo], + ['서구 검단 동', OLD.seo, '마전동', '', NEW.geomdan], + ['서구 나머지 동', OLD.seo, '청라동', '', NEW.seohae], [ - '도로명 주소 괄호', + '동 칸이 비면 주소(괄호 표기)', OLD.jung, + '', '인천 중구 영종대로 85 (운서동)', NEW.yeongjong, ], [ - '괄호 안 쉼표 앞', + '동 칸이 주소보다 우선', OLD.jung, - '인천 중구 하늘중앙로 1 (중산동, 스카이시티)', - NEW.yeongjong, - ], - ['동 이름이 길 이름의 일부', OLD.jung, '인천 중구 운서동길 3', NEW.jemulpo], - [ - '확정 동과 모호한 동이 함께 있으면 확정 동', - OLD.seo, - '인천 서구 마전동 100 (오류동 인근)', - NEW.geomdan, + '신포동', + '인천 중구 운서동 1', + NEW.jemulpo, ], - ])( - '동 칸이 비었을 때 주소 형식: %s', - async (_label, oldSlug, addressFull, expected) => { - const ids = await seedBeforeReorg(); - const store = await createStore(prisma, { - region_id: ids[oldSlug], - address_neighborhood: '', - address_full: addressFull, - }); + ])('%s', async (_label, oldSlug, dong, addressFull, expected) => { + const ids = await seedBeforeReorg(); + const store = await createStore(prisma, { + region_id: ids[oldSlug], + address_neighborhood: dong, + address_full: addressFull, + }); - await runMigration(); + await runMigration(); - expect(await regionSlugOfStore(store.id)).toBe(expected); - }, - ); + expect(await regionSlugOfStore(store.id)).toBe(expected); + }); + + it('서구 시천동·오류동처럼 승계 구가 모호하면 옮기지 않고 옛 서구를 비활성으로만 남긴다', async () => { + const ids = await seedBeforeReorg(); + const store = await createStore(prisma, { + region_id: ids[OLD.seo], + address_neighborhood: '시천동', + }); + + await runMigration(); + + expect(await regionSlugOfStore(store.id)).toBe(OLD.seo); + const seo = await prisma.region.findUniqueOrThrow({ + where: { slug: OLD.seo }, + }); + expect(seo.is_active).toBe(false); + expect(seo.deleted_at).toBeNull(); + }); it('삭제된 매장도 옮긴다', async () => { const ids = await seedBeforeReorg(); diff --git a/src/features/region/services/region-location.service.spec.ts b/src/features/region/services/region-location.service.spec.ts index db7e764c..43e35761 100644 --- a/src/features/region/services/region-location.service.spec.ts +++ b/src/features/region/services/region-location.service.spec.ts @@ -7,7 +7,8 @@ import { LocationAccessLogRepository } from '@/features/region/repositories/loca import { RegionRepository } from '@/features/region/repositories/region.repository'; import { LocationAccessLogService } from '@/features/region/services/location-access-log.service'; import { - nextCacheExpiry, + LOCATION_CACHE_TTL_SECONDS, + locationCacheKey, RegionLocationService, } from '@/features/region/services/region-location.service'; import type { PrismaClient } from '@/generated/prisma/client'; @@ -36,7 +37,6 @@ describe('RegionLocationService (real DB)', () => { let prisma: PrismaClient; let redis: Redis; let apiKey: string | undefined; - let now = NOW; const transport = jest.fn, [string, RequestInit]>(); /** 실제 클라이언트에서 get·set만 실패시킨다. */ @@ -60,7 +60,7 @@ describe('RegionLocationService (real DB)', () => { RegionRepository, LocationAccessLogService, LocationAccessLogRepository, - { provide: ClockService, useValue: { now: () => now } }, + { provide: ClockService, useValue: { now: () => NOW } }, { provide: ConfigService, useValue: { @@ -89,7 +89,6 @@ describe('RegionLocationService (real DB)', () => { await truncateAll(); await redis.flushdb(); apiKey = 'rest-key'; - now = NOW; transport.mockReset(); jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); }); @@ -314,104 +313,24 @@ describe('RegionLocationService (real DB)', () => { }); describe('캐시', () => { - it('같은 약 1m 격자는 카카오에 다시 묻지 않는다', async () => { + it('같은 약 100m 격자는 카카오에 다시 묻지 않는다', async () => { const { district } = await seedDistrict('sgg-11140'); kakaoReplies(regionDocs('1114010300')); - // 둘 다 소수 5자리로 반올림하면 (37.56620, 126.97810) + // 둘 다 소수 3자리로 반올림하면 (37.566, 126.978) await service.regionByLocation( - { latitude: 37.566201, longitude: 126.978101 }, + { latitude: 37.5662, longitude: 126.9781 }, null, ); const again = await service.regionByLocation( - { latitude: 37.566204, longitude: 126.978104 }, + { latitude: 37.5664, longitude: 126.9784 }, null, ); expect(again?.region.id).toBe(district.id.toString()); expect(transport).toHaveBeenCalledTimes(1); - }); - - it('약 10m 떨어진 점은 따로 물어 경계 건너편 구를 받는다', async () => { - const { district: north } = await seedDistrict('sgg-11110'); - const { district: south } = await seedDistrict('sgg-11140'); - transport - .mockResolvedValueOnce( - new Response(JSON.stringify(regionDocs('1114010300'))), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify(regionDocs('1111010100'))), - ); - - const first = await service.regionByLocation( - { latitude: 37.5662, longitude: 126.9781 }, - null, - ); - const across = await service.regionByLocation( - { latitude: 37.5663, longitude: 126.9781 }, - null, - ); - - expect(first?.region.id).toBe(south.id.toString()); - expect(across?.region.id).toBe(north.id.toString()); - }); - - it('캐시 키에는 좌표가 없다(격자의 HMAC)', async () => { - kakaoReplies({ documents: [] }); - - await service.regionByLocation(SEOUL_CITY_HALL, null); - - const keys = await redis.keys('region:loc:*'); - expect(keys).toHaveLength(1); - // base64url 43자 — 소수점·구분자가 없어 격자 좌표가 들어갈 자리가 없다 - expect(keys[0]).toMatch(/^region:loc:[A-Za-z0-9_-]{43}$/); - }); - - it('재기동(새 인스턴스)하면 같은 격자도 다른 키라 이전 캐시를 쓰지 않는다', async () => { - kakaoReplies({ documents: [] }); - const restarted = await build(redis); - - await service.regionByLocation(SEOUL_CITY_HALL, null); - await restarted.regionByLocation(SEOUL_CITY_HALL, null); - - expect(await redis.keys('region:loc:*')).toHaveLength(2); - expect(transport).toHaveBeenCalledTimes(2); - }); - - it('만료 시각은 요청 시각과 무관하게 다음 04:00 KST로 같다', async () => { - kakaoReplies({ documents: [] }); - - now = new Date('2026-10-04T01:00:00.000Z'); - await service.regionByLocation(SEOUL_CITY_HALL, null); - now = new Date('2026-10-04T11:30:00.000Z'); - await service.regionByLocation( - { latitude: 37.5702, longitude: 126.9821 }, - null, - ); - - const keys = await redis.keys('region:loc:*'); - const expiries = await Promise.all( - keys.map((key) => redis.call('PEXPIRETIME', key)), - ); - expect(keys).toHaveLength(2); - expect(expiries).toEqual([ - Date.parse('2026-10-04T19:00:00.000Z'), - Date.parse('2026-10-04T19:00:00.000Z'), - ]); - }); - - it.each([ - ['KST 오후', '2026-10-03T05:00:00.000Z', '2026-10-03T19:00:00.000Z'], - ['04시 1ms 전', '2026-10-03T18:59:59.999Z', '2026-10-03T19:00:00.000Z'], - [ - '정확히 04시면 다음 날', - '2026-10-03T19:00:00.000Z', - '2026-10-04T19:00:00.000Z', - ], - ['KST 자정 넘김', '2026-10-03T15:30:00.000Z', '2026-10-03T19:00:00.000Z'], - ['월말', '2026-10-31T20:00:00.000Z', '2026-11-01T19:00:00.000Z'], - ])('nextCacheExpiry: %s', (_label, at, expected) => { - expect(nextCacheExpiry(new Date(at)).toISOString()).toBe(expected); + const ttl = await redis.ttl(locationCacheKey(37.5662, 126.9781)); + expect(ttl).toBeGreaterThan(LOCATION_CACHE_TTL_SECONDS - 5); }); it('다른 격자는 다시 묻는다', async () => { @@ -419,7 +338,7 @@ describe('RegionLocationService (real DB)', () => { await service.regionByLocation(SEOUL_CITY_HALL, null); await service.regionByLocation( - { latitude: 37.56651, longitude: 126.978 }, + { latitude: 37.5675, longitude: 126.978 }, null, ); diff --git a/src/features/region/services/region-location.service.ts b/src/features/region/services/region-location.service.ts index 830a1fa9..722abce5 100644 --- a/src/features/region/services/region-location.service.ts +++ b/src/features/region/services/region-location.service.ts @@ -1,11 +1,8 @@ -import { createHmac, randomBytes } from 'node:crypto'; - import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type Redis from 'ioredis'; import { DomainException } from '@/common/errors/error-catalog'; -import { ClockService } from '@/common/providers/clock.service'; import { districtSlugOf, sigunguCodeOf } from '@/common/utils/legal-dong-code'; import type { KakaoLocalConfig } from '@/config/kakao-local.config'; import type { RegionByLocationInput } from '@/features/region/dto/inputs/region-by-location.input'; @@ -24,27 +21,20 @@ const KAKAO_COORD_TO_REGION_URL = export const LOCATION_LOOKUP_TIMEOUT_MS = 3_000; /** 국내(마라도·독도·백령도 포함) 밖이면 서비스 지역일 수 없어 카카오에 묻지 않는다. */ const KOREA_BOUNDS = { minLat: 33, maxLat: 39, minLng: 124, maxLng: 132 }; -/** - * 소수 5자리(약 1m) 격자로 카카오 결과(시군구 코드)를 캐시한다 — 격자가 구 경계를 걸쳐도 오차가 측위 오차보다 작다. - * 지역 활성 여부는 매번 DB에서 본다. - */ -const CACHE_PRECISION = 5; -/** 캐시는 매일 04:00 KST(19:00 UTC)에 일괄 만료한다 — 요청마다 TTL을 주면 남은 TTL로 요청 시각이 드러난다. */ -const CACHE_EXPIRES_AT_UTC_HOUR = 19; +/** 소수 3자리(약 100m) 격자로 카카오 결과(시군구 코드)를 캐시한다. 지역 활성 여부는 매번 DB에서 본다. */ +const CACHE_PRECISION = 3; +export const LOCATION_CACHE_TTL_SECONDS = 24 * 60 * 60; const NO_DISTRICT = '-'; -export function nextCacheExpiry(now: Date): Date { - const at = new Date(now); - at.setUTCHours(CACHE_EXPIRES_AT_UTC_HOUR, 0, 0, 0); - if (at <= now) at.setUTCDate(at.getUTCDate() + 1); - return at; -} - interface KakaoRegionDocument { region_type?: unknown; code?: unknown; } +export function locationCacheKey(latitude: number, longitude: number): string { + return `region:loc:${latitude.toFixed(CACHE_PRECISION)}:${longitude.toFixed(CACHE_PRECISION)}`; +} + function inKorea({ latitude, longitude }: RegionByLocationInput): boolean { return ( latitude >= KOREA_BOUNDS.minLat && @@ -74,19 +64,17 @@ function sigunguCodeFrom(documents: KakaoRegionDocument[]): string | null { } /** - * 현재 위치 → 2차 지역. 좌표는 카카오 요청에만 쓰고 저장·로그하지 않는다. 이용 사실은 위치정보법 확인자료로 남긴다. - * 캐시 키는 격자 좌표의 HMAC이고 비밀값은 기동마다 새로 뽑아 메모리에만 둔다 — Redis만으로는 위치를 되돌릴 수 없다. + * 현재 위치 → 2차 지역. 좌표는 카카오 요청에만 쓰고 저장·로그하지 않는다(캐시 키는 약 100m 격자이고 이용자와 묶이지 않는다). + * 이용 사실은 위치정보법 확인자료로 남긴다. */ @Injectable() export class RegionLocationService { private readonly logger = new Logger(RegionLocationService.name); - private readonly cacheSecret = randomBytes(32); constructor( private readonly repo: RegionRepository, private readonly accessLogs: LocationAccessLogService, private readonly config: ConfigService, - private readonly clock: ClockService, @Inject(REDIS_CLIENT) private readonly redis: Redis, @Inject(KAKAO_LOCAL_TRANSPORT) private readonly transport: KakaoLocalTransport, @@ -110,7 +98,7 @@ export class RegionLocationService { private async sigunguCodeAt( input: RegionByLocationInput, ): Promise { - const key = this.cacheKey(input); + const key = locationCacheKey(input.latitude, input.longitude); const cached = await this.redis.get(key).catch((error: unknown) => { this.logger.warn(`위치 캐시 조회 실패: ${String(error)}`); return null; @@ -119,23 +107,13 @@ export class RegionLocationService { const code = await this.fetchSigunguCode(input); await this.redis - .set( - key, - code ?? NO_DISTRICT, - 'PXAT', - nextCacheExpiry(this.clock.now()).getTime(), - ) + .set(key, code ?? NO_DISTRICT, 'EX', LOCATION_CACHE_TTL_SECONDS) .catch((error: unknown) => this.logger.warn(`위치 캐시 저장 실패: ${String(error)}`), ); return code; } - private cacheKey({ latitude, longitude }: RegionByLocationInput): string { - const cell = `${latitude.toFixed(CACHE_PRECISION)}:${longitude.toFixed(CACHE_PRECISION)}`; - return `region:loc:${createHmac('sha256', this.cacheSecret).update(cell).digest('base64url')}`; - } - private async fetchSigunguCode({ latitude, longitude, diff --git a/src/global/graphql/format-graphql-error.spec.ts b/src/global/graphql/format-graphql-error.spec.ts index d92f7591..68b90912 100644 --- a/src/global/graphql/format-graphql-error.spec.ts +++ b/src/global/graphql/format-graphql-error.spec.ts @@ -1,11 +1,8 @@ import { Logger } from '@nestjs/common'; import { buildSchema, - coerceInputValue, GraphQLError, - GraphQLFloat, parse, - validate, type GraphQLFormattedError, type OperationDefinitionNode, } from 'graphql'; @@ -13,7 +10,7 @@ import { getVariableValues } from 'graphql/execution/values'; import { formatGraphqlError, - redactInputValues, + redactVariableValue, } from '@/global/graphql/format-graphql-error'; const LOCATED = { locations: [{ line: 1, column: 3 }], path: ['x'] }; @@ -81,7 +78,7 @@ describe('formatGraphqlError', () => { }, }); expect(warn).toHaveBeenCalledWith( - `${apolloCode}: Variable "$input" got invalid value [redacted] at "input.sortOrder"; Int cannot represent non 32-bit signed integer value: [redacted]`, + `${apolloCode}: Variable "$input" got invalid value [redacted]`, ); expect(error).not.toHaveBeenCalled(); }, @@ -148,455 +145,53 @@ describe('formatGraphqlError', () => { }); }); -describe('redactInputValues', () => { - const SENTINEL = /37\.?5665/; +describe('redactVariableValue', () => { const schema = buildSchema(` - enum Kind { HOME WORK } input Loc { latitude: Float! longitude: Float! } - input Probe { f: Float i: Int s: String b: Boolean id: ID e: Kind loc: Loc } - type Query { - at(input: Loc!): Boolean - probe(input: Probe!): Boolean - float(v: Float): Boolean - int(v: Int): Boolean - string(v: String): Boolean - bool(v: Boolean): Boolean - id(v: ID): Boolean - kind(v: Kind): Boolean - } + type Query { at(v: Loc!): Boolean float(v: Float): Boolean } `); - function variableMessage(query: string, input: unknown): string { - const [operation] = parse(query).definitions as OperationDefinitionNode[]; + function variableMessage( + type: string, + field: string, + value: unknown, + ): string { + const [operation] = parse(`query Q($v: ${type}) { ${field}(v: $v) }`) + .definitions as OperationDefinitionNode[]; const result = getVariableValues( schema, operation.variableDefinitions ?? [], - { input }, + { v: value }, ); if (!result.errors) throw new Error('강제 변환이 성공했다'); return result.errors[0].message; } - const probe = (input: unknown) => - variableMessage('query Q($input: Probe!) { probe(input: $input) }', input); - const at = (input: unknown) => - variableMessage('query Q($input: Loc!) { at(input: $input) }', input); - - function literalMessage(query: string): string { - const [error] = validate(schema, parse(query)); - if (!error) throw new Error('검증이 통과했다'); - return error.message; - } - - function syntaxMessage(query: string): string { - try { - parse(query); - } catch (error) { - return (error as GraphQLError).message; - } - throw new Error('파싱이 성공했다'); - } - - function coerceMessage(value: unknown): string { - try { - coerceInputValue(value, GraphQLFloat); - } catch (error) { - return (error as GraphQLError).message; - } - throw new Error('강제 변환이 성공했다'); - } - // graphql-js가 값을 싣는 입력 오류 템플릿 전수. 문구는 실제 graphql-js 호출로 만든다 + // 값·키(구분자를 심어도)·사유를 모두 버린다 it.each([ - ['변수: Float에 문자열', () => probe({ f: '37.5665' })], - ['변수: Float에 배열', () => probe({ f: [37.5665] })], - ['변수: Float에 객체', () => probe({ f: { v: 37.5665 } })], - ['변수: Int에 소수', () => probe({ i: 37.5665 })], - ['변수: Int 32비트 초과', () => probe({ i: 3756650000000 })], - ['변수: String에 숫자', () => probe({ s: 37.5665 })], - ['변수: Boolean에 숫자', () => probe({ b: 37.5665 })], - ['변수: ID에 객체', () => probe({ id: { lat: 37.5665 } })], - ['변수: 열거형에 모르는 문자열', () => probe({ e: '37.5665' })], - ['변수: 열거형에 숫자', () => probe({ e: 37.5665 })], + ['Float 자리에 문자열', () => variableMessage('Float', 'float', '37.5665')], [ - '변수: 모르는 필드', - () => at({ latitude: 37.5665, longitude: 1, foo: 1 }), - ], - ['변수: 필드 누락', () => at({ latitude: 37.5665 })], - ['변수: 객체가 아닌 값', () => at('37.5665,126.978')], - ['변수: 값 안의 세미콜론', () => at({ latitude: 37.5665, note: 'a; b' })], - [ - '변수: 숫자 모양 필드 이름', - () => at({ latitude: 1, longitude: 1, '37.5665': 1 }), - ], - [ - '변수: 접미 문구를 심은 필드 이름', + '구분자를 심은 키', () => - at({ + variableMessage('Loc!', 'at', { latitude: 1, longitude: 1, - 'x" is not defined by type "Loc". sneaky37.5665': 1, + 'a; Field "b" is not defined by type "Loc". 37.5665': 1, }), ], - [ - '변수: 따옴표를 심은 필드 이름', - () => - at({ - latitude: 1, - longitude: 1, - 'k" is not defined by type 37.5665': 1, - }), - ], - [ - '리터럴: 모르는 필드 이름', - () => - 'Field "lat37.5665" is not defined by type "Loc". Did you mean "latitude"?', - ], - ['리터럴: Float에 문자열', () => literalMessage('{ float(v: "37.5665") }')], - ['리터럴: Float에 배열', () => literalMessage('{ float(v: [37.5665]) }')], - [ - '리터럴: Float에 객체', - () => literalMessage('{ float(v: { a: 37.5665 }) }'), - ], - ['리터럴: Int에 소수', () => literalMessage('{ int(v: 37.5665) }')], - [ - '리터럴: Int 32비트 초과', - () => literalMessage('{ int(v: 3756650000000) }'), - ], - ['리터럴: String에 숫자', () => literalMessage('{ string(v: 37.5665) }')], - ['리터럴: Boolean에 숫자', () => literalMessage('{ bool(v: 37.5665) }')], - ['리터럴: ID에 소수', () => literalMessage('{ id(v: 37.5665) }')], - ['리터럴: 열거형에 문자열', () => literalMessage('{ kind(v: "37.5665") }')], - [ - '리터럴: 입력 객체 필드', - () => - literalMessage('{ at(input: { latitude: "37.5665", longitude: 1 }) }'), - ], - ['파서: 이름 자리에 숫자', () => syntaxMessage('{ at 37.5665 }')], - ['파서: 문서 첫 토큰', () => syntaxMessage('37.5665')], - ['coerceInputValue 기본 문구', () => coerceMessage('37.5665')], - ['실행 인자', () => 'Argument "v" has invalid value 37.5665.'], - ])('%s: 값을 가린다', (_label, make) => { + ['필드 누락', () => variableMessage('Loc!', 'at', { latitude: 37.5665 })], + ])('변수 오류(%s)는 변수명만 남긴다', (_label, make) => { const message = make(); - expect(message).toMatch(SENTINEL); - - const redacted = redactInputValues(message); - - expect(redacted).not.toMatch(SENTINEL); - expect(redacted).toContain('[redacted]'); - }); - - it('열거형의 모르는 이름은 값으로 보고 가린다', () => { - const message = literalMessage('{ kind(v: L375665) }'); - expect(message).toContain('L375665'); + expect(message).toMatch(/37\.5665/); - expect(redactInputValues(message)).not.toContain('L375665'); - }); - - it.each([ - [ - '변수 경로와 스칼라 사유', - () => probe({ f: '37.5665' }), - 'Variable "$input" got invalid value [redacted] at "input.f"; Float cannot represent non numeric value: [redacted]', - ], - [ - '변수 모르는 필드', - () => at({ latitude: 37.5665, longitude: 1, foo: 1 }), - 'Variable "$input" got invalid value [redacted]; Field "[redacted]" is not defined by type "Loc".', - ], - [ - '리터럴 스칼라 사유', - () => literalMessage('{ float(v: "37.5665") }'), - 'Float cannot represent non numeric value: [redacted]', - ], - [ - '리터럴 형식 불일치', - () => literalMessage('{ at(input: 37.5665) }'), - 'Expected value of type "Loc!", found [redacted]', - ], - [ - '모르는 필드 이름과 서버가 정한 후보', - () => - 'Field "lat37.5665" is not defined by type "Loc". Did you mean "latitude"?', - 'Field "[redacted]" is not defined by type "Loc". Did you mean "latitude"?', - ], - ])('%s: 경로·형식 사유는 남긴다', (_label, make, expected) => { - expect(redactInputValues(make())).toBe(expected); - }); - - it.each([ - 'Cannot query field "x" on type "Query".', - 'Variable "$input" of required type "Loc!" was not provided.', - 'Unknown argument "radius" on field "Query.at".', - ])('값이 없는 문구는 그대로 둔다: %s', (message) => { - expect(redactInputValues(message)).toBe(message); - }); - - it('formatGraphqlError의 warn 로그에 좌표가 남지 않는다', () => { - const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); - const message = probe({ f: '126.978' }); - - formatGraphqlError( - { message, extensions: { code: 'BAD_USER_INPUT' } }, - new GraphQLError(message), + expect(redactVariableValue(message)).toBe( + 'Variable "$v" got invalid value [redacted]', ); - - expect(warn).toHaveBeenCalledTimes(1); - expect(String(warn.mock.calls[0][0])).not.toMatch(/126\.978/); - warn.mockRestore(); }); -}); - -// 변수 강제 변환 사유 허용 목록 — graphql-js 16의 사유 템플릿마다, 값·JSON 키에 공격 문자열을 심은 실제 문구로 전수 확인한다 -describe('redactInputValues — 변수 강제 변환 사유 허용 목록', () => { - const schema = buildSchema(` - input One @oneOf { a: Int b: Int } - input Loc { latitude: Float! longitude: Float! } - input Outer { loc: Loc points: [Loc!] } - enum Kind { HOME WORK } - scalar When - type Query { - at(v: Loc!): Boolean - outer(v: Outer): Boolean - one(v: One): Boolean - int(v: Int): Boolean - string(v: String): Boolean - bool(v: Boolean): Boolean - id(v: ID): Boolean - kind(v: Kind): Boolean - when(v: When): Boolean - } - `); - ( - schema.getType('When') as { parseValue: (value: unknown) => unknown } - ).parseValue = (value) => { - if (value === 'undef') return undefined; - throw new TypeError(`bad ${String(value)}`); - }; - - function message(type: string, field: string, value: unknown): string { - const [operation] = parse(`query Q($v: ${type}) { ${field}(v: $v) }`) - .definitions as OperationDefinitionNode[]; - const result = getVariableValues( - schema, - operation.variableDefinitions ?? [], - { v: value }, - ); - if (!result.errors) throw new Error('강제 변환이 성공했다'); - return result.errors[0].message; - } - - /** 값·키에 심는 공격 문자열 — 구분자(;)·따옴표·가짜 경로·가짜 사유를 섞는다. 모두 37.5665를 품는다. */ - const ATTACKS = [ - '37.5665', - 'a; 37.5665', - 'sneaky" is not defined by type "Loc". 37.5665', - 'z; Field "q" is not defined by type "Loc". 37.5665', - 'w" at "v.latitude"; Expected type "Loc". 37.5665', - 'p; Float cannot represent non numeric value: 37.5665', - ]; - const HEAD = 'Variable "$v" got invalid value [redacted]'; - - /** 공격 입력: 분류는 틀어질 수 있어도 출력은 허용된 모양뿐이고 좌표가 남지 않는다. */ - const ADVERSARIAL: [string, () => string][] = ATTACKS.flatMap( - (attack): [string, () => string][] => [ - [ - `모르는 필드 키 ${JSON.stringify(attack)}`, - () => message('Loc!', 'at', { latitude: 1, longitude: 1, [attack]: 1 }), - ], - [ - `중첩 경로 Float ${JSON.stringify(attack)}`, - () => - message('Outer', 'outer', { - loc: { latitude: attack, longitude: 1 }, - }), - ], - [ - `목록 경로 Float ${JSON.stringify(attack)}`, - () => - message('Outer', 'outer', { - points: [ - { latitude: 1, longitude: 1 }, - { latitude: attack, longitude: 1 }, - ], - }), - ], - [ - `객체 자리 문자열 ${JSON.stringify(attack)}`, - () => message('Loc!', 'at', attack), - ], - [`Int ${JSON.stringify(attack)}`, () => message('Int', 'int', attack)], - [ - `ID ${JSON.stringify(attack)}`, - () => message('ID', 'id', { k: attack }), - ], - [ - `열거형 ${JSON.stringify(attack)}`, - () => message('Kind', 'kind', attack), - ], - [ - `커스텀 스칼라 ${JSON.stringify(attack)}`, - () => message('When', 'when', attack), - ], - ], - ); - const NAME = '[A-Za-z_][0-9A-Za-z_]*'; - const DYM = ` Did you mean (?:the enum value )?"${NAME}"(?:(?:, |, or | or )"${NAME}")*\\?`; - const SAFE_OUTPUT = new RegExp( - `^Variable "\\$v" got invalid value \\[redacted\\](?: at "${NAME}(?:\\.${NAME}|\\[\\d+\\])*")?; (?:` + - [ - '\\[redacted\\]', - `Expected non-nullable type "[\\[\\]!0-9A-Za-z_]+" not to be null\\.`, - `Expected type "${NAME}" to be an object\\.`, - `Field "${NAME}" of required type "[\\[\\]!0-9A-Za-z_]+" was not provided\\.`, - `Field "\\[redacted\\]" is not defined by type "${NAME}"\\.(?:${DYM})?`, - `Exactly one key must be specified for OneOf type "${NAME}"\\.`, - `Field "${NAME}" must be non-null\\.`, - `Expected type "${NAME}"\\.(?: \\[redacted\\])?`, - '(?:Int|Float|String|Boolean|ID) cannot represent [a-z0-9 -]*value: \\[redacted\\]', - `Enum "${NAME}" cannot represent (?:non-string|non-enum) value: \\[redacted\\]`, - `Value "\\[redacted\\]" does not exist in "${NAME}" enum\\.(?:${DYM})?`, - ].join('|') + - ')$', - ); - - const PLAIN = '37.5665'; - const CASES: [string, () => string, string][] = [ - [ - `모르는 필드 키 `, - () => message('Loc!', 'at', { latitude: 1, longitude: 1, [PLAIN]: 1 }), - `${HEAD}; Field "[redacted]" is not defined by type "Loc".`, - ], - [ - `중첩 경로의 Float 값 `, - () => - message('Outer', 'outer', { - loc: { latitude: PLAIN, longitude: 1 }, - }), - `${HEAD} at "v.loc.latitude"; Float cannot represent non numeric value: [redacted]`, - ], - [ - `목록 경로의 Float 값 `, - () => - message('Outer', 'outer', { - points: [ - { latitude: 1, longitude: 1 }, - { latitude: PLAIN, longitude: 1 }, - ], - }), - `${HEAD} at "v.points[1].latitude"; Float cannot represent non numeric value: [redacted]`, - ], - [ - `객체 자리의 문자열 `, - () => message('Loc!', 'at', PLAIN), - `${HEAD}; Expected type "Loc" to be an object.`, - ], - [ - `Int 값 `, - () => message('Int', 'int', PLAIN), - `${HEAD}; Int cannot represent non-integer value: [redacted]`, - ], - [ - `ID 값 `, - () => message('ID', 'id', { k: PLAIN }), - `${HEAD}; ID cannot represent value: [redacted]`, - ], - [ - `열거형 값 `, - () => message('Kind', 'kind', PLAIN), - `${HEAD}; Value "[redacted]" does not exist in "Kind" enum.`, - ], - [ - `커스텀 스칼라 사유 `, - () => message('When', 'when', PLAIN), - `${HEAD}; Expected type "When". [redacted]`, - ], - [ - '필드 누락', - () => message('Loc!', 'at', { latitude: 37.5665 }), - `${HEAD}; Field "longitude" of required type "Float!" was not provided.`, - ], - [ - '목록 안의 null', - () => message('Outer', 'outer', { points: [null] }), - `${HEAD} at "v.points[0]"; Expected non-nullable type "Loc!" not to be null.`, - ], - [ - 'Int 32비트 초과', - () => message('Int', 'int', 3756650000000), - `${HEAD}; Int cannot represent non 32-bit signed integer value: [redacted]`, - ], - [ - 'String에 숫자', - () => message('String', 'string', 37.5665), - `${HEAD}; String cannot represent a non string value: [redacted]`, - ], - [ - 'Boolean에 숫자', - () => message('Boolean', 'bool', 37.5665), - `${HEAD}; Boolean cannot represent a non boolean value: [redacted]`, - ], - [ - '열거형에 숫자', - () => message('Kind', 'kind', 37.5665), - `${HEAD}; Enum "Kind" cannot represent non-string value: [redacted]`, - ], - [ - '열거형 Did you mean 후보는 남긴다', - () => message('Kind', 'kind', 'HOMEE37.5665'.slice(0, 5)), - `${HEAD}; Value "[redacted]" does not exist in "Kind" enum. Did you mean the enum value "HOME"?`, - ], - [ - '필드 Did you mean 후보는 남긴다', - () => - message('Loc!', 'at', { latitude: 1, longitude: 1, latitud: 37.5665 }), - `${HEAD}; Field "[redacted]" is not defined by type "Loc". Did you mean "latitude"?`, - ], - [ - '커스텀 스칼라 사유 없음', - () => message('When', 'when', 'undef'), - `${HEAD}; Expected type "When".`, - ], - [ - 'OneOf 키 개수', - () => message('One', 'one', { a: 37, b: 5665 }), - `${HEAD}; Exactly one key must be specified for OneOf type "One".`, - ], - [ - 'OneOf null', - () => message('One', 'one', { a: null }), - `${HEAD} at "v.a"; Field "a" must be non-null.`, - ], - [ - '목록에 없는 사유는 통째로 가린다', - () => 'Variable "$v" got invalid value 37.5665; Some new reason 37.5665', - `${HEAD}; [redacted]`, - ], - [ - '너무 긴 문구는 맞추지 않고 사유째 가린다', - () => - message('Loc!', 'at', { - latitude: 'x'.repeat(2_100), - longitude: 37.5665, - }), - `${HEAD}; [redacted]`, - ], - ]; - - it.each(CASES)('%s', (_label, make, expected) => { - const raw = make(); - const redacted = redactInputValues(raw); - - expect(redacted).toBe(expected); - expect(redacted).not.toMatch(/37\.?5665|sneaky/); - }); - - it.each(ADVERSARIAL)('공격: %s', (_label, make) => { - const raw = make(); - expect(raw).toMatch(/37\.?5665/); - const redacted = redactInputValues(raw); + it('변수 오류가 아닌 문구는 그대로 둔다', () => { + const message = 'Cannot query field "x" on type "Query".'; - expect(redacted).toMatch(SAFE_OUTPUT); - expect(redacted).not.toMatch(/37\.?5665|sneaky/); + expect(redactVariableValue(message)).toBe(message); }); }); diff --git a/src/global/graphql/format-graphql-error.ts b/src/global/graphql/format-graphql-error.ts index 7bcf2b00..6cde0730 100644 --- a/src/global/graphql/format-graphql-error.ts +++ b/src/global/graphql/format-graphql-error.ts @@ -22,131 +22,16 @@ const PROTOCOL_CODES = new Set([ ApolloServerErrorCode.PERSISTED_QUERY_NOT_SUPPORTED, ]); -// graphql-js 입력 오류 문구는 받은 값을 그대로 싣는다(예: 현재 위치 좌표) — 로그에는 서버가 정한 부분만 남긴다. -const NAME = '[A-Za-z_][0-9A-Za-z_]*'; -const TYPE_REF = '[\\[\\]!0-9A-Za-z_]+'; -const PATH = `${NAME}(?:\\.${NAME}|\\[\\d+\\])*`; -const DID_YOU_MEAN = ` Did you mean (?:the enum value )?"${NAME}"(?:(?:, |, or | or )"${NAME}")*\\?`; -/** 변수 오류는 끝에서부터 맞춘다 — 이보다 길면 맞추지 않고 사유째 가린다(정규식 역추적 상한). */ -const MAX_VARIABLE_ERROR_LENGTH = 2_000; +// graphql-js 변수 강제 변환 오류는 받은 값(예: 현재 위치 좌표)을 이스케이프 없이 싣고, 값·키·사유가 뒤섞여 안전하게 가를 수 없다. +// 로그에는 변수명만 남긴다. FE는 값을 변수로만 보내 리터럴·파서 오류에는 좌표가 실리지 않는다. +const VARIABLE_ERROR = + /^Variable "\$([A-Za-z_][0-9A-Za-z_]*)" got invalid value /; -type Groups = Record; - -/** - * 변수 강제 변환 오류(`Variable "$x" got invalid value <값>[ at "<경로>"]; <사유>`)의 사유 허용 목록 — graphql-js 16의 - * coerceInputValue·기본 스칼라·열거형이 만드는 문구 전부. 출력에는 캡처한 서버 이름(타입·필드·경로·후보)만 쓴다. - * 값·JSON 키처럼 클라이언트가 정하는 부분은 [\s\S]*로 건너뛰어 버린다. 목록에 없는 사유는 통째로 가린다. - * graphql-js는 값·키를 이스케이프하지 않아 공격 문자열은 분류를 틀어지게 할 수 있지만, 출력은 템플릿 상수·서버 이름 꼴 식별자· - * [redacted]뿐이다. 값 구간은 greedy — 서버가 마지막에 붙인 실제 경로·사유를 고른다. - */ -const VARIABLE_REASONS: readonly [string, (g: Groups) => string][] = [ - [ - `Expected non-nullable type "(?${TYPE_REF})" not to be null\\.`, - (g) => `Expected non-nullable type "${g.type}" not to be null.`, - ], - [ - `Expected type "(?${NAME})" to be an object\\.`, - (g) => `Expected type "${g.type}" to be an object.`, - ], - [ - `Field "(?${NAME})" of required type "(?${TYPE_REF})" was not provided\\.`, - (g) => `Field "${g.field}" of required type "${g.type}" was not provided.`, - ], - [ - `Field "[\\s\\S]*" is not defined by type "(?${NAME})"\\.(?${DID_YOU_MEAN})?`, - (g) => - `Field "[redacted]" is not defined by type "${g.type}".${g.dym ?? ''}`, - ], - [ - `Exactly one key must be specified for OneOf type "(?${NAME})"\\.`, - (g) => `Exactly one key must be specified for OneOf type "${g.type}".`, - ], - [ - `Field "(?${NAME})" must be non-null\\.`, - (g) => `Field "${g.field}" must be non-null.`, - ], - [`Expected type "(?${NAME})"\\.`, (g) => `Expected type "${g.type}".`], - [ - `Expected type "(?${NAME})"\\. [\\s\\S]*`, - (g) => `Expected type "${g.type}". [redacted]`, - ], - [ - `(?(?:Int|Float|String|Boolean|ID) cannot represent [a-z0-9 -]*value): [\\s\\S]*`, - (g) => `${g.scalar}: [redacted]`, - ], - [ - `Enum "(?${NAME})" cannot represent (?non-string|non-enum) value: [\\s\\S]*`, - (g) => `Enum "${g.type}" cannot represent ${g.kind} value: [redacted]`, - ], - [ - `Value "[\\s\\S]*" does not exist in "(?${NAME})" enum\\.(?${DID_YOU_MEAN})?`, - (g) => - `Value "[redacted]" does not exist in "${g.type}" enum.${g.dym ?? ''}`, - ], -]; - -const VARIABLE_ERROR_HEAD = `Variable "\\$(?${NAME})" got invalid value `; -const VARIABLE_ERROR = new RegExp(`^${VARIABLE_ERROR_HEAD}`); -// 경로가 있는 꼴을 먼저 전부 맞춘다 — greedy 값 구간이 경로까지 삼키지 않게 -const VARIABLE_REASON_PATTERNS = [` at "(?${PATH})"; `, '; '].flatMap( - (separator) => - VARIABLE_REASONS.map( - ([reason, render]) => - [ - new RegExp( - `^${VARIABLE_ERROR_HEAD}[\\s\\S]*${separator}(?:${reason})$`, - ), - render, - ] as const, - ), -); - -function redactVariableError(message: string): string | null { - const head = VARIABLE_ERROR.exec(message); - if (!head) { - return message.startsWith('Variable "$') && - message.includes(' got invalid value ') - ? '[redacted]' - : null; - } - const prefix = `Variable "$${head.groups?.variable}" got invalid value [redacted]`; - if (message.length <= MAX_VARIABLE_ERROR_LENGTH) { - for (const [pattern, render] of VARIABLE_REASON_PATTERNS) { - const groups = pattern.exec(message)?.groups; - if (!groups) continue; - const path = groups.path ? ` at "${groups.path}"` : ''; - return `${prefix}${path}; ${render(groups)}`; - } - } - return `${prefix}; [redacted]`; -} - -// 변수 오류 밖의 값 템플릿: 스칼라·열거형 사유, 리터럴 검증, 실행 인자, coerceInputValue 기본 문구, 파서 토큰 -const VALUE_REDACTIONS: readonly [RegExp, string][] = [ - [/(cannot represent[^:]*): [\s\S]*$/, '$1: [redacted]'], - [/(, found )[\s\S]*$/, '$1[redacted]'], - [/(has invalid value )[\s\S]*$/, '$1[redacted]'], - [/Value "[\s\S]*" does not exist in /, 'Value [redacted] does not exist in '], - [/^(Invalid value )[\s\S]*$/, '$1[redacted]'], - [ - new RegExp(`(Field ")[\\s\\S]*(" is not defined by type "${NAME}"\\.)`), - '$1[redacted]$2', - ], - [ - /(Unexpected (?:Name|Int|Float|String|BlockString)) "[\s\S]*"/, - '$1 [redacted]', - ], -]; - -export function redactInputValues(message: string): string { - return ( - redactVariableError(message) ?? - VALUE_REDACTIONS.reduce( - (redacted, [pattern, replacement]) => - redacted.replace(pattern, replacement), - message, - ) - ); +export function redactVariableValue(message: string): string { + const variable = VARIABLE_ERROR.exec(message); + return variable + ? `Variable "$${variable[1]}" got invalid value [redacted]` + : message; } /** @@ -160,7 +45,7 @@ export function formatGraphqlError( if (formatted.extensions?.classification) return formatted; if (PROTOCOL_CODES.has(formatted.extensions?.code)) return formatted; - const original = `${String(formatted.extensions?.code)}: ${redactInputValues(formatted.message)}`; + const original = `${String(formatted.extensions?.code)}: ${redactVariableValue(formatted.message)}`; const code = CLIENT_ERROR_CODES.has(formatted.extensions?.code) ? 'VALIDATION_FAILED' : 'INTERNAL_ERROR'; diff --git a/src/test/jest-host-lock.spec.ts b/src/test/jest-host-lock.spec.ts index 6c988064..d95d2452 100644 --- a/src/test/jest-host-lock.spec.ts +++ b/src/test/jest-host-lock.spec.ts @@ -1,14 +1,9 @@ import { type ChildProcess, spawn } from 'node:child_process'; -import { createServer, type Server, type Socket } from 'node:net'; +import { createServer } from 'node:net'; import { acquireHostLock, - decideAfterProbe, - type Occupant, - DEFAULT_JEST_HOST_LOCK_PORT, type HostLock, - hostLockPort, - LOCK_BANNER, shouldTakeHostLock, } from '@/test/jest-host-lock'; @@ -51,69 +46,9 @@ describe('shouldTakeHostLock', () => { }); }); -describe('decideAfterProbe', () => { - /** 확인 결과를 차례로 넣어 단계 목록을 얻는다. */ - function steps(occupants: Occupant[]): string[] { - let streak = 0; - return occupants.map((occupant) => { - const decision = decideAfterProbe(occupant, streak); - streak = decision.streak; - return decision.step; - }); - } - - it.each([ - [['jest'], ['wait']], - [['gone'], ['retry']], - [['other'], ['fail']], - [ - ['ambiguous', 'ambiguous'], - ['retry', 'retry'], - ], - [ - ['ambiguous', 'ambiguous', 'ambiguous'], - ['retry', 'retry', 'fail'], - ], - // 반증: 모호가 아닌 결과(풀림·jest)가 끼면 연속이 끊긴다 - [ - ['ambiguous', 'gone', 'ambiguous', 'gone', 'ambiguous'], - ['retry', 'retry', 'retry', 'retry', 'retry'], - ], - [ - ['ambiguous', 'ambiguous', 'gone', 'ambiguous', 'ambiguous'], - ['retry', 'retry', 'retry', 'retry', 'retry'], - ], - [ - ['ambiguous', 'ambiguous', 'jest', 'ambiguous', 'ambiguous'], - ['retry', 'retry', 'wait', 'retry', 'retry'], - ], - ] as [Occupant[], string[]][])('%j → %j', (occupants, expected) => { - expect(steps(occupants)).toEqual(expected); - }); - - it('모호하면 잠깐 뒤, 풀렸으면 바로 다시 잡는다', () => { - expect(decideAfterProbe('ambiguous', 0).delayMs).toBeGreaterThan(0); - expect(decideAfterProbe('gone', 0).delayMs).toBe(0); - }); -}); - -describe('hostLockPort', () => { - it.each([ - [{}, DEFAULT_JEST_HOST_LOCK_PORT], - [{ JEST_HOST_LOCK_PORT: '50123' }, 50123], - [{ JEST_HOST_LOCK_PORT: '' }, DEFAULT_JEST_HOST_LOCK_PORT], - [{ JEST_HOST_LOCK_PORT: 'abc' }, DEFAULT_JEST_HOST_LOCK_PORT], - [{ JEST_HOST_LOCK_PORT: '-1' }, DEFAULT_JEST_HOST_LOCK_PORT], - ])('env %j → %i', (env, expected) => { - expect(hostLockPort(env)).toBe(expected); - }); -}); - describe('acquireHostLock', () => { let port: number; const held: HostLock[] = []; - /** 테스트가 띄운 다른 서버 — 단언이 실패해도 닫아야 jest가 끝난다. */ - const servers: Server[] = []; const children: ChildProcess[] = []; const log = jest.fn(); @@ -121,17 +56,10 @@ describe('acquireHostLock', () => { port = await freePort(); log.mockClear(); }); + // 단언이 실패해도 자식이 남아 jest가 끝나지 않는 일이 없게 한다 afterEach(async () => { children.splice(0).forEach((child) => child.kill('SIGKILL')); await Promise.all(held.splice(0).map((lock) => lock.release())); - await Promise.all( - servers - .splice(0) - .map( - (server) => - new Promise((resolve) => server.close(() => resolve())), - ), - ); }); async function take(): Promise { @@ -176,53 +104,10 @@ describe('acquireHostLock', () => { ); }); - it.each([ - ['아무것도 보내지 않는 서버', (socket: Socket) => void socket], - [ - '다른 내용을 보내는 서버', - (socket: Socket) => socket.end('HTTP/1.1 200 OK\r\n\r\n'), - ], - ['바로 끊는 서버', (socket: Socket) => socket.end()], - ])( - '반증: jest가 아닌 %s가 포트를 쓰면 기다리지 않고 오류로 멈춘다', - async (_label, onConnection) => { - const other = createServer(onConnection); - servers.push(other); - await new Promise((resolve) => - other.listen(port, '127.0.0.1', resolve), - ); - - await expect( - settledWithin(acquireHostLock({ port, pollMs: 20, log }), 3_000), - ).rejects.toThrow(/jest가 아닌 프로세스가 쓰고 있다/); - expect(log).not.toHaveBeenCalled(); - }, - ); - - it('락이 풀리는 순간의 끊김(RST·빈 응답)은 다른 프로세스로 오판하지 않고 재시도해 잡는다', async () => { - // 쥔 쪽이 닫히는 중 — 첫 접속은 RST, 둘째는 빈 응답으로 끊고 포트를 놓는다 - let connections = 0; - const releasing = createServer((socket) => { - connections += 1; - if (connections === 1) socket.resetAndDestroy(); - else { - socket.end(); - releasing.close(); - } - }); - servers.push(releasing); - await new Promise((resolve) => - releasing.listen(port, '127.0.0.1', resolve), - ); - - await expect(settledWithin(take(), 3_000)).resolves.not.toBe('pending'); - expect(connections).toBe(2); - }); - it('쥐고 있던 프로세스가 강제 종료되면(SIGKILL) OS가 포트를 풀어 바로 잡는다', async () => { const child = spawn(process.execPath, [ '-e', - `require('node:net').createServer((s) => s.end('${LOCK_BANNER} 1\\n')).listen({ port: ${port}, host: '127.0.0.1', exclusive: true }, () => console.log('ready')); setInterval(() => {}, 1000);`, + `require('node:net').createServer().listen({ port: ${port}, host: '127.0.0.1', exclusive: true }, () => console.log('ready')); setInterval(() => {}, 1000);`, ]); children.push(child); await new Promise((resolve) => diff --git a/src/test/jest-host-lock.ts b/src/test/jest-host-lock.ts index 3ca685c6..34f3442e 100644 --- a/src/test/jest-host-lock.ts +++ b/src/test/jest-host-lock.ts @@ -1,17 +1,11 @@ -import { connect, createServer, type Server } from 'node:net'; +import { createServer, type Server } from 'node:net'; /** * 같은 호스트의 jest 실행을 한 번에 하나로 묶는다 — testcontainers가 운영과 같은 VM을 써서 동시 실행이 부하·간헐 실패를 낳는다(guide §9). - * 락은 127.0.0.1의 포트를 여는 것이다. 프로세스가 어떻게 끝나든(Ctrl+C·SIGKILL) OS가 포트를 풀어 남는 락이 없다. - * 쥔 쪽은 접속에 식별 문자열을 돌려준다 — 다른 프로그램이 포트를 쓰고 있으면 jest 실행으로 오인해 끝없이 기다리지 않는다. + * 락은 127.0.0.1의 고정 포트를 여는 것이다. 프로세스가 어떻게 끝나든(Ctrl+C·SIGKILL) OS가 포트를 풀어 남는 락이 없다. */ -export const DEFAULT_JEST_HOST_LOCK_PORT = 47391; -export const LOCK_BANNER = 'caquick-be-jest-lock'; +export const JEST_HOST_LOCK_PORT = 47391; const POLL_MS = 2_000; -const PROBE_TIMEOUT_MS = 1_000; -/** 락이 풀리는 순간 접속하면 RST나 빈 응답을 받는다 — 이만큼 연달아 모호해야 jest가 아닌 점유로 본다. */ -const AMBIGUOUS_LIMIT = 3; -const AMBIGUOUS_RETRY_MS = 100; export interface HostLock { release(): Promise; @@ -32,18 +26,9 @@ export function shouldTakeHostLock( return !argv.some((arg) => /^--watch(All)?(=true)?$/.test(arg)); } -export function hostLockPort(env: NodeJS.ProcessEnv = process.env): number { - const fromEnv = Number(env.JEST_HOST_LOCK_PORT); - return Number.isInteger(fromEnv) && fromEnv > 0 - ? fromEnv - : DEFAULT_JEST_HOST_LOCK_PORT; -} - function listen(port: number): Promise { return new Promise((resolve, reject) => { - const server = createServer((socket) => - socket.end(`${LOCK_BANNER} ${process.pid}\n`), - ); + const server = createServer(); server.once('error', (error: NodeJS.ErrnoException) => { if (error.code === 'EADDRINUSE') resolve(null); else reject(error); @@ -56,69 +41,13 @@ function listen(port: number): Promise { }); } -export type Occupant = 'jest' | 'other' | 'gone' | 'ambiguous'; - -export interface ProbeDecision { - /** wait: 다른 jest가 쥐고 있다 · retry: 바로(또는 잠깐 뒤) 다시 잡아 본다 · fail: jest가 아닌 점유 */ - step: 'wait' | 'retry' | 'fail'; - delayMs: number; - /** 연속 모호 횟수. 모호가 아닌 결과는 연속을 끊는다. */ - streak: number; -} - -export function decideAfterProbe( - occupant: Occupant, - ambiguousStreak: number, -): ProbeDecision { - if (occupant === 'ambiguous') { - const streak = ambiguousStreak + 1; - return streak >= AMBIGUOUS_LIMIT - ? { step: 'fail', delayMs: 0, streak } - : { step: 'retry', delayMs: AMBIGUOUS_RETRY_MS, streak }; - } - if (occupant === 'other') return { step: 'fail', delayMs: 0, streak: 0 }; - if (occupant === 'gone') return { step: 'retry', delayMs: 0, streak: 0 }; - return { step: 'wait', delayMs: 0, streak: 0 }; -} - -/** 포트를 쥔 쪽이 이 락인지 묻는다. 접속이 거절되면 그새 풀린 것이다. */ -function probe(port: number): Promise { - return new Promise((resolve) => { - const socket = connect({ port, host: '127.0.0.1' }); - let data = ''; - let settled = false; - const done = (occupant: Occupant) => { - if (settled) return; - settled = true; - socket.destroy(); - resolve(occupant); - }; - socket.setTimeout(PROBE_TIMEOUT_MS, () => done('other')); - socket.on('data', (chunk: Buffer) => { - data += chunk.toString(); - if (data.length >= LOCK_BANNER.length) { - done(data.startsWith(LOCK_BANNER) ? 'jest' : 'other'); - } - }); - socket.on('end', () => { - if (data.startsWith(LOCK_BANNER)) done('jest'); - else done(data === '' ? 'ambiguous' : 'other'); - }); - socket.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'ECONNREFUSED') done('gone'); - else done(error.code === 'ECONNRESET' ? 'ambiguous' : 'other'); - }); - }); -} - export async function acquireHostLock( options: HostLockOptions = {}, ): Promise { - const port = options.port ?? hostLockPort(); + const port = options.port ?? JEST_HOST_LOCK_PORT; const pollMs = options.pollMs ?? POLL_MS; const log = options.log ?? ((message: string) => console.log(message)); let waited = false; - let ambiguous = 0; for (;;) { const server = await listen(port); if (server) { @@ -127,17 +56,6 @@ export async function acquireHostLock( new Promise((resolve) => server.close(() => resolve())), }; } - const decision = decideAfterProbe(await probe(port), ambiguous); - ambiguous = decision.streak; - if (decision.step === 'fail') { - throw new Error( - `[test] 127.0.0.1:${port}를 jest가 아닌 프로세스가 쓰고 있다 — JEST_HOST_LOCK_PORT로 다른 포트를 지정한다`, - ); - } - if (decision.step === 'retry') { - await new Promise((resolve) => setTimeout(resolve, decision.delayMs)); - continue; - } if (!waited) { log( `[test] 다른 jest 실행이 끝나기를 기다린다(호스트 락 127.0.0.1:${port})`,