Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions prisma/seed/credential-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/features/auth/auth-admin-account.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ type AdminAccountConnection {
"""
input AdminCreateAdminInput {
"""
로그인 username. 4~80자, 소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면 BAD_USER_INPUT.
로그인 username. 4~80자, 영문 대소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면(대소문자만 달라도) BAD_USER_INPUT.
"""
username: String!
"""
Expand Down
4 changes: 2 additions & 2 deletions src/features/auth/constants/auth-admin.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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._-]+$/;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Synchronize the seed username validator

When ADMIN_SEED_USERNAME contains a newly valid mixed-case value such as Ops.Admin, seedAdmins still passes it to prisma/seed/credential-policy.ts, whose lowercase-only regex throws before the account is inserted. This leaves the documented optional admin-seeding path unable to use the username policy introduced here; update that validator and its error message, preferably by reusing the shared policy.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영: 시드 username 검증이 생성 정책(USERNAME_PATTERN·길이 상수)을 직접 쓰도록 바꿈 — fix/release-review-seed-username → develop PR로 반영 후 이 릴리즈에 포함.


export const MAX_EMAIL_LENGTH = 320;
export const MAX_ACCOUNT_NAME_LENGTH = 100;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
['한글', '관리자계정'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 17 additions & 0 deletions src/features/auth/services/auth-admin-account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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) => {
Expand Down
15 changes: 15 additions & 0 deletions src/features/store/services/store-admin-seller.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down
2 changes: 1 addition & 1 deletion src/features/store/store-admin-seller.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ input AdminSellerListInput {
"""
input AdminCreateSellerInput {
"""
로그인 username. 4~80자, 소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면 BAD_USER_INPUT.
로그인 username. 4~80자, 영문 대소문자·숫자·`.`·`_`·`-`만 허용. 이미 쓰이고 있으면(대소문자만 달라도) BAD_USER_INPUT.
"""
username: String!
"""
Expand Down
61 changes: 61 additions & 0 deletions src/test/seed-credential-policy.spec.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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 정책 위반/);
});
});
Loading