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! """