diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index f760373..27ab729 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -85,6 +85,15 @@ - 일반 본문 기본값은 `Body Small` 이상을 우선합니다. - Figma token명이 바뀌면 CSS utility class 변경 전에 이 문서를 먼저 갱신합니다. + +### Fixed visual contracts for issue #14 + +2026-06-23 기준, `LibraryBookCard`와 `PortfolioResumeSheet`는 범용 variant 시스템이 아니라 Figma 예시 화면을 코드로 고정한 초기 shared UI입니다. + +- `LibraryBookCard`는 도서관/레주메북 진입용 160×220 책 커버 카드입니다. 현재 커버는 `public/assets/library-book-cover.svg` 고정 asset을 사용하며, 이 파일명/경로는 issue #14 초기 카드 계약입니다. 카드 종류가 늘어나면 `cover` 또는 `variant` 계약을 별도 결정합니다. +- `PortfolioResumeSheet`는 학생 포트폴리오 첫 자기소개/이력서 시트입니다. 사용자 지정 기준에 따라 이름은 `Title Small`, 전공 상태는 `Body Tiny`, 학번/학과/email 메타는 `Body very Tiny`를 사용합니다. `resume small` / `resume major` token은 PDF/고밀도 출력 기준이 확정될 때 별도 적용 여부를 결정합니다. +- 공개 route와 `ComponentPreview`에서 쓰는 예시 데이터는 실제 API mock이 아니라 화면 확인용 fixture이므로 `src/shared/fixtures/examples/publicExamples.ts`에 둡니다. + ### Spacing TODO: spacing scale을 정리한다. diff --git a/package.json b/package.json index 6c20818..fea0c4d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "next build", "lint": "eslint .", "start": "next start", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:unit": "rm -rf /tmp/repo-v2-unit && tsc --ignoreConfig --types node --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir /tmp/repo-v2-unit --rootDir . tests/unit/internalHref.test.ts src/shared/lib/internalHref.ts && node --test /tmp/repo-v2-unit/tests/unit/*.test.js" }, "dependencies": { "next": "^16.2.7", diff --git a/public/assets/library-book-cover.svg b/public/assets/library-book-cover.svg new file mode 100644 index 0000000..6333eb8 --- /dev/null +++ b/public/assets/library-book-cover.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/(public)/[portfolioSlug]/page.tsx b/src/app/(public)/[portfolioSlug]/page.tsx index ea264be..d78d005 100644 --- a/src/app/(public)/[portfolioSlug]/page.tsx +++ b/src/app/(public)/[portfolioSlug]/page.tsx @@ -2,16 +2,14 @@ import type { Metadata } from 'next' import { notFound } from 'next/navigation' import { isReservedPortfolioSlug, normalizePortfolioSlug } from '@/shared/lib/portfolioSlug' +import { getSamplePortfolioResume, samplePortfolioResume } from '@/shared/fixtures/examples/publicExamples' +import { PortfolioResumeSheet } from '@/shared/ui' import styles from '../publicRoute.module.css' -const mockPortfolio = { - name: '오혜민', - headline: '문제를 구조화하고 끝까지 구현하는 소프트웨어 메이커', - school: '대덕소프트마이스터고등학교', - skills: ['Product Thinking', 'Frontend', 'AI Native Workflow'], +function getPortfolioDisplayName(decodedSlug: string) { + return decodedSlug === '오혜민' ? samplePortfolioResume.name : decodedSlug } - type PortfolioPageProps = { params: Promise<{ portfolioSlug: string @@ -27,9 +25,11 @@ export async function generateMetadata({ params }: PortfolioPageProps): Promise< const decodedSlug = normalizePortfolioSlug(portfolioSlug) + const displayName = getPortfolioDisplayName(decodedSlug) + return { - title: `${decodedSlug} 포트폴리오`, - description: `${decodedSlug} 학생의 공개 포트폴리오`, + title: `${displayName} 포트폴리오`, + description: `${displayName} 학생의 공개 포트폴리오`, } } @@ -41,32 +41,13 @@ export default async function PortfolioPage({ params }: PortfolioPageProps) { } const decodedSlug = normalizePortfolioSlug(portfolioSlug) - const displayName = decodedSlug === mockPortfolio.name ? mockPortfolio.name : decodedSlug + const displayName = getPortfolioDisplayName(decodedSlug) + const portfolio = getSamplePortfolioResume(displayName) return (
-
-
- Public Portfolio -

- {displayName} -

-

{mockPortfolio.headline}

-
-
-

School

-

{mockPortfolio.school}

-
-
-

Slug

-

/{decodedSlug}

-
-
-

Skills

-

{mockPortfolio.skills.join(' · ')}

-
-
-
+
+
) diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 67cb9c2..6ae6edb 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -1,5 +1,8 @@ import Link from 'next/link' +import { samplePortfolioHref, sampleResumeBookHref } from '@/shared/fixtures/examples/publicExamples' +import { LibraryBookCard } from '@/shared/ui' + import styles from './publicRoute.module.css' export default function HomePage() { @@ -15,14 +18,31 @@ export default function HomePage() { Repo는 학생의 이력서와 포트폴리오를 정리하고, 공개 URL을 통해 성장 기록을 보여주기 위한 서비스입니다.

- + 공개 포트폴리오 예시 - + 레주메북 예시
+ +
+
+ Examples +

+ 바로 확인하는 포트폴리오와 레주메북 예시 +

+

+ 실제 데이터 연동 전에도 공개 포트폴리오와 레주메북의 진입 카드를 같은 컴포넌트 패턴으로 확인합니다. +

+
+ +
+ + +
+
) diff --git a/src/app/(public)/publicRoute.module.css b/src/app/(public)/publicRoute.module.css index 3cab339..59e4c35 100644 --- a/src/app/(public)/publicRoute.module.css +++ b/src/app/(public)/publicRoute.module.css @@ -68,32 +68,85 @@ background: var(--brand); } -.infoGrid { +.exampleSection { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + gap: 48px; + align-items: center; + margin-top: 24px; + padding: 40px; + border: 1px solid var(--border); + border-radius: 32px; + background: var(--surface); +} + +.exampleSectionHeader { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; + max-width: 560px; +} + +.exampleTitle { + margin: 0; + font-size: var(--repo-font-title-medium-size); + font-weight: var(--repo-font-title-medium-weight); + line-height: var(--repo-font-title-medium-line-height); + letter-spacing: -0.04em; +} + +.exampleDescription { + margin: 0; + color: var(--text-secondary); + font-size: var(--repo-font-body-medium-size); + font-weight: var(--repo-font-body-medium-weight); + line-height: var(--repo-font-body-medium-line-height); } -.infoCard { - min-height: 148px; +.libraryBookGrid { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: 20px; padding: 24px; - border: 1px solid var(--border); - border-radius: 24px; - background: var(--surface); + border-radius: 28px; + background: var(--repo-gray-100); } -.infoCard h2, -.infoCard h3 { - margin: 0 0 10px; - font-size: var(--repo-font-title-tiny-size); - font-weight: var(--repo-font-title-tiny-weight); - line-height: var(--repo-font-title-tiny-line-height); +@media (max-width: 820px) { + .exampleSection { + grid-template-columns: 1fr; + } + + .libraryBookGrid { + justify-content: start; + width: fit-content; + } } -.infoCard p { - margin: 0; - color: var(--text-muted); - font-size: var(--repo-font-body-small-size); - font-weight: var(--repo-font-body-small-weight); - line-height: var(--repo-font-body-small-line-height); +@media (max-width: 560px) { + .libraryBookGrid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 480px) { + .pageShell { + width: min(100% - 32px, 1120px); + padding: 48px 0; + } + + .heroCard, + .exampleSection { + padding: 28px; + border-radius: 24px; + } +} + + +.resumePageShell { + display: grid; + justify-items: center; + width: min(100%, 920px); + margin: 0 auto; + padding: 24px 20px 48px; + background: var(--repo-gray-600); } diff --git a/src/dev/ComponentPreview.module.css b/src/dev/ComponentPreview.module.css index 2921a9f..45216d0 100644 --- a/src/dev/ComponentPreview.module.css +++ b/src/dev/ComponentPreview.module.css @@ -214,3 +214,23 @@ border: 2px dashed #9747FF; border-radius: 20px; } + +.libraryBookFrame { + display: grid; + grid-template-columns: repeat(2, max-content); + gap: 32px; + width: fit-content; + padding: 48px 56px; + border: 2px dashed #9747FF; + border-radius: 20px; + background: var(--repo-gray-600); +} + + +.portfolioResumeSheetFrame { + width: fit-content; + padding: 32px; + border: 2px dashed #9747FF; + border-radius: 20px; + background: var(--repo-gray-600); +} diff --git a/src/dev/ComponentPreview.tsx b/src/dev/ComponentPreview.tsx index a940ac2..7930c1b 100644 --- a/src/dev/ComponentPreview.tsx +++ b/src/dev/ComponentPreview.tsx @@ -3,7 +3,23 @@ import type { SVGProps, ReactNode } from 'react' import { useState } from 'react' -import { Button, Checkbox, CheckboxOption, Dropdown, Feedback, FeedbackBalloon, Input, Logo, Switch, Tag, Toast } from '@/shared/ui' +import { samplePortfolioResume, samplePortfolioHref, sampleResumeBookHref } from '@/shared/fixtures/examples/publicExamples' + +import { + Button, + Checkbox, + CheckboxOption, + Dropdown, + Feedback, + FeedbackBalloon, + Input, + Logo, + LibraryBookCard, + PortfolioResumeSheet, + Switch, + Tag, + Toast, +} from '@/shared/ui' import styles from './ComponentPreview.module.css' @@ -183,6 +199,27 @@ export function ComponentPreview() { +
+

+ Portfolio Resume Sheet +

+ +
+ +
+
+ +
+

+ Library Book Card +

+ +
+ + +
+
+

Tag diff --git a/src/shared/fixtures/examples/publicExamples.ts b/src/shared/fixtures/examples/publicExamples.ts new file mode 100644 index 0000000..37dda4f --- /dev/null +++ b/src/shared/fixtures/examples/publicExamples.ts @@ -0,0 +1,27 @@ +import type { InternalHref } from '@/shared/lib/internalHref' + +export const samplePortfolioHref = '/오혜민' satisfies InternalHref +export const sampleResumeBookHref = '/resume-books/sample' satisfies InternalHref + +export const samplePortfolioResume = { + name: '홍길동', + majorStatus: '전공미정', + headline: '안녕하세요 저는 디자이너가 되고 싶은 인간입니다', + intro: + '새벽잠실너무 졸립니다. 밀 적지.. 한줄소개는 이런식으로 쭉쭉 들어갑니다.\n줄넘김 가능합니다. 자기소개자기소개자기소개까지 소개까지 소개까지소개까지... 최대 4줄이면 충분하겠지만..\n줄이 이런식으로\n길어지면 폼도 자동으로 늘어납니다.', + studentNumber: '2415', + department: '인공지능소프트웨어과', + email: 'mare2mare6@gmail.com', + skills: ['Figma', 'illustrator', 'photoshop'], + activities: [ + { dateLabel: '2025.12.25', description: '제 1회 SCSC 온라인 해커톤 2위' }, + { dateLabel: '2025.07.18', description: '2025 교내 해커톤 우수상' }, + ], +} + +export function getSamplePortfolioResume(displayName: string) { + return { + ...samplePortfolioResume, + name: displayName, + } +} diff --git a/src/shared/lib/internalHref.ts b/src/shared/lib/internalHref.ts new file mode 100644 index 0000000..35ce2ba --- /dev/null +++ b/src/shared/lib/internalHref.ts @@ -0,0 +1,13 @@ +export type InternalHref = `/${string}` + +export function isSafeInternalHref(href: string): href is InternalHref { + return href.startsWith('/') && !href.startsWith('//') && !/[\u0000-\u001F\u007F]/.test(href) +} + +export function assertSafeInternalHref(href: InternalHref): InternalHref { + if (!isSafeInternalHref(href)) { + throw new Error('Internal href must start with a single slash and cannot contain control characters.') + } + + return href +} diff --git a/src/shared/ui/LibraryBookCard/LibraryBookCard.module.css b/src/shared/ui/LibraryBookCard/LibraryBookCard.module.css new file mode 100644 index 0000000..ba71781 --- /dev/null +++ b/src/shared/ui/LibraryBookCard/LibraryBookCard.module.css @@ -0,0 +1,90 @@ +.card { + position: relative; + display: block; + overflow: hidden; + width: 160px; + height: 220px; + padding: 20px 15px 16px; + border: 0; + border-radius: 12px; + color: var(--repo-gray-50); + /* library-book-cover.svg is an issue #14 fixed visual contract; update docs before renaming/moving. */ + background: + linear-gradient(180deg, rgb(57 57 57 / 6%) 0%, rgb(57 57 57 / 0%) 100%), + url('/assets/library-book-cover.svg') center / cover no-repeat; + text-decoration: none; + transform: translateZ(0); +} + +.card:focus-visible { + outline: 3px solid var(--repo-main); + outline-offset: 4px; +} + +.card[href] { + cursor: pointer; + transition: + filter 160ms ease, + transform 160ms ease; +} + +.card[href]:hover { + filter: brightness(1.08); + transform: translateY(-2px); +} + +.heading { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; +} + +.title { + color: var(--repo-gray-50); + font-size: 24px; + font-weight: 700; + line-height: 1; + letter-spacing: -0.04em; +} + +.batchLabel { + color: var(--repo-gray-400); + font-size: 16px; + font-weight: 600; + line-height: 1; + letter-spacing: -0.04em; +} + +.gradeLabel { + display: block; + margin-top: 6px; + color: var(--repo-gray-50); + font-size: 16px; + font-weight: 500; + line-height: 1; + letter-spacing: -0.04em; +} + +.action { + position: absolute; + right: 15px; + bottom: 15px; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + color: var(--repo-gray-400); + font-size: 14px; + font-weight: 500; + line-height: 1; + letter-spacing: -0.05em; + white-space: nowrap; +} + +.arrow { + font-size: 24px; + font-weight: 300; + line-height: 0.7; + transform: translateY(-1px); +} diff --git a/src/shared/ui/LibraryBookCard/LibraryBookCard.tsx b/src/shared/ui/LibraryBookCard/LibraryBookCard.tsx new file mode 100644 index 0000000..b8701d1 --- /dev/null +++ b/src/shared/ui/LibraryBookCard/LibraryBookCard.tsx @@ -0,0 +1,51 @@ +import type { ComponentPropsWithoutRef } from 'react' + +import { assertSafeInternalHref, type InternalHref } from '@/shared/lib/internalHref' + +import styles from './LibraryBookCard.module.css' + +type LibraryBookCardBaseProps = { + actionLabel?: string + ariaLabel?: string + batchLabel?: string + className?: string + gradeLabel?: string + href: InternalHref + title?: string +} + +/** + * Fixed 160×220 library-entry book cover card for the current Figma-derived library/resume-book entry pattern. + * Keep this API intentionally narrow until multiple cover variants or sizes are approved. + */ +export type LibraryBookCardProps = LibraryBookCardBaseProps & Omit, keyof LibraryBookCardBaseProps | 'href'> + +export function LibraryBookCard({ + actionLabel = '포트폴리오 열람', + ariaLabel, + batchLabel = '9기', + className, + gradeLabel = '2학년', + href, + title = '2022', + ...anchorProps +}: LibraryBookCardProps) { + const cardClassName = [styles.card, className].filter(Boolean).join(' ') + const accessibleLabel = ariaLabel ?? `${title} ${batchLabel} ${gradeLabel} ${actionLabel}` + + return ( + + + {title} + {batchLabel} + + {gradeLabel} + + {actionLabel} + + + + ) +} diff --git a/src/shared/ui/LibraryBookCard/index.ts b/src/shared/ui/LibraryBookCard/index.ts new file mode 100644 index 0000000..738e8af --- /dev/null +++ b/src/shared/ui/LibraryBookCard/index.ts @@ -0,0 +1,2 @@ +export { LibraryBookCard } from './LibraryBookCard' +export type { LibraryBookCardProps } from './LibraryBookCard' diff --git a/src/shared/ui/PortfolioResumeSheet/PortfolioResumeSheet.module.css b/src/shared/ui/PortfolioResumeSheet/PortfolioResumeSheet.module.css new file mode 100644 index 0000000..1c6d531 --- /dev/null +++ b/src/shared/ui/PortfolioResumeSheet/PortfolioResumeSheet.module.css @@ -0,0 +1,178 @@ +.sheet { + width: min(100%, 760px); + min-height: 1048px; + padding: 32px 36px 64px; + color: var(--repo-text-black); + background: var(--repo-gray-50); +} + +.header { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 64px; + gap: 22px; + align-items: start; +} + +.profilePlaceholder, +.qrPlaceholder { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--repo-gray-400); + background: var(--repo-gray-50); +} + +.profilePlaceholder { + width: 62px; + height: 62px; + border: 2px dashed var(--repo-gray-400); + border-radius: 999px; + font-size: 28px; +} + +.qrPlaceholder { + width: 56px; + height: 56px; + margin-left: auto; + border: 2px dashed var(--repo-gray-300); + font-size: 28px; +} + +.identity { + display: grid; + gap: 7px; + min-width: 0; +} + +.nameRow { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: baseline; +} + +.name { + margin: 0; + font-size: var(--repo-font-title-small-size); + font-weight: var(--repo-font-title-small-weight); + line-height: var(--repo-font-title-small-line-height); + letter-spacing: -0.04em; +} + +.majorStatus { + color: var(--repo-gray-500); + font-size: var(--repo-font-body-tiny-size); + font-weight: var(--repo-font-body-tiny-weight); + line-height: var(--repo-font-body-tiny-line-height); +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + color: var(--repo-text-black); + font-size: var(--repo-font-body-very-tiny-size); + font-weight: var(--repo-font-body-very-tiny-weight); + line-height: var(--repo-font-body-very-tiny-line-height); +} + +.meta span + span::before { + margin-right: 8px; + color: var(--repo-gray-700); + content: '|'; +} + +.introBox { + display: grid; + gap: 14px; + margin-top: 28px; + padding: 18px 20px; + border-radius: 12px; + background: var(--repo-gray-100); +} + +.headline, +.sectionTitle { + margin: 0; + font-size: var(--repo-font-body-medium-2-size); + font-weight: var(--repo-font-body-medium-2-weight); + line-height: var(--repo-font-body-medium-2-line-height); + letter-spacing: -0.04em; +} + +.intro { + max-width: 620px; + margin: 0; + color: var(--repo-gray-800); + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: 1.45; + white-space: pre-line; +} + +.section { + display: grid; + gap: 18px; + margin-top: 28px; +} + +.skillList { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 0; + margin: 0; + list-style: none; +} + +.skillChip { + padding: 9px 18px; + border-radius: 999px; + background: var(--repo-gray-100); + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: var(--repo-font-body-small-line-height); +} + +.activityList { + display: grid; + gap: 18px; + padding: 24px 0 0; + margin: 0; + border-top: 1px solid var(--repo-gray-300); + list-style: none; +} + +.activityItem { + display: grid; + gap: 4px; + color: var(--repo-text-black); + font-size: var(--repo-font-body-small-size); + font-weight: var(--repo-font-body-small-weight); + line-height: var(--repo-font-body-small-line-height); +} + +.activityDate { + color: var(--repo-gray-500); +} + +@media (max-width: 640px) { + .sheet { + min-height: auto; + padding: 24px 20px 40px; + } + + .header { + grid-template-columns: 56px minmax(0, 1fr); + } + + .profilePlaceholder { + width: 52px; + height: 52px; + } + + .qrPlaceholder { + display: none; + } +} diff --git a/src/shared/ui/PortfolioResumeSheet/PortfolioResumeSheet.tsx b/src/shared/ui/PortfolioResumeSheet/PortfolioResumeSheet.tsx new file mode 100644 index 0000000..a64c72c --- /dev/null +++ b/src/shared/ui/PortfolioResumeSheet/PortfolioResumeSheet.tsx @@ -0,0 +1,92 @@ +import styles from './PortfolioResumeSheet.module.css' + +type ResumeActivity = { + dateLabel?: string + description: string +} + +/** + * Figma-derived portfolio intro/resume sheet. Typography contract: name uses Title Small, + * major status uses Body Tiny, and student number/department/email metadata uses Body very Tiny. + */ +export type PortfolioResumeSheetProps = { + activities?: ResumeActivity[] + department: string + email?: string + headline: string + intro: string + majorStatus?: string + name: string + profileImageLabel?: string + qrLabel?: string + skills?: string[] + studentNumber: string +} + +export function PortfolioResumeSheet({ + activities = [], + department, + email, + headline, + intro, + majorStatus = '전공미정', + name, + profileImageLabel = '프로필 이미지 영역', + qrLabel = 'QR 코드 영역', + skills = [], + studentNumber, +}: PortfolioResumeSheetProps) { + return ( +
+
+
+ +
+ +
+
+

{name}

+ {majorStatus} +
+

+ {studentNumber} + {department} + {email ? {email} : null} +

+
+ +
+ +
+
+ +
+

{headline}

+

{intro}

+
+ +
+

기술스택

+
    + {skills.map((skill) => ( +
  • + {skill} +
  • + ))} +
+
+ +
+

활동

+
    + {activities.map((activity) => ( +
  1. + {activity.dateLabel ? : null} + {activity.description} +
  2. + ))} +
+
+
+ ) +} diff --git a/src/shared/ui/PortfolioResumeSheet/index.ts b/src/shared/ui/PortfolioResumeSheet/index.ts new file mode 100644 index 0000000..e5cc639 --- /dev/null +++ b/src/shared/ui/PortfolioResumeSheet/index.ts @@ -0,0 +1,2 @@ +export { PortfolioResumeSheet } from './PortfolioResumeSheet' +export type { PortfolioResumeSheetProps } from './PortfolioResumeSheet' diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 0151fca..036da73 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -5,7 +5,9 @@ export { Feedback } from './Feedback'; export { FeedbackBalloon } from './FeedbackBalloon'; export { Icon } from './Icon'; export { Input } from './Input'; +export { LibraryBookCard } from './LibraryBookCard'; export { Logo } from './Logo'; +export { PortfolioResumeSheet } from './PortfolioResumeSheet'; export { Switch } from './Switch'; export { Tag } from './Tag'; export { Toast } from './Toast'; @@ -14,7 +16,9 @@ export type { DropdownOption, DropdownProps } from './Dropdown'; export type { FeedbackProps } from './Feedback'; export type { FeedbackBalloonProps } from './FeedbackBalloon'; export type { IconName } from './Icon'; +export type { LibraryBookCardProps } from './LibraryBookCard'; export type { LogoTone } from './Logo'; +export type { PortfolioResumeSheetProps } from './PortfolioResumeSheet'; export type { SwitchProps } from './Switch'; export type { TagProps, TagTone } from './Tag'; export type { ToastProps, ToastVariant } from './Toast'; diff --git a/tests/e2e/public-routes.spec.ts b/tests/e2e/public-routes.spec.ts index d899494..5b5bf7c 100644 --- a/tests/e2e/public-routes.spec.ts +++ b/tests/e2e/public-routes.spec.ts @@ -20,8 +20,9 @@ test.describe('public route smoke', () => { const body = await response.text() const normalizedBody = body.replaceAll(//g, '') - expect(normalizedBody).toContain('오혜민') - expect(normalizedBody).toContain('/오혜민') + expect(normalizedBody).toContain('홍길동') + expect(normalizedBody).toContain('기술스택') + expect(normalizedBody).toContain('2415') }) test('does not treat the reserved dev slug as a public portfolio', async ({ request }) => { diff --git a/tests/unit/internalHref.test.ts b/tests/unit/internalHref.test.ts new file mode 100644 index 0000000..74d3e04 --- /dev/null +++ b/tests/unit/internalHref.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { assertSafeInternalHref, isSafeInternalHref } from '../../src/shared/lib/internalHref.js' + +test('accepts app-internal paths', () => { + assert.equal(isSafeInternalHref('/오혜민'), true) + assert.equal(isSafeInternalHref('/resume-books/sample'), true) +}) + +test('rejects protocol-relative and malformed paths', () => { + assert.equal(isSafeInternalHref('//evil.example'), false) + assert.equal(isSafeInternalHref('https://evil.example'), false) + assert.equal(isSafeInternalHref('/safe\npath'), false) + assert.throws(() => assertSafeInternalHref('//evil.example' as `/${string}`), /Internal href/) +})