diff --git a/README.md b/README.md index 31f204c..27cfcb4 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 ** 기기 이름은 서버 DB에 저장되며 동일 브라우저의 기기 세션 쿠키가 유효한 동안 유지됩니다. 쿠키를 지우거나 다른 브라우저로 접속하면 새 기기로 인식됩니다. -전송 제한의 기본값은 상대 10회·기기 세션 30회·같은 IP 전체 120회/분입니다. 거절 후 5분 대기는 유지하며 전송 버튼에 재시도까지 남은 초를 표시합니다. 기존 NAS `.env` 한도 변경은 [deploy.md](deploy.md)의 전송 요청 제한 안내를 따릅니다. 수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하며 차단은 확인 후 실행합니다. 푸터의 내 기기 설정에서 차단한 기기를 관리·해제할 수 있습니다. `npm run test:e2e:receive`로 새 수신·제한·차단 흐름을 검증합니다. +전송 제한의 기본값은 상대 10회·기기 세션 30회·같은 IP 전체 120회/분입니다. 같은 상대에게 최근 5분 안에 연속 3회 거절된 경우에만 마지막 거절부터 5분 대기를 적용하며, 중간 수락은 누적을 끊습니다. 거절 시 보내기 창을 닫고 전송자에게 알리며 제한 중에는 전송 버튼에 남은 초를 표시합니다. 기존 NAS `.env` 한도 변경은 [deploy.md](deploy.md)의 전송 요청 제한 안내를 따릅니다. 수신 요청은 파일·링크·텍스트 아이콘과 제목으로 구분하며 차단은 확인 후 실행합니다. 푸터의 내 기기 설정에서 차단한 기기를 관리·해제할 수 있습니다. `npm run test:e2e:receive`로 새 수신·제한·차단 흐름을 검증합니다. ## 관리자 정보와 용량 표시 @@ -147,6 +147,6 @@ NAS 파일은 `/volume1/docker/projects/apps/hop/uploads/originals` 안에서 ** 관리 기록은 `동시에 처리할 업로드 수 5 > 10`, `블랙리스트 추가: 중국(CN)`처럼 변경 사항만 보여 줍니다. 변경이 없는 저장은 기록하지 않습니다. 이전 기록은 비교 가능한 전 정보가 있을 때만 차이를 표시합니다. 읽음 기능은 두지 않고 개별·선택·전체 삭제만 제공하며 기록을 지워도 설정·파일·자동 차단 목록은 바뀌지 않습니다. -관리자 용량은 기본적으로 값만 표시하며 **수정 → 설정 저장/취소**로 변경합니다. 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 설정하며 저장한 값은 DB에 보존됩니다. GB 입력은 소수점 셋째 자리까지 지원하고 입력창을 벗어나면 `20.000`처럼 정리합니다. **기본값으로 돌리기**는 NAS `.env`의 초기 설정값을 초안으로 불러오며 **설정 저장**을 눌러야 적용됩니다. 네트워크 토글과 규칙 추가·제거는 언제든 각각 즉시 적용하고, 자동 블랙리스트의 숫자 감지 기준만 해당 영역의 **수정 → 감지 기준 저장/취소**로 변경합니다. 서로 다른 영역의 변경이 입력 중인 감지 기준을 함께 저장하지 않습니다. IP 대역은 CIDR 외에 `203.0.113.*`처럼 뒤쪽 전체 자리의 IPv4 와일드카드도 입력할 수 있고, `203.0.113.0/24`로 정규화합니다. 저장 파일 목록은 업로더 ID와 계정 이메일(비로그인은 ‘비로그인’)을 표시합니다. +관리자 용량은 기본적으로 값만 표시하며 **수정 → 설정 저장/취소**로 변경합니다. 계정별 보관함·기기 세션별/전체 업로드·파일 한 개·NAS 최소 여유 공간도 설정하며 저장한 값은 DB에 보존됩니다. GB 입력은 소수점 셋째 자리까지 지원하고 입력창을 벗어나면 `20.000`처럼 정리합니다. **기본값으로 돌리기**는 NAS `.env`의 초기 설정값을 초안으로 불러오며 **설정 저장**을 눌러야 적용됩니다. 네트워크 토글과 규칙 추가·제거는 언제든 각각 즉시 적용하고, 자동 블랙리스트의 숫자 감지 기준만 해당 영역의 **수정 → 감지 기준 저장/취소**로 변경합니다. 서로 다른 영역의 변경이 입력 중인 감지 기준을 함께 저장하지 않습니다. IP 대역은 CIDR 외에 `203.0.113.*`처럼 뒤쪽 전체 자리의 IPv4 와일드카드도 입력할 수 있고, `203.0.113.0/24`로 정규화합니다. 저장 파일 목록은 업로더 ID와 계정 이메일(로그인하지 않은 사용자는 ‘게스트(Guest)’)을 표시합니다. -네트워크의 **파일 전송 금지**를 켜면 새 파일 전송/저장 메뉴를 숨기고 링크·텍스트만 허용합니다. 이미 수락한 파일 업로드와 기존 다운로드는 유지하며, 변경 사항은 접속 중인 사용자에게도 반영합니다. 용량 표시는 `7,687.0GB`처럼 천 단위 쉼표를 사용합니다. +네트워크의 **파일 전송 금지**를 켜면 새 파일 전송/저장 메뉴를 숨기고 링크·텍스트만 허용합니다. 이미 수락한 파일 업로드와 기존 다운로드는 유지하며, 변경 사항은 접속 중인 사용자에게도 반영합니다. 용량 표시는 천 단위 쉼표를 사용하고, 정수 부분은 선명하게, 소수점 부분은 더 작고 흐리게 표시합니다. 단위 앞에 시각적 간격을 두며 전송·수신·보관함·관리자 화면에 같은 표시를 적용합니다. 관리자 저장 파일은 `업로더UUID · 게스트(Guest)` 또는 `업로더UUID · 이메일`로 표시하고 `접속 지역 · 일본(JP) · Tokyo · IP`처럼 지역과 IP도 구분합니다. diff --git a/apps/server/src/service.ts b/apps/server/src/service.ts index e539496..cd46fc8 100644 --- a/apps/server/src/service.ts +++ b/apps/server/src/service.ts @@ -616,25 +616,36 @@ export class Service { input.files?.some((f) => f.size > this.admin.state.perFileBytes) ) fail(413, 'TRANSFER_LIMIT', '전송 크기 또는 개수 제한을 초과했습니다.'); - const rejected = this.db - .select() + // Count decisions for this direction only. Acceptance resets the streak; + // cancelling or letting a request expire must not bypass repeated rejections. + const decisions = this.db + .select({ status: s.requests.status, rejectedAt: s.requests.rejectedAt }) .from(s.requests) .where( and( eq(s.requests.senderGuestId, g.id), eq(s.requests.recipientGuestId, target!.guestId), - eq(s.requests.status, 'rejected'), - gt(s.requests.rejectedAt, Date.now() - this.cfg.rejectCooldownMs), + or( + eq(s.requests.status, 'accepted'), + and(eq(s.requests.status, 'rejected'), sql`${s.requests.rejectedAt} IS NOT NULL`), + ), ), ) - .orderBy(desc(s.requests.rejectedAt)) - .get(); - if (rejected) + .orderBy(desc(s.requests.createdAt), desc(sql`${s.requests}.rowid`)) + .limit(3) + .all(); + if ( + decisions.length === 3 && + decisions.every( + (row) => + row.status === 'rejected' && row.rejectedAt! > Date.now() - this.cfg.rejectCooldownMs, + ) + ) throw new ApiError( 429, 'REJECTION_COOLDOWN', - '상대가 거절했어요. 잠시 후 다시 시도해 주세요.', - rejected.rejectedAt! + this.cfg.rejectCooldownMs, + '상대가 연속 3회 거절했어요. 잠시 후 다시 시도해 주세요.', + decisions[0].rejectedAt! + this.cfg.rejectCooldownMs, 'pair', ); const active = this.db diff --git a/apps/server/test/phase1.test.ts b/apps/server/test/phase1.test.ts index 761f37a..8385fcf 100644 --- a/apps/server/test/phase1.test.ts +++ b/apps/server/test/phase1.test.ts @@ -339,6 +339,11 @@ test('거절, 만료, 취소 후 확정 금지와 거절 재요청 제한', asyn ).statusCode, 409, ); + for (let i = 0; i < 2; i++) { + const next = await f.request(a, b); + assert.equal(next.statusCode, 200); + await f.call(b, `/transfer-requests/${next.json().requestId}/reject`, 'POST', {}); + } assert.equal((await f.request(a, b)).statusCode, 429); const c = await f.create(); await f.space(a, c); diff --git a/apps/server/test/transfer-controls.test.js b/apps/server/test/transfer-controls.test.js index 738cc06..85a6169 100644 --- a/apps/server/test/transfer-controls.test.js +++ b/apps/server/test/transfer-controls.test.js @@ -4,6 +4,8 @@ import { fixture } from './helpers.js'; import { Limiter, ApiError } from '../src/security.js'; import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; +import { eq } from 'drizzle-orm'; +import * as schema from '../src/db/schema.js'; test('a known reconnecting peer returns a transient code without creating a request or charging quotas', async () => { const f = await fixture(true); @@ -100,25 +102,83 @@ test('transfer limit returns Retry-After and cooldown metadata without creating } }); -test('rejection cooldown reports time and does not use a successful-transfer quota', async () => { +test('only three consecutive recent rejections trigger a directional pair cooldown; denied retries do not charge quotas', async () => { const f = await fixture(); try { - f.cfg.sessionRate = 2; + f.cfg.sessionRate = 4; const a = await f.create(), b = await f.create(), c = await f.create(); await f.space(a, b); await f.space(a, c); - const request = await f.request(a, b); - await f.call(b, `/transfer-requests/${request.json().requestId}/reject`, 'POST', {}); + for (let i = 0; i < 3; i++) { + const request = await f.request(a, b); + assert.equal(request.statusCode, 200, `rejection ${i} must allow the next request`); + const id = request.json().requestId; + assert.equal( + (await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode, + 200, + ); + assert.equal( + (await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode, + 409, + ); + } + let deadline; for (let i = 0; i < 4; i++) { const denied = await f.request(a, b); assert.equal(denied.statusCode, 429); assert.equal(denied.json().code, 'REJECTION_COOLDOWN'); assert.equal(denied.json().limitScope, 'pair'); assert(denied.json().retryAfterSeconds <= 300 && denied.json().retryAfterSeconds > 290); + deadline ??= denied.json().retryAt; + assert.equal(denied.json().retryAt, deadline, 'retry never extends the cooldown'); } assert.equal((await f.request(a, c)).statusCode, 200); + assert.equal((await f.request(b, a)).statusCode, 200, 'reverse direction is independent'); + } finally { + await f.close(); + } +}); + +test('acceptance breaks a rejection streak; cancellation does not bypass it; cooldown expires without deleting history', async () => { + const f = await fixture(false, undefined, { pairRate: 30, sessionRate: 50 }); + try { + const a = await f.create(), + b = await f.create(); + await f.space(a, b); + const reject = async () => { + const request = await f.request(a, b); + assert.equal(request.statusCode, 200, request.body); + const id = request.json().requestId; + assert.equal( + (await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {})).statusCode, + 200, + ); + return id; + }; + await reject(); + await reject(); + await f.complete(a, b, '수락하면 누적 초기화'); + await reject(); + await reject(); + const cancelled = await f.request(a, b); + assert.equal(cancelled.statusCode, 200); + await f.call(a, `/transfer-requests/${cancelled.json().requestId}/cancel`, 'POST', {}); + const id = await reject(); + assert.equal((await f.request(a, b)).json().code, 'REJECTION_COOLDOWN'); + const cutoff = Date.now() - f.cfg.rejectCooldownMs; + // At exactly the third rejection's deadline the same history no longer blocks. + f.service.db + .update(schema.requests) + .set({ rejectedAt: cutoff }) + .where(eq(schema.requests.id, id)) + .run(); + assert.equal((await f.request(a, b)).statusCode, 200); + assert.equal( + f.service.db.select().from(schema.requests).where(eq(schema.requests.id, id)).get().status, + 'rejected', + ); } finally { await f.close(); } diff --git a/apps/web/src/AccountPanel.vue b/apps/web/src/AccountPanel.vue index 181e5b9..5642574 100644 --- a/apps/web/src/AccountPanel.vue +++ b/apps/web/src/AccountPanel.vue @@ -1,5 +1,6 @@ + + + + diff --git a/apps/web/src/client.js b/apps/web/src/client.js index 508174a..76ce9e9 100644 --- a/apps/web/src/client.js +++ b/apps/web/src/client.js @@ -28,6 +28,8 @@ export const device = ref(null), export const pendingPeers = ref([]); export const transferCooldown = ref({ until: 0, targetDeviceId: null }); export const transferConnecting = ref(false); +// Also observe a decision that arrives before the first pending snapshot/HTTP response. +export const latestRequestedTransfer = ref(null); let pendingTimer; let deviceInfo = {}; export const nextCursor = ref(null); @@ -88,7 +90,9 @@ export async function requestTransfer(path, input) { if (device.value?.id !== owner) throw Error('기기 세션이 변경되었습니다. 다시 선택해 주세요.'); try { - return await api(path, 'POST', input, key); + const result = await api(path, 'POST', input, key); + latestRequestedTransfer.value = result.requestId; + return result; } catch (error) { // Only a known, still-authorized peer in its reconnect grace period is retried. if (error.code !== 'TARGET_RECONNECTING' || Date.now() >= deadline) throw error; @@ -279,8 +283,10 @@ export const refresh = createRefreshQueue(async () => { if ( ['rejected', 'expired', 'cancelled'].includes(req.status) || req.transferStatus === 'stored' - ) + ) { drafts.delete(req.id); + fileDrafts.delete(req.id); + } } }); export async function send(targetDeviceId, kind, values) { diff --git a/deploy.md b/deploy.md index b0385a8..166d6a8 100644 --- a/deploy.md +++ b/deploy.md @@ -461,7 +461,7 @@ pull_policy: build 새 기본 한도는 같은 상대 10회/분, 기기 세션 전체 30회/분, 같은 IP 전체 120회/분입니다. 로그인 중 기기 전송에도 같은 기준을 적용합니다. 파일 개수가 아닌 전송 요청 횟수를 계산하며, 거절·취소한 전송 요청도 이미 사용한 횟수에 포함됩니다. 제한된 재시도나 진행 중 전송 때문에 거절된 요청은 다른 한도를 추가로 소모하지 않습니다. -제한 창은 첫 허용 요청부터 60초이며 반복 재시도로 종료 시간이 연장되지 않습니다. 상대가 거절하면 별도로 기본 300초 동안 그 상대에게 재요청할 수 없습니다. 서버는 `Retry-After`와 남은 시간을 반환하고, 전송 버튼에 카운트를 표시합니다. 대기 중에도 입력·파일 선택은 유지하며 시간이 끝나면 전송 버튼이 다시 활성화됩니다. +제한 창은 첫 허용 요청부터 60초이며 반복 재시도로 종료 시간이 연장되지 않습니다. 같은 상대가 최근 300초 안에 연속 3회 거절한 경우에만 마지막 거절부터 기본 300초 동안 그 상대에게 재요청할 수 없습니다. 한두 번의 거절 후에는 즉시 다시 요청할 수 있고 중간에 수락하면 누적을 끊습니다. 취소·만료는 거절 횟수에 추가하지 않으며 기존 거절 누적을 지우는 용도로도 쓰지 않습니다. 다른 상대나 반대 방향 전송에는 해당 거절 제한을 적용하지 않습니다. 거절 시 보내기 창을 닫고 상대가 거절했다는 알림을 표시하며, 새로고침으로 과거 거절을 반복 알리지 않습니다. 서버는 `Retry-After`와 남은 시간을 반환하고, 전송 버튼에 카운트를 표시합니다. 대기 중에도 입력·파일 선택은 유지하며 시간이 끝나면 전송 버튼이 다시 활성화됩니다. **기존 NAS `.env`에 이전 기본값이 있으면 한 번 아래 명령으로 그 값만 변경합니다. 직접 조정한 다른 값과 비밀값은 유지합니다.** hop 폴더에서 실행하세요: @@ -528,7 +528,7 @@ Compose 변경이나 DB 초기화 없이 평소처럼 Git pull 후 `sudo docker ## 출시 전 전송 자료 초기화 (계정 보존) -현재 운영 NAS의 전송/보관 자료를 비워도 된다는 운영자 결정에 따라 사용할 일회성 작업입니다. 앱 시작 때 자동 초기화하거나 새 폴더로 이관하는 코드는 추가하지 않습니다. 새 업로드는 접두어 없는 `uploads/originals/업로더UUID/`에 저장합니다. 관리자 파일 목록은 기기명을 생략하고 ID와 로그인 이메일 또는 ‘비로그인’을 표시합니다. 과거 비로그인 기록을 현재 로그인 계정으로 추정하지 않습니다. +현재 운영 NAS의 전송/보관 자료를 비워도 된다는 운영자 결정에 따라 사용할 일회성 작업입니다. 앱 시작 때 자동 초기화하거나 새 폴더로 이관하는 코드는 추가하지 않습니다. 새 업로드는 접두어 없는 `uploads/originals/업로더UUID/`에 저장합니다. 관리자 파일 목록은 기기명을 생략하고 ID와 로그인 이메일 또는 ‘게스트(Guest)’를 구분점으로 나누어 표시합니다. 과거 비로그인 기록을 현재 로그인 계정으로 추정하지 않습니다. 프로젝트 디렉터리에서 최신 소스를 받은 뒤 진행합니다. @@ -549,3 +549,7 @@ sudo docker compose -f docker-compose.yaml up -d --build ## 관리자 용량 표시·파일 전송 금지 관리자 상단의 신규 업로드 허용/중지 영역은 제거하고 파일 전송 금지를 네트워크의 개별 토글로 옮겼습니다. 용량 단위는 KB/MB/GB를 유지하고 `7,687.0GB`처럼 천 단위 쉼표를 표시합니다. 동시에 처리할 업로드 수에도 설명을 표시하고 읽기/수정 화면의 수치와 입력창을 같은 높이로 정렬합니다. 이번 변경은 기존 관리 설정 필드를 재사용하므로 DB 초기화나 Compose 변경 없이 최신 소스로 재빌드·배포하면 됩니다. + +## 거절 누적·게스트 표시·용량 소수점 가독성 + +한 번 거절하면 바로 5분 기다리는 정책을 연속 3회 거절 시에만 적용하도록 변경했습니다. 텍스트·링크·파일과 보관함 재전송에 같은 정책을 적용합니다. 기존 횟수 제한과 수신자 차단은 유지합니다. 용량 숫자는 천 단위 구분을 유지하고 소수점 부분을 더 작고 흐리게, 단위 앞에는 시각적 간격을 둡니다. 저장 파일의 주체는 `UUID · 게스트(Guest)` 또는 `UUID · 이메일`, 지역은 `접속 지역 · 일본(JP) · Tokyo · IP` 형식으로 표시합니다. DB 구조·저장된 바이트 수·Compose 변경 없이 최신 소스를 재빌드·배포하면 됩니다. diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index ad7a16e..9006797 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -233,3 +233,10 @@ - 기본값 복원은 서버가 제공하는 NAS 초기 설정값을 초안에만 채운다. 취소 시 기존 설정을 유지하고 명시적으로 저장하면 일곱 용량/동시 업로드 항목만 복원하며 네트워크 토글·감지 기준·기존 파일은 유지한다. 저장된 관리 설정과 초기 기본값을 분리하고 별도 인스턴스 및 Docker 재시작 뒤에도 초기값을 그대로 제공함을 확인했다. - 네트워크 전체 수정 모드를 없애고 규칙 추가/선택 제거와 각 토글을 즉시 적용한다. 자동 감지 숫자만 해당 영역에서 수정/저장/취소하며, 수정 중 목록/토글 작업이 숫자 초안을 저장하지 않음을 실제 브라우저로 검증했다. 관리자 자신 차단 거절 시 추가 모달과 기존 목록 유지, 영역 간 여백, 자동 차단/해제와 기존 파일 금지 정책도 확인했다. - 전체 API 145개, 관리자 PC/모바일 및 기존 수신 화면 브라우저 회귀, 타입 검사·빌드·포맷 검사를 통과했다. 최종 운영 Docker 이미지의 격리 컨테이너에서 기본값과 저장 설정 분리, 정책/파일/텍스트/인증 재시작 보존, 수락된 8MiB 조각 재개와 다운로드, SQLite integrity·NAS UID 바인드 저장소·수동 삭제 대응도 통과했다. 테스트 자원은 종료 후 제거했으며 NAS 운영 데이터·Compose는 변경하지 않았다. + +## 2026-10-15 연속 거절 제한·발신자 알림·용량 가독성 + +- 같은 발신/수신 기기 쌍에서 최근 5분 안의 연속 3회 거절일 때만 마지막 거절부터 기본 5분 대기를 적용한다. 한두 번의 거절 뒤 재요청, 중간 수락 시 누적 해제, 반대 방향/다른 상대 격리, 취소로 누적 우회 불가, 같은 요청의 중복 거절 거부와 제한 재시도 시 마감 연장/다른 예산 소모 없음·기한 후 재요청을 API로 검증했다. 기존 차단·전송 빈도·수락 전 비공개 정책은 유지한다. +- 텍스트·파일·링크와 보관함 재전송의 거절 시 보내기 창을 닫고 상대 이름과 거절 알림을 표시한다. 전송 POST 응답을 일부러 지연하고 먼저 거절한 경우에도 알림/종료를 놓치지 않는 실제 브라우저 검증을 통과했다. 새로고침으로 과거 거절을 다시 알리지 않고, 세 번째 거절 뒤 재요청 제한에는 기존 남은 초 카운트를 표시한다. 거절/종료된 파일 초안의 메모리 참조도 정리한다. +- 모든 화면의 용량을 공통 컴포넌트로 표시하여 천 단위 정수는 선명하게, 소수점은 더 작고 흐리게, 단위 앞에는 시각적 간격을 준다. 기존 표시 단위·계산값·정확한 입력값은 유지한다. 실제 관리자 카드의 `7,687`/`.0`/`GB` 분리·소수점 글자 크기/명암·줄바꿈 방지·단위 간격과 PC/390px 모바일 화면을 확인했다. 게스트 명칭과 `UUID · 이메일/게스트(Guest)`, `국가 · 도시 · IP` 구분점도 확인했다. +- 전체 API 146개, 수신/관리자/클립보드/계정 보관함 브라우저 회귀, 타입 검사·빌드·포맷 검사를 통과했다. 최종 격리 운영 Docker에서 계정·파일·정책·관리 설정 재시작 보존, 8MiB 부분 조각 재개와 다운로드, NAS UID 바인드 저장소·수동 파일 삭제·SQLite integrity를 통과했다. 운영 NAS 자료·Compose·DB 구조 변경은 없다. diff --git a/tests/e2e/admin.mjs b/tests/e2e/admin.mjs index bdb4aa8..a5b6ffd 100644 --- a/tests/e2e/admin.mjs +++ b/tests/e2e/admin.mjs @@ -160,7 +160,40 @@ try { await admin.locator('.admin-file').filter({ hasText: 'sample.mp4' }).waitFor(); assert.match(await admin.locator('.admin-file').innerText(), /중국\(CN\) · Beijing/); assert.match(await admin.locator('.admin-file').innerText(), /1\.1\.1\.1/); - assert.match(await admin.locator('.admin-file-owner').innerText(), /^[a-f0-9-]{36}\s+비로그인$/); + assert.match( + await admin.locator('.admin-file-owner').innerText(), + /^[a-f0-9-]{36}\s*·\s*게스트\(Guest\)$/, + ); + assert.match(await admin.locator('.admin-file').innerText(), /Beijing\s*·\s*1\.1\.1\.1/); + const sizeStyle = await admin + .locator('.admin-stats article') + .nth(1) + .locator('strong .file-size') + .evaluate((label) => { + const fraction = label.querySelector('.file-size-fraction'); + const whole = label.querySelector('.file-size-whole'); + const unit = label.querySelector('.file-size-unit'); + return { + whole: whole.textContent, + fraction: fraction.textContent, + unit: unit.textContent, + dimmed: Number(getComputedStyle(fraction).opacity) < 1, + smaller: + parseFloat(getComputedStyle(fraction).fontSize) < + parseFloat(getComputedStyle(whole).fontSize), + inline: getComputedStyle(fraction).display === 'inline', + unitGap: parseFloat(getComputedStyle(unit).marginInlineStart) > 0, + }; + }); + assert.deepEqual(sizeStyle, { + whole: '7,687', + fraction: '.0', + unit: 'GB', + dimmed: true, + smaller: true, + inline: true, + unitGap: true, + }); assert.doesNotMatch(await admin.locator('.admin-file-owner').innerText(), /guest-|user-/); assert.match(await admin.locator('.admin-file').innerText(), /접속 지역 · 중국\(CN\) · Beijing/); assert.doesNotMatch(await admin.locator('.admin-file').innerText(), /최근 접속/); @@ -475,7 +508,7 @@ try { const ownedCard = admin.locator('.admin-file').filter({ hasText: 'account-owner.txt' }); await ownedCard.waitFor(); const ownerLabel = await ownedCard.locator('.admin-file-owner').innerText(); - assert.match(ownerLabel, /^[a-f0-9-]{36}\s+other@hop\.test$/); + assert.match(ownerLabel, /^[a-f0-9-]{36}\s*·\s*other@hop\.test$/); assert.doesNotMatch(ownerLabel, /user-|guest-|\[/); await admin.screenshot({ path: 'tests/e2e/admin-file-account.png', fullPage: true }); await admin.getByRole('button', { name: '관리 기록', exact: true }).click(); diff --git a/tests/e2e/phase3.mjs b/tests/e2e/phase3.mjs index c718440..e72db69 100644 --- a/tests/e2e/phase3.mjs +++ b/tests/e2e/phase3.mjs @@ -280,6 +280,14 @@ try { await storedLinkPage.waitForURL('https://example.com/saved'); await storedLinkPage.close(); await receiver.locator('.item-body').filter({ hasText: 'https://example.com/saved' }).waitFor(); + await sender.locator('.send-dialog').waitFor({ state: 'hidden' }); + await sender.locator('.peer-card').first().click(); + await sender.getByRole('button', { name: '보관함에서 가져오기', exact: true }).click(); + await sender.locator('.send-dialog .vault-choice').filter({ hasText: '업무 링크' }).click(); + await sender.getByRole('button', { name: '전송하기', exact: true }).click(); + await receiver.getByRole('button', { name: '거절', exact: true }).click(); + await sender.locator('.send-dialog').waitFor({ state: 'hidden' }); + await sender.locator('.toast').filter({ hasText: '전송을 거절했어요.' }).waitFor(); await navigate(sender, '보관함'); await sender.getByRole('button', { name: '보관함에 담기', exact: true }).click(); await sender diff --git a/tests/e2e/receive-controls.mjs b/tests/e2e/receive-controls.mjs index a9aa881..90d334c 100644 --- a/tests/e2e/receive-controls.mjs +++ b/tests/e2e/receive-controls.mjs @@ -194,9 +194,52 @@ try { await expect(blocked.getByText('차단한 기기가 없어요.')).toBeVisible(); await expect(receiver.locator('.peer-card')).toHaveCount(1); assert.equal((await api(receiver, '/device/blocks')).blocks.length, 0); + await blocked.getByRole('button', { name: '대화상자 닫기', exact: true }).click(); + const recipientName = (await api(receiver, '/device/me')).device.name; + // Reject the first request before its POST response reaches the sender. This + // must still close the composer even if it never observed a pending snapshot. + await sender.route('**/api/transfer-requests', async (route) => { + const response = await route.fetch(); + assert.equal(response.status(), 200); + const { requestId } = await response.json(); + await api(receiver, `/transfer-requests/${requestId}/reject`, 'POST', {}); + await route.fulfill({ response }); + }); + for (const kind of ['text', 'file', 'url']) { + await sender.locator('.peer-card').first().click(); + if (kind === 'file') + await sender.getByLabel('보낼 파일 선택', { exact: true }).setInputFiles({ + name: 'declined.txt', + mimeType: 'text/plain', + buffer: Buffer.from('test'), + }); + else + await sender + .getByLabel('보낼 텍스트', { exact: true }) + .fill(kind === 'url' ? 'https://example.com/declined' : '빠른 거절도 놓치지 않습니다.'); + await sender.getByRole('button', { name: '전송하기', exact: true }).click(); + if (kind !== 'text') { + const incoming = receiver.getByRole('dialog', { name: '전송 수락 요청' }); + await incoming.getByRole('button', { name: '거절', exact: true }).click(); + } + await expect(sender.locator('.send-dialog')).toHaveCount(0); + await expect(sender.locator('.toast')).toHaveText(`${recipientName}에서 전송을 거절했어요.`); + if (kind === 'text') await sender.unroute('**/api/transfer-requests'); + } + // History must not replay rejection notifications after a reload; the fourth + // request now gets the real pair cooldown and visible remaining seconds. + await sender.reload(); + await sender.locator('.peer-card').first().waitFor(); + await expect(sender.locator('.toast')).toHaveCount(0); + await sender.locator('.peer-card').first().click(); + await sender.getByLabel('보낼 텍스트', { exact: true }).fill('세 번 거절 이후의 대기'); + await sender.getByRole('button', { name: '전송하기', exact: true }).click(); + await expect(sender.locator('.content-submit')).toBeDisabled(); + await expect(sender.locator('.content-submit')).toHaveText(/\d+초 후 전송 가능/); + await expect(sender.locator('.error-toast')).toContainText('연속 3회 거절'); assert.deepEqual(errors, []); console.log( - 'PASS: content icons/titles, desktop/mobile 2:1 buttons, consistent single file sizes 97.7KB/1.3MB/1.6GB, no list indent or overflow, retry countdown and draft preservation, block confirmation and private unblock UI.', + 'PASS: receive icons/sizes, desktop/mobile 2:1 controls, retry countdown/draft preservation, block/unblock, first two rejections allow retries, text/file/link rejection closes sender and notifies, decision before HTTP response and reload deduplication, third rejection triggers pair cooldown.', ); } finally { await browser?.close();