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/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! """ 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 정책 위반/); + }); +});