From cc03ba8afa16b28b2d178395fad87e0db628aace Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 8 Oct 2026 10:16:15 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EB=94=94=EC=97=90=20=EC=98=81=EB=AC=B8=20=EB=8C=80=EB=AC=B8?= =?UTF-8?q?=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! """