docs: 13단계 전환 준비 — 계약 6절 · 디자인 보드 넷 · README #10

Merged
ttipo merged 8 commits from feat/mail-cutover-prep into main 2026-11-02 19:18:08 +09:00
2 changed files with 45 additions and 33 deletions
Showing only changes of commit a7b9d00931 - Show all commits
@@ -17,30 +17,36 @@ import AccountAppPasswordsPage from './AccountAppPasswordsPage.vue'
// 설정 › 계정 › 앱 비밀번호 (12단계 — 디자인 원본 SettingsAppPasswords · 계약 5절 "앱 비밀번호")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 한국 시간 2026-10-31 (토) 낮 12시 */
const NOW = Date.parse('2026-10-31T03:00:00Z')
/**
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
* "오늘 오전 9:12" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
* (UTC 로 적었더니 개발 PC(KST)에서만 통과하고 CI(UTC)에서 실패했다 — 2026-11-02).
*/
const local = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).toISOString()
/** 2026-10-31 (토) 낮 12시 */
const NOW = Date.parse(local(10, 31, 12))
const VALUE = 'app_7qm2xk9vtn3wpl1srb3hcy7djf2gaz1euk0n'
const ITEMS: AppPassword[] = [
{ id: 'c', name: '노트북 썬더버드', createdAt: '2026-10-31T00:20:00Z', lastUsed: null },
{ id: 'c', name: '노트북 썬더버드', createdAt: local(10, 31, 9, 20), lastUsed: null },
{
id: 'b',
name: '회사 PC 아웃룩',
createdAt: '2026-10-27T00:00:00Z',
lastUsed: { at: '2026-10-31T00:12:00Z', method: 'imap', ip: '211.186.95.252' },
createdAt: local(10, 27, 9),
lastUsed: { at: local(10, 31, 9, 12), method: 'imap', ip: '211.186.95.252' },
},
{
id: 'a',
name: '휴대폰 메일 앱',
createdAt: '2026-10-20T00:00:00Z',
lastUsed: { at: '2026-10-30T09:40:00Z', method: 'smtp', ip: '223.38.120.77' },
createdAt: local(10, 20, 9),
lastUsed: { at: local(10, 30, 18, 40), method: 'smtp', ip: '223.38.120.77' },
},
]
const page = (items: AppPassword[]) => ({ items, pageInfo: { hasNext: false, nextCursor: null, total: items.length } })
function created(name: string): CreatedAppPassword {
return { id: 'd', name, createdAt: '2026-10-31T02:59:00Z', lastUsed: null, password: VALUE }
return { id: 'd', name, createdAt: local(10, 31, 11, 59), lastUsed: null, password: VALUE }
}
interface Server {
@@ -123,12 +129,12 @@ describe('AccountAppPasswordsPage', () => {
[
'회사 PC 아웃룩',
formatBlockedDay(ITEMS[1]?.createdAt ?? '', nowDate),
`${formatRelativeDayTime('2026-10-31T00:12:00Z', nowDate)} · IMAP · 211.186.95.252`,
`${formatRelativeDayTime(ITEMS[1]?.lastUsed?.at ?? '', nowDate)} · IMAP · 211.186.95.252`,
],
[
'휴대폰 메일 앱',
formatBlockedDay(ITEMS[2]?.createdAt ?? '', nowDate),
`${formatRelativeDayTime('2026-10-30T09:40:00Z', nowDate)} · SMTP · 223.38.120.77`,
`${formatRelativeDayTime(ITEMS[2]?.lastUsed?.at ?? '', nowDate)} · SMTP · 223.38.120.77`,
],
])
expect(rows(wrapper)[1]?.[2]).toBe('오늘 오전 9:12 · IMAP · 211.186.95.252')
@@ -16,30 +16,36 @@ import AccountLoginHistoryPage from './AccountLoginHistoryPage.vue'
// 설정 › 계정 › 로그인 기록 (12단계 — 디자인 원본 SettingsLoginHistory · 계약 5절 "로그인된 브라우저" · "로그인 기록")
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
/** 한국 시간 2026-10-31 (토) 낮 12시 */
const NOW = Date.parse('2026-10-31T03:00:00Z')
/**
* 2026년 그 시각을 이 PC 의 시간대(지역 시각)로 — 화면은 시각을 브라우저 시간대로 보인다.
* "오늘 오전 9:05" 같은 글을 그대로 보려면 시각을 지역 시각으로 적어야 어느 시간대에서도 같다
* (UTC 로 적었더니 개발 PC(KST)에서만 통과하고 CI(UTC)에서 실패했다 — 2026-11-02).
*/
const local = (month: number, day: number, hour: number, minute = 0) => new Date(2026, month - 1, day, hour, minute).toISOString()
/** 2026-10-31 (토) 낮 12시 */
const NOW = Date.parse(local(10, 31, 12))
const SESSIONS: AuthSession[] = [
{
id: 'now',
createdAt: '2026-10-31T00:00:00Z',
lastActiveAt: '2026-10-31T02:50:00Z',
createdAt: local(10, 31, 9),
lastActiveAt: local(10, 31, 11, 50),
ip: '211.186.95.252',
device: { browser: 'Chrome', os: 'Windows' },
isCurrent: true,
},
{
id: 'edge',
createdAt: '2026-10-30T00:00:00Z',
lastActiveAt: '2026-10-31T00:05:00Z',
createdAt: local(10, 30, 9),
lastActiveAt: local(10, 31, 9, 5),
ip: '211.186.95.252',
device: { browser: 'Edge', os: 'Windows' },
isCurrent: false,
},
{
id: 'phone',
createdAt: '2026-10-29T00:00:00Z',
lastActiveAt: '2026-10-30T13:31:00Z',
createdAt: local(10, 29, 9),
lastActiveAt: local(10, 30, 22, 31),
ip: null,
device: { browser: 'Safari', os: 'iOS' },
isCurrent: false,
@@ -49,7 +55,7 @@ const SESSIONS: AuthSession[] = [
function loginEvent(id: string, changes: Partial<LoginEvent>): LoginEvent {
return {
id,
occurredAt: '2026-10-31T00:30:00Z',
occurredAt: local(10, 31, 9, 30),
method: 'webmail',
isSuccess: true,
isTwoFactor: false,
@@ -62,23 +68,23 @@ function loginEvent(id: string, changes: Partial<LoginEvent>): LoginEvent {
}
const EVENTS: LoginEvent[] = [
loginEvent('e1', { occurredAt: '2026-10-31T00:30:00Z', isTwoFactor: true }),
loginEvent('e2', { occurredAt: '2026-10-31T00:29:00Z', isSuccess: false, failureReason: 'wrongCode' }),
loginEvent('e1', { occurredAt: local(10, 31, 9, 30), isTwoFactor: true }),
loginEvent('e2', { occurredAt: local(10, 31, 9, 29), isSuccess: false, failureReason: 'wrongCode' }),
loginEvent('e3', {
occurredAt: '2026-10-31T00:12:00Z',
occurredAt: local(10, 31, 9, 12),
method: 'imap',
device: null,
appPassword: { id: 'b', name: '회사 PC 아웃룩' },
}),
loginEvent('e4', {
occurredAt: '2026-10-30T14:58:00Z',
occurredAt: local(10, 30, 23, 58),
method: 'smtp',
device: null,
isSuccess: false,
failureReason: 'appPasswordRequired',
ip: '45.133.10.21',
}),
loginEvent('e5', { occurredAt: '2026-10-29T06:21:00Z', device: null, isSuccess: false, failureReason: 'wrongPassword' }),
loginEvent('e5', { occurredAt: local(10, 29, 15, 21), device: null, isSuccess: false, failureReason: 'wrongPassword' }),
]
const pageOf = <T>(items: T[], next: string | null = null, total: number | null = items.length) => ({
@@ -175,8 +181,8 @@ describe('AccountLoginHistoryPage', () => {
])
expect(sessionRows(wrapper)).toEqual([
['Chrome · Windows', '211.186.95.252', '지금 이 브라우저'],
['Edge · Windows', '211.186.95.252', formatRelativeDayTime('2026-10-31T00:05:00Z', nowDate)],
['Safari · iOS', '—알 수 없음', formatRelativeDayTime('2026-10-30T13:31:00Z', nowDate)],
['Edge · Windows', '211.186.95.252', formatRelativeDayTime(SESSIONS[1]?.lastActiveAt ?? '', nowDate)],
['Safari · iOS', '—알 수 없음', formatRelativeDayTime(SESSIONS[2]?.lastActiveAt ?? '', nowDate)],
])
expect(sessionRows(wrapper)[1]?.[2]).toBe('오늘 오전 9:05')
expect(wrapper.findAll('[data-session] svg').map((icon) => icon.attributes('aria-label'))).toEqual(['PC', 'PC', '휴대폰'])
@@ -319,15 +325,15 @@ describe('AccountLoginHistoryPage', () => {
it('시각 · 방식(앱 비밀번호 이름) · IP · 기기(메일 프로그램은 —) · 결과 — 실패는 빨간 굵은 글자만', async () => {
const wrapper = await mountPage()
const nowDate = new Date(NOW)
const time = (value: string) => formatRelativeDayTime(value, nowDate)
const time = (index: number) => formatRelativeDayTime(EVENTS[index]?.occurredAt ?? '', nowDate)
expect(wrapper.get('[data-events-hint]').text()).toBe('최근 90일 · 웹메일과 메일 프로그램')
expect(eventRows(wrapper)).toEqual([
[time('2026-10-31T00:30:00Z'), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'],
[time('2026-10-31T00:29:00Z'), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'],
[time('2026-10-31T00:12:00Z'), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
[time('2026-10-30T14:58:00Z'), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'],
[time('2026-10-29T06:21:00Z'), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'],
[time(0), '웹메일', '211.186.95.252', 'Chrome · Windows', '성공 · 2단계 인증'],
[time(1), '웹메일', '211.186.95.252', 'Chrome · Windows', '실패 — 코드 틀림'],
[time(2), '메일 프로그램 · IMAP앱 비밀번호 ‘회사 PC 아웃룩’', '211.186.95.252', '—알 수 없음', '성공'],
[time(3), '메일 프로그램 · SMTP', '45.133.10.21', '—알 수 없음', '실패 — 앱 비밀번호 필요'],
[time(4), '웹메일', '211.186.95.252', '알 수 없는 브라우저', '실패 — 비밀번호 틀림'],
])
expect(eventRows(wrapper)[0]?.[0]).toBe('오늘 오전 9:30')
expect(wrapper.findAll('[data-event-result]').map((cell) => cell.classes().includes('events__result--failure'))).toEqual([
@@ -383,7 +389,7 @@ describe('AccountLoginHistoryPage', () => {
it('[더 보기] — 커서로 20개씩 이어 받는다(실패만이면 실패만) · 다 받으면 단추가 사라진다', async () => {
const failures = EVENTS.filter((event) => !event.isSuccess)
const more = [loginEvent('e9', { occurredAt: '2026-10-12T19:02:00Z', isSuccess: false, failureReason: 'wrongPassword' })]
const more = [loginEvent('e9', { occurredAt: local(10, 13, 4, 2), isSuccess: false, failureReason: 'wrongPassword' })]
const wrapper = await mountPage({
events: (request) => {
if (request.params.cursor === 'next-failures') return ok(pageOf(more, null, 4))