From cc03ba8afa16b28b2d178395fad87e0db628aace Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 8 Oct 2026 10:16:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EB=94=94=EC=97=90=20=EC=98=81=EB=AC=B8=20=EB=8C=80?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 페이지의 관리자·판매자 계정 생성에서 username 규칙에 영문 대문자를 더함. - USERNAME_PATTERN: [a-z0-9._-] → [A-Za-z0-9._-] - 입력한 대소문자 그대로 저장·표시, 로그인은 대소문자 무관 - 대소문자만 다른 username은 USERNAME_TAKEN — account_credential.username 정렬이 utf8mb4_unicode_ci라 사전 조회·unique 인덱스 모두 같은 값으로 봄. 마이그레이션 없음 - SDL 설명 2곳(AdminCreateAdminInput·AdminCreateSellerInput) 갱신 테스트 - input spec 2개: 대문자 거절 케이스 → 허용 케이스 - 관리자·판매자 생성: 대소문자만 다른 username 충돌 2건, 대문자 그대로 저장 1건 - AccountCredentialRepository.findCredentialByUsername: 대소문자 무관 조회 3건 --- src/features/auth/auth-admin-account.graphql | 2 +- .../auth/constants/auth-admin.constants.ts | 4 ++-- .../dto/inputs/admin-create-admin.input.spec.ts | 7 ++++++- .../account-credential.repository.spec.ts | 12 ++++++++++++ .../services/auth-admin-account.service.spec.ts | 17 +++++++++++++++++ .../inputs/admin-create-seller.input.spec.ts | 8 +++++++- .../services/store-admin-seller.service.spec.ts | 15 +++++++++++++++ src/features/store/store-admin-seller.graphql | 2 +- 8 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/features/auth/auth-admin-account.graphql b/src/features/auth/auth-admin-account.graphql index 91ef591c..dd09a877 100644 --- a/src/features/auth/auth-admin-account.graphql +++ b/src/features/auth/auth-admin-account.graphql @@ -85,7 +85,7 @@ type AdminAccountConnection { """ input AdminCreateAdminInput { """ - 로그인 username. 4~80자, 소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면 BAD_USER_INPUT. + 로그인 username. 4~80자, 영문 대소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면(대소문자만 달라도) BAD_USER_INPUT. """ username: String! """ diff --git a/src/features/auth/constants/auth-admin.constants.ts b/src/features/auth/constants/auth-admin.constants.ts index 21c36ed6..e31a8091 100644 --- a/src/features/auth/constants/auth-admin.constants.ts +++ b/src/features/auth/constants/auth-admin.constants.ts @@ -2,8 +2,8 @@ export const MIN_USERNAME_LENGTH = 4; export const MAX_USERNAME_LENGTH = 80; -/** 정책: 소문자·숫자·`.`·`_`·`-`만. 대소문자 혼용 username 충돌을 원천 차단한다. */ -export const USERNAME_PATTERN = /^[a-z0-9._-]+$/; +/** 정책: 영문 대소문자·숫자·`.`·`_`·`-`만. 대소문자만 다른 username은 컬럼 정렬(ci)이 같은 값으로 봐 중복으로 막는다. */ +export const USERNAME_PATTERN = /^[A-Za-z0-9._-]+$/; export const MAX_EMAIL_LENGTH = 320; export const MAX_ACCOUNT_NAME_LENGTH = 100; diff --git a/src/features/auth/dto/inputs/admin-create-admin.input.spec.ts b/src/features/auth/dto/inputs/admin-create-admin.input.spec.ts index c6074f9f..0ab692e8 100644 --- a/src/features/auth/dto/inputs/admin-create-admin.input.spec.ts +++ b/src/features/auth/dto/inputs/admin-create-admin.input.spec.ts @@ -19,10 +19,15 @@ describe('AdminCreateAdminInput', () => { ).toHaveLength(0); }); + it('username에 영문 대문자를 허용한다', async () => { + expect( + await validate(build({ ...valid, username: 'Ops.Admin_1' })), + ).toHaveLength(0); + }); + it.each([ ['길이 4 미만', 'abc'], ['길이 80 초과', 'a'.repeat(81)], - ['대문자 포함', 'Admin1'], ['공백 포함', 'ad min'], ['허용 외 문자(@)', 'ad@min'], ['한글', '관리자계정'], diff --git a/src/features/auth/repositories/account-credential.repository.spec.ts b/src/features/auth/repositories/account-credential.repository.spec.ts index 4f42f0ef..ab0994b9 100644 --- a/src/features/auth/repositories/account-credential.repository.spec.ts +++ b/src/features/auth/repositories/account-credential.repository.spec.ts @@ -77,6 +77,18 @@ describe('AccountCredentialRepository (real DB)', () => { const found = await repo.findCredentialByUsername('nonexistent'); expect(found).toBeNull(); }); + + // 로그인은 대소문자를 구분하지 않는다 — username 컬럼 정렬(ci)이 보장하는 계약 + it.each(['ops.admin', 'OPS.ADMIN', 'Ops.Admin'])( + '대소문자가 달라도 같은 자격증명을 찾는다: %s', + async (input) => { + await createAccountCredential(prisma, { username: 'Ops.Admin' }); + + const found = await repo.findCredentialByUsername(input); + + expect(found!.username).toBe('Ops.Admin'); + }, + ); }); describe('findCredentialByAccountId', () => { diff --git a/src/features/auth/services/auth-admin-account.service.spec.ts b/src/features/auth/services/auth-admin-account.service.spec.ts index 4a0a868b..86ccc215 100644 --- a/src/features/auth/services/auth-admin-account.service.spec.ts +++ b/src/features/auth/services/auth-admin-account.service.spec.ts @@ -270,6 +270,23 @@ describe('AdminAccountService (real DB)', () => { ).rejects.toThrowDomain(400); }); + it('대문자 username은 입력 그대로 저장하고, 대소문자만 다른 username은 충돌한다', async () => { + const actor = await makeAdmin(); + + const created = await service.adminCreateAdmin(actor, { + ...validInput, + username: 'Ops.Admin', + }); + expect(created.username).toBe('Ops.Admin'); + + await expect( + service.adminCreateAdmin(actor, { + ...validInput, + username: 'ops.admin', + }), + ).rejects.toThrowDomain('USERNAME_TAKEN'); + }); + it('판매자가 쓰는 username도 충돌한다(자격증명 테이블 공용)', async () => { const actor = await makeAdmin(); await createAccountCredential(prisma, { diff --git a/src/features/store/dto/inputs/admin-create-seller.input.spec.ts b/src/features/store/dto/inputs/admin-create-seller.input.spec.ts index b0f9c0d6..2ba89754 100644 --- a/src/features/store/dto/inputs/admin-create-seller.input.spec.ts +++ b/src/features/store/dto/inputs/admin-create-seller.input.spec.ts @@ -27,6 +27,12 @@ describe('AdminCreateSellerInput', () => { expect(await validate(build(valid))).toHaveLength(0); }); + it('username에 영문 대문자를 허용한다', async () => { + expect( + await validate(build({ ...valid, username: 'Shop.Owner' })), + ).toHaveLength(0); + }); + it('store 누락 거절', async () => { const { store: _store, ...withoutStore } = valid; const errors = await validate(build(withoutStore)); @@ -69,7 +75,7 @@ describe('AdminCreateSellerInput', () => { }); it.each([ - ['username 대문자', { username: 'Shop' }], + ['username 허용 외 문자', { username: 'shop@owner' }], ['email 형식', { email: 'nope' }], ['businessName 누락', { businessName: undefined }], ])('%s 거절', async (_label, overrides) => { diff --git a/src/features/store/services/store-admin-seller.service.spec.ts b/src/features/store/services/store-admin-seller.service.spec.ts index 8942deea..cb65e9c5 100644 --- a/src/features/store/services/store-admin-seller.service.spec.ts +++ b/src/features/store/services/store-admin-seller.service.spec.ts @@ -360,6 +360,21 @@ describe('AdminSellerService (real DB)', () => { ).rejects.toThrowDomain(400); }); + it('대소문자만 다른 username도 충돌한다', async () => { + const actor = await admin(); + await createAccountCredential(prisma, { + account_type: 'SELLER', + username: 'Cake.Shop', + }); + + await expect( + service.adminCreateSeller(actor, { + ...validInput, + username: 'cake.shop', + }), + ).rejects.toThrowDomain('USERNAME_TAKEN'); + }); + it('감사 기록이 실패하면 계정·매장도 롤백된다(같은 트랜잭션)', async () => { const actor = await admin(); const auditLogs = service['auditLogs']; diff --git a/src/features/store/store-admin-seller.graphql b/src/features/store/store-admin-seller.graphql index d91699c2..f2de9291 100644 --- a/src/features/store/store-admin-seller.graphql +++ b/src/features/store/store-admin-seller.graphql @@ -158,7 +158,7 @@ input AdminSellerListInput { """ input AdminCreateSellerInput { """ - 로그인 username. 4~80자, 소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면 BAD_USER_INPUT. + 로그인 username. 4~80자, 영문 대소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면(대소문자만 달라도) BAD_USER_INPUT. """ username: String! """ From fbac569e785c8c151a3ff78ca0f1c41ca2682540 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 8 Oct 2026 10:30:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=8B=9C=EB=93=9C=20username=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=83=9D=EC=84=B1=20=EC=A0=95?= =?UTF-8?q?=EC=B1=85=EA=B3=BC=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 릴리즈 #527 Codex 리뷰 반영. #526에서 생성 정책에 대문자를 허용했는데 prisma/seed/credential-policy.ts는 소문자 전용 정규식을 따로 들고 있어 ADMIN_SEED_USERNAME=Ops.Admin 같은 값이 시드에서 거절됨. - 시드 검증이 USERNAME_PATTERN·MIN/MAX_USERNAME_LENGTH를 직접 쓰도록 변경(정책 사본 제거) - 위반 메시지: 소문자 → 영문 대소문자, 값은 계속 싣지 않음 테스트 - src/test/seed-credential-policy.spec.ts: username 10건을 시드 검증과 AdminCreateAdminInput이 같은 판정을 내리는지 표로 확인, 메시지에 값 미포함·password 위반 메시지 각 1건 - 반증: 수정 전 시드 정책으로 되돌리면 대문자 케이스 2건 실패 --- prisma/seed/credential-policy.ts | 18 ++++++-- src/test/seed-credential-policy.spec.ts | 61 +++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 src/test/seed-credential-policy.spec.ts diff --git a/prisma/seed/credential-policy.ts b/prisma/seed/credential-policy.ts index b5f14679..1597bd6b 100644 --- a/prisma/seed/credential-policy.ts +++ b/prisma/seed/credential-policy.ts @@ -3,16 +3,26 @@ * 통과 못 하는 값으로 만들어 두면 시드는 성공하는데 로그인은 ValidationPipe에서 전부 거절된다. */ import { IsStrongPasswordConstraint } from '@/common/validators/strong-password.validator'; - -const USERNAME_PATTERN = /^[a-z0-9._-]{4,80}$/; +import { + MAX_USERNAME_LENGTH, + MIN_USERNAME_LENGTH, + USERNAME_PATTERN, +} from '@/features/auth/constants/auth-admin.constants'; export function assertSeedCredential(args: { username: string; password: string; }): void { - if (!USERNAME_PATTERN.test(args.username)) { + const { username } = args; + if ( + username.length < MIN_USERNAME_LENGTH || + username.length > MAX_USERNAME_LENGTH || + !USERNAME_PATTERN.test(username) + ) { // 값은 메시지에 싣지 않는다 — env에서 온 자격증명이 오류 로그로 새는 경로(CodeQL clear-text-logging) - throw new Error('시드 username 정책 위반: 4~80자, 소문자·숫자·._- 만 허용'); + throw new Error( + `시드 username 정책 위반: ${MIN_USERNAME_LENGTH}~${MAX_USERNAME_LENGTH}자, 영문 대소문자·숫자·._- 만 허용`, + ); } if (!new IsStrongPasswordConstraint().validate(args.password)) { throw new Error( diff --git a/src/test/seed-credential-policy.spec.ts b/src/test/seed-credential-policy.spec.ts new file mode 100644 index 00000000..e6428f12 --- /dev/null +++ b/src/test/seed-credential-policy.spec.ts @@ -0,0 +1,61 @@ +import 'reflect-metadata'; + +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { assertSeedCredential } from '../../prisma/seed/credential-policy'; + +import { AdminCreateAdminInput } from '@/features/auth/dto/inputs/admin-create-admin.input'; + +const PASSWORD = 'Strong!Pass1'; + +function seedAccepts(username: string): boolean { + try { + assertSeedCredential({ username, password: PASSWORD }); + return true; + } catch { + return false; + } +} + +async function dtoAccepts(username: string): Promise { + const input = plainToInstance(AdminCreateAdminInput, { + username, + password: PASSWORD, + }); + return (await validate(input)).length === 0; +} + +// 시드가 만든 계정이 관리자 생성 경로와 같은 username 정책을 따르는지 — 두 판정이 어긋나면 안 된다 +describe('시드 자격증명 username 정책', () => { + it.each([ + ['소문자', 'ops.admin', true], + ['대문자 혼용', 'Ops.Admin_1', true], + ['전부 대문자', 'OPS-ADMIN', true], + ['4자', 'abcd', true], + ['80자', 'a'.repeat(80), true], + ['3자', 'abc', false], + ['81자', 'a'.repeat(81), false], + ['공백', 'ops admin', false], + ['허용 외 문자(@)', 'ops@admin', false], + ['한글', '관리자계정', false], + ])('%s: 생성 DTO와 같은 판정(%s → %s)', async (_label, username, ok) => { + expect(seedAccepts(username)).toBe(ok); + expect(await dtoAccepts(username)).toBe(ok); + }); + + it('위반 메시지에 username 값을 싣지 않는다', () => { + expect(() => + assertSeedCredential({ username: 'bad@name', password: PASSWORD }), + ).toThrow(/^시드 username 정책 위반: 4~80자/); + expect(() => + assertSeedCredential({ username: 'bad@name', password: PASSWORD }), + ).not.toThrow(/bad@name/); + }); + + it('password 정책 위반은 별도 메시지로 거절한다', () => { + expect(() => + assertSeedCredential({ username: 'ops.admin', password: 'weak' }), + ).toThrow(/^시드 password 정책 위반/); + }); +});