Files
hop.noname.company/tests/e2e/admin.mjs

617 lines
31 KiB
JavaScript

import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { chromium, expect } from '@playwright/test';
import { createApp } from '../../apps/server/src/app.ts';
import { loadConfig } from '../../apps/server/src/config.ts';
import { users } from '../../apps/server/src/db/schema.ts';
const folder = await mkdtemp(resolve(tmpdir(), 'hop-admin-e2e-'));
const runtime = await createApp(
{
...loadConfig({
APP_URL: 'http://127.0.0.1',
DATABASE_PATH: resolve(folder, 'db.sqlite'),
UPLOAD_DIR: resolve(folder, 'uploads'),
MIN_FREE_DISK_MB: '0',
LOCATION_DISCOVERY_ENABLED: 'false',
CLIENT_IP_SOURCE: 'cloudflare',
TRUSTED_PROXY_CIDRS: '127.0.0.1,::1',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
webRoot: resolve('apps/web/dist'),
},
{ mailer: async () => {} },
);
// A NAS-sized disk verifies comma grouping in the rendered admin card.
const realSummary = runtime.service.admin.summary.bind(runtime.service.admin);
runtime.service.admin.summary = () => ({ ...realSummary(), diskFreeBytes: 7687 * 1024 ** 3 });
let browser;
try {
await runtime.app.listen({ host: '127.0.0.1', port: 0 });
const url = `http://127.0.0.1:${runtime.app.server.address().port}`;
runtime.setDevOrigin(url);
const passwordHash = await runtime.service.auth.password('12345678');
for (const email of ['hello@noname.company', 'other@hop.test'])
runtime.service.db
.insert(users)
.values({
id: randomUUID(),
email,
displayName: email,
passwordHash,
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
browser = await chromium.launch({
headless: true,
...(process.env.HOP_BROWSER_EXECUTABLE
? { executablePath: process.env.HOP_BROWSER_EXECUTABLE }
: {}),
});
const errors = [];
async function page(width = 1280, ip = '8.8.4.4', country = 'KR') {
const context = await browser.newContext({
locale: 'ko-KR',
viewport: { width, height: 900 },
isMobile: width < 600,
hasTouch: width < 600,
extraHTTPHeaders: {
'CF-Connecting-IP': ip,
'CF-IPCountry': country,
'CF-IPCity': country === 'CN' ? 'Beijing' : 'Daejeon',
'CF-Region': country === 'CN' ? 'Beijing' : 'Daejeon',
},
});
await context.addInitScript(() => localStorage.setItem('hop-language', 'ko'));
const page = await context.newPage();
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(url);
await page.locator('.app-shell[data-online="true"]').waitFor();
return page;
}
const sender = await page(1280, '1.1.1.1', 'CN'),
receiver = await page(390, '8.8.8.8', 'KR');
async function session(page) {
return page.evaluate(async () => {
const response = await fetch('/api/guest/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
return response.json();
});
}
const a = await session(sender),
b = await session(receiver);
async function call(page, csrf, path, body, method = 'POST') {
return page.evaluate(
async ({ csrf, path, body, method }) => {
const response = await fetch('/api' + path, {
method,
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
'X-CSRF-Token': csrf,
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify(body),
});
const value = await response.json();
if (!response.ok) throw Error(value.message);
return value;
},
{ csrf, path, body, method },
);
}
const invite = await call(sender, a.csrfToken, '/spaces', {});
await call(receiver, b.csrfToken, '/spaces/join', { code: invite.code });
const transfer = await call(sender, a.csrfToken, '/transfer-requests', {
targetDeviceId: b.device.id,
kind: 'file',
count: 1,
byteSize: 4,
files: [{ name: 'sample.mp4', size: 4, type: 'video/mp4' }],
});
await call(receiver, b.csrfToken, '/transfer-requests/' + transfer.requestId + '/accept', {});
async function uploadFile(page, csrf, id, name, type) {
await page.evaluate(
async ({ csrf, id, name, type }) => {
const body = new FormData();
body.append('file', new Blob(['test'], { type }), name);
const response = await fetch('/api/uploads?requestId=' + id + '&fileIndex=0', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf, 'Idempotency-Key': crypto.randomUUID() },
body,
});
if (!response.ok) throw Error(await response.text());
},
{ csrf, id, name, type },
);
}
await uploadFile(sender, a.csrfToken, transfer.requestId, 'sample.mp4', 'video/mp4');
async function login(page, email) {
await page.locator('.login-button').click();
await page.locator('.account-auth').waitFor();
await page.getByLabel('이메일', { exact: true }).fill(email);
await page.getByLabel('비밀번호', { exact: true }).fill('12345678');
await page
.locator('.account-auth')
.getByRole('button', { name: '로그인', exact: true })
.click();
await page.locator('.account-workspace').waitFor();
}
const normal = await page();
await login(normal, 'other@hop.test');
await normal.getByRole('button', { name: '더보기', exact: true }).click();
assert.equal(await normal.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0);
assert.equal(await normal.evaluate(async () => (await fetch('/api/admin/summary')).status), 403);
const admin = await page();
await login(admin, 'hello@noname.company');
await admin.getByRole('button', { name: '더보기', exact: true }).click();
await admin.getByRole('menuitem', { name: '관리자', exact: true }).click();
await admin.locator('.admin-stats').waitFor();
assert.equal(
await admin.locator('.admin-stats article').nth(1).locator('strong').innerText(),
'7,687.0GB',
);
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*·\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(), /최근 접속/);
assert.match(
await admin.locator('.admin-file footer small').innerText(),
/^\[보관 만료\] \d{4}년 \d{1,2}월 \d{1,2}일 \d{2}:\d{2} 까지$/,
);
assert.equal(await admin.locator('.admin-upload-control').count(), 0);
// Prepare a stored account file and an unsent file draft before pausing.
const normalSessionBefore = await session(normal);
const policyFile = await call(normal, normalSessionBefore.csrfToken, '/vault', {
kind: 'file',
files: [{ name: 'policy-file.txt', size: 4, type: 'text/plain' }],
});
await uploadFile(
normal,
normalSessionBefore.csrfToken,
policyFile.requestId,
'policy-file.txt',
'text/plain',
);
await normal.getByRole('button', { name: '더보기', exact: true }).click();
await normal.getByRole('tab', { name: '임시 저장', exact: true }).click();
const policyCard = normal.locator('.compact-item').filter({ hasText: 'policy-file.txt' });
await policyCard.waitFor();
await normal.getByRole('button', { name: '보관함에 담기', exact: true }).click();
await normal.getByRole('button', { name: '파일 저장', exact: true }).click();
await sender
.locator('.peer-card')
.filter({ hasText: '#' + b.device.code })
.click();
const composer = sender.locator('.send-dialog');
await composer
.locator('input[type=file]')
.setInputFiles({ name: 'draft.txt', mimeType: 'text/plain', buffer: Buffer.from('draft') });
await composer.locator('.selected-files').waitFor();
await admin.getByRole('button', { name: '네트워크', exact: true }).click();
async function banFiles(value) {
const toggle = admin.getByRole('switch', { name: '파일 전송 금지', exact: true });
if ((await toggle.getAttribute('aria-checked')) !== String(value)) await toggle.click();
await expect(toggle).toHaveAttribute('aria-checked', String(value));
}
await banFiles(true);
assert.equal(runtime.service.admin.state.uploadsPaused, true);
await composer
.getByText('파일 전송이 일시 제한되어 링크와 텍스트만 보낼 수 있어요.', { exact: true })
.waitFor();
assert.equal(await composer.locator('input[type=file]').count(), 0);
assert.equal(
await composer.getByRole('button', { name: '대신 파일 선택하기', exact: true }).count(),
0,
);
assert.equal(await composer.locator('.selected-files').count(), 0);
await normal.getByLabel('보낼 텍스트', { exact: true }).waitFor();
assert.equal(await normal.locator('input[type=file]').count(), 0);
assert.equal(
await policyCard.getByRole('button', { name: '기기로 보내기', exact: true }).count(),
0,
);
assert.equal(await policyCard.getByRole('button', { name: '다운로드', exact: true }).count(), 1);
await normal.getByRole('button', { name: '다른 종류 선택', exact: true }).click();
assert.equal(await normal.getByRole('button', { name: '파일 저장', exact: true }).count(), 0);
await normal.getByRole('button', { name: '보관함 저장 닫기', exact: true }).click();
const pausedGuest = await page(390);
await pausedGuest.locator('.file-policy-note').waitFor();
await pausedGuest.context().close();
const message = '파일 제한 중에도 한글 日本語 😀\n 텍스트를 전송합니다.';
await composer.getByLabel('보낼 텍스트', { exact: true }).fill(message);
await composer.locator('.unified-composer').evaluate((element) => {
const data = new DataTransfer();
data.items.add(new File(['ignored'], 'blocked.txt', { type: 'text/plain' }));
element.dispatchEvent(
new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: data }),
);
element.dispatchEvent(
new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: data }),
);
});
assert.equal(await composer.getByLabel('보낼 텍스트', { exact: true }).inputValue(), message);
await sender.screenshot({ path: 'tests/e2e/file-policy-sender.png', fullPage: true });
await composer.getByRole('button', { name: '전송하기', exact: true }).click();
await receiver.getByRole('button', { name: '수락', exact: true }).click();
await receiver.getByRole('dialog', { name: '받은 텍스트', exact: true }).waitFor();
assert.equal(await receiver.getByLabel('받은 텍스트 내용', { exact: true }).innerText(), message);
await receiver.getByRole('button', { name: '받은 텍스트 닫기', exact: true }).click();
await composer.waitFor({ state: 'hidden' });
// Unpausing updates existing clients without a reload and preserves typed text.
await sender
.locator('.peer-card')
.filter({ hasText: '#' + b.device.code })
.click();
await composer.getByLabel('보낼 텍스트', { exact: true }).fill('작성 중인 내용은 유지');
await banFiles(false);
await composer.getByRole('button', { name: '대신 파일 선택하기', exact: true }).waitFor();
assert.equal(
await composer.getByLabel('보낼 텍스트', { exact: true }).inputValue(),
'작성 중인 내용은 유지',
);
await composer.getByRole('button', { name: '전송 화면 닫기', exact: true }).click();
const removed = (
await normal.evaluate(async () => (await (await fetch('/api/vault')).json()).items)
).find((item) => item.file?.name === 'policy-file.txt');
const adminSession = await session(admin);
await call(admin, adminSession.csrfToken, '/admin/files/' + removed.file.id, undefined, 'DELETE');
await admin.getByRole('button', { name: '용량 설정', exact: true }).click();
const capacityForm = admin.locator('.admin-settings');
assert.equal(await capacityForm.locator('input').count(), 0);
assert.match(await capacityForm.innerText(), /로그인 보관함 계정당 한도[\s\S]*2\.0GB/);
await admin.getByRole('button', { name: '수정', exact: true }).click();
const rowInputs = await admin.locator('.admin-settings-grid input').evaluateAll((inputs) =>
inputs.slice(0, 2).map((input) => ({
top: input.getBoundingClientRect().top,
height: input.getBoundingClientRect().height,
})),
);
assert.equal(rowInputs[0].top, rowInputs[1].top);
assert.equal(rowInputs[0].height, rowInputs[1].height);
await admin.screenshot({ path: 'tests/e2e/admin-capacity-edit-desktop.png', fullPage: true });
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
assert.equal(
runtime.service.admin.state.totalStorageLimitMb,
20480,
'unmodified rounded GB keeps exact stored quota',
);
assert.equal(runtime.service.admin.state.accountBytes, 2147483648);
await admin.getByRole('button', { name: '수정', exact: true }).click();
await admin.getByLabel('동시에 처리할 업로드 수', { exact: true }).fill('8');
await admin.getByRole('button', { name: '취소', exact: true }).click();
assert.equal(runtime.service.admin.state.maxConcurrentUploads, 4);
assert.equal(await capacityForm.locator('input').count(), 0);
await admin.getByRole('button', { name: '수정', exact: true }).click();
await admin.getByLabel('동시에 처리할 업로드 수', { exact: true }).fill('3');
await admin.getByLabel('전체 파일 저장 한도 (GB)', { exact: true }).fill('16');
await admin.getByLabel('로그인 보관함 계정당 한도 (GB)', { exact: true }).fill('3');
await admin.getByLabel('기기 세션당 업로드 한도 (GB)', { exact: true }).fill('4');
await admin.getByLabel('기기 전송 업로드 전체 한도 (GB)', { exact: true }).fill('12');
await admin.getByLabel('파일 한 개 한도 (GB)', { exact: true }).fill('1');
await admin.getByLabel('NAS 최소 여유 공간 (GB)', { exact: true }).fill('0');
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
await admin.getByText('관리 설정을 저장했어요.', { exact: true }).waitFor();
assert.equal(runtime.service.admin.state.maxConcurrentUploads, 3);
assert.equal(runtime.service.admin.state.totalStorageLimitMb, 16 * 1024);
for (const [key, expected] of Object.entries({
accountBytes: 3 * 1024 ** 3,
deviceBytes: 4 * 1024 ** 3,
devicesTotalBytes: 12 * 1024 ** 3,
perFileBytes: 1024 ** 3,
minDiskFreeBytes: 0,
}))
assert.equal(runtime.service.admin.state[key], expected);
assert.equal(await capacityForm.locator('input').count(), 0);
await admin.screenshot({ path: 'tests/e2e/admin-capacity-desktop.png', fullPage: true });
// Fixed precision is formatting, not an automatic save or a quota conversion.
await admin.getByRole('button', { name: '수정', exact: true }).click();
const singleFile = admin.getByLabel('파일 한 개 한도 (GB)', { exact: true });
assert.equal(await singleFile.inputValue(), '1.000');
await singleFile.fill('');
assert.equal(await singleFile.getAttribute('placeholder'), '0.000');
await singleFile.fill('15');
await singleFile.blur();
assert.equal(await singleFile.inputValue(), '15.000');
await singleFile.fill('15.125');
await singleFile.blur();
assert.equal(await singleFile.inputValue(), '15.125');
await singleFile.fill('1.2345');
assert.equal(await singleFile.evaluate((input) => input.checkValidity()), false);
const totalLimit = admin.getByLabel('전체 파일 저장 한도 (GB)', { exact: true });
await totalLimit.fill('1234567');
assert.equal(await totalLimit.evaluate((input) => input.checkValidity()), false);
const beforeReset = { ...runtime.service.admin.state };
const defaults = await admin.evaluate(
async () => (await (await fetch('/api/admin/summary')).json()).capacityDefaults,
);
await admin.getByRole('button', { name: '기본값으로 돌리기', exact: true }).click();
assert.equal(await totalLimit.inputValue(), '20.000');
assert.equal(await singleFile.inputValue(), '2.000');
assert.equal(await totalLimit.evaluate((input) => input.checkValidity()), true);
assert.deepEqual(runtime.service.admin.state, beforeReset, 'reset fills the form without saving');
await admin.getByRole('button', { name: '취소', exact: true }).click();
assert.deepEqual(runtime.service.admin.state, beforeReset, 'cancel preserves saved settings');
await admin.getByRole('button', { name: '수정', exact: true }).click();
await admin.getByRole('button', { name: '기본값으로 돌리기', exact: true }).click();
await admin.getByRole('button', { name: '설정 저장', exact: true }).click();
await admin.getByRole('button', { name: '수정', exact: true }).waitFor();
for (const [key, value] of Object.entries(defaults))
assert.equal(runtime.service.admin.state[key], value, key);
assert.equal(
runtime.service.admin.state.uploadsPaused,
false,
'capacity reset never changes file policy',
);
await admin.getByRole('button', { name: '네트워크', exact: true }).click();
assert.equal(await admin.locator('.network-auto-fields input').count(), 0);
assert.equal(await admin.getByRole('switch').count(), 3);
async function addRule(page, list, kind, value) {
await page.getByRole('button', { name: list + ' 추가', exact: true }).click();
const modal = page.getByRole('dialog', { name: list + ' 추가', exact: true });
await modal.getByLabel('등록 대상').selectOption(kind);
await modal
.getByLabel(kind === 'country' ? '국가 코드' : 'IP 주소 또는 IP 대역', { exact: true })
.fill(value);
await modal.getByRole('button', { name: '추가', exact: true }).click();
await modal.waitFor({ state: 'hidden' });
}
await addRule(admin, '블랙리스트', 'country', 'cn');
await addRule(admin, '블랙리스트', 'ip', '9.9.9.*');
assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']);
assert.deepEqual(runtime.service.admin.state.blockedIps, ['9.9.9.0/24']);
assert.equal(await admin.locator('.network-auto-fields input').count(), 0);
await admin.getByRole('button', { name: '블랙리스트 추가', exact: true }).click();
const blockedModal = admin.getByRole('dialog', { name: '블랙리스트 추가', exact: true });
await blockedModal.getByLabel('국가 코드', { exact: true }).fill('KR');
await blockedModal.getByRole('button', { name: '추가', exact: true }).click();
await admin
.locator('.error-toast')
.filter({ hasText: '현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.' })
.waitFor();
assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']);
assert.equal(await blockedModal.isVisible(), true);
await blockedModal.getByRole('button', { name: '규칙 추가 닫기', exact: true }).click();
await admin.getByRole('button', { name: '오류 닫기', exact: true }).click();
await admin.getByRole('button', { name: '자동 감지 기준 수정', exact: true }).click();
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('50');
await admin.getByRole('button', { name: '감지 기준 수정 취소', exact: true }).click();
assert.equal(runtime.service.admin.state.autoLoginFailures, 10);
assert.equal(await admin.locator('.network-auto-fields input').count(), 0);
await admin.getByRole('button', { name: '자동 감지 기준 수정', exact: true }).click();
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('5');
await addRule(admin, '화이트리스트', 'country', 'KR');
await addRule(admin, '화이트리스트', 'ip', '8.8.8.*');
await addRule(admin, '화이트리스트', 'ip', '4.4.4.4');
await admin.getByRole('checkbox', { name: '4.4.4.4 허용 규칙 선택', exact: true }).check();
await admin
.locator('section[aria-labelledby="whitelist-title"]')
.getByRole('button', { name: '선택 항목 제거', exact: true })
.click();
assert.equal(await admin.getByLabel('로그인 실패 (회)', { exact: true }).inputValue(), '5');
assert.equal(
runtime.service.admin.state.autoLoginFailures,
10,
'list operations never save numeric drafts',
);
await admin.getByRole('switch', { name: '자동 블랙리스트 사용', exact: true }).click();
await expect(
admin.getByRole('switch', { name: '자동 블랙리스트 사용', exact: true }),
).toHaveAttribute('aria-checked', 'true');
assert.equal(
runtime.service.admin.state.autoLoginFailures,
10,
'a toggle saves only its own flag',
);
await admin.getByLabel('로그인 실패 (회)', { exact: true }).fill('3');
await admin.getByRole('button', { name: '감지 기준 저장', exact: true }).click();
await admin.getByText('자동 감지 기준을 저장했어요.', { exact: true }).waitFor();
assert.equal(await admin.getByRole('switch').count(), 3);
assert.equal(runtime.service.admin.state.autoLoginFailures, 3);
assert.deepEqual(runtime.service.admin.state.blockedIps, ['9.9.9.0/24']);
assert.deepEqual(runtime.service.admin.state.allowedCountries, ['KR']);
assert.deepEqual(runtime.service.admin.state.allowedIps, ['8.8.8.0/24']);
assert.equal(runtime.service.admin.state.autoBlockEnabled, true);
const attacker = await page(390, '8.8.8.9', 'KR'),
attackSession = await session(attacker);
await attacker.evaluate(async (csrf) => {
for (let i = 0; i < 3; i++)
await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ email: 'missing@hop.test', password: 'incorrect' }),
});
}, attackSession.csrfToken);
await admin.getByRole('button', { name: '자동 차단 목록 새로고침', exact: true }).click();
await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).waitFor();
const networkSections = await admin.locator('.network-sections > section').evaluateAll((items) =>
items.map((item) => {
const { top, bottom } = item.getBoundingClientRect();
return { top, bottom };
}),
);
for (let index = 1; index < networkSections.length; index++)
assert.ok(networkSections[index].top - networkSections[index - 1].bottom >= 20);
await admin.screenshot({ path: 'tests/e2e/network-desktop.png', fullPage: true });
await admin.getByRole('checkbox', { name: '8.8.8.9 자동 차단 선택', exact: true }).check();
await admin.getByRole('button', { name: '선택 차단 해제', exact: true }).click();
await admin.getByText('선택한 IP의 자동 차단을 해제했어요.', { exact: true }).waitFor();
assert.equal(await attacker.evaluate(async () => (await fetch('/api/device/me')).status), 200);
assert.deepEqual(runtime.service.admin.state.blockedCountries, ['CN']);
assert.equal(await sender.evaluate(async () => (await fetch('/api/device/me')).status), 403);
await admin.getByRole('button', { name: '저장 파일', exact: true }).click();
await admin.screenshot({ path: 'tests/e2e/admin-desktop.png', fullPage: true });
await admin.getByRole('button', { name: '원본 삭제', exact: true }).click();
await admin.getByRole('alertdialog').waitFor();
await admin.getByRole('button', { name: '취소', exact: true }).click();
assert.equal(await admin.locator('.admin-file').count(), 1);
await admin.getByRole('button', { name: '원본 삭제', exact: true }).click();
await admin.getByRole('button', { name: '원본 삭제 확인', exact: true }).click();
await admin.getByText('저장된 파일이 없습니다.', { exact: true }).waitFor();
assert.equal(runtime.service.admin.summary().storedBytes, 0);
const normalSession = await session(normal);
const owned = await call(normal, normalSession.csrfToken, '/vault', {
kind: 'file',
files: [{ name: 'account-owner.txt', size: 4, type: 'text/plain' }],
});
await uploadFile(
normal,
normalSession.csrfToken,
owned.requestId,
'account-owner.txt',
'text/plain',
);
await admin.getByRole('button', { name: '새로고침', exact: true }).click();
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*·\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();
await admin.getByText('원본 파일 삭제', { exact: true }).first().waitFor();
const changedRow = admin
.locator('.admin-audit article')
.filter({ hasText: '동시에 처리할 업로드 수 4 > 3' });
await changedRow.waitFor();
await changedRow.getByRole('checkbox').check();
await admin.getByRole('button', { name: '선택 기록 삭제', exact: true }).click();
await admin.getByText('선택한 관리 기록을 삭제했어요.', { exact: true }).waitFor();
assert.equal(await changedRow.count(), 0);
await admin.getByRole('button', { name: '사용자', exact: true }).click();
await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).waitFor();
assert.equal(await admin.getByRole('menuitem', { name: '계정 삭제', exact: true }).count(), 0);
await admin.screenshot({ path: 'tests/e2e/admin-users.png', fullPage: true });
await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).click();
await admin.getByRole('menuitem', { name: '비밀번호 재설정 메일 보내기', exact: true }).click();
await admin
.getByRole('alertdialog')
.getByRole('button', { name: '메일 보내기', exact: true })
.click();
await admin.getByText('비밀번호 재설정 메일을 보냈어요.', { exact: true }).waitFor();
await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).click();
await admin.getByRole('menuitem', { name: '계정 삭제', exact: true }).click();
const deletion = admin.getByRole('alertdialog', { name: '계정을 삭제할까요?', exact: true });
assert.equal(
await deletion.getByRole('button', { name: '계정 삭제 확인', exact: true }).isDisabled(),
true,
);
await deletion.getByLabel('확인을 위해 대상 이메일 입력', { exact: true }).fill('other@hop.test');
await deletion.getByRole('button', { name: '계정 삭제 확인', exact: true }).click();
await admin.getByText('계정을 삭제했어요.', { exact: true }).waitFor();
assert.equal(
await admin.getByRole('button', { name: 'other@hop.test 더보기', exact: true }).count(),
0,
);
await admin.getByRole('button', { name: '관리 기록', exact: true }).click();
await admin.getByText('사용자 계정 삭제', { exact: true }).waitFor();
await admin.getByText('비밀번호 재설정 메일 발송', { exact: true }).waitFor();
await admin.getByRole('button', { name: '전체 기록 삭제', exact: true }).click();
await admin
.getByRole('alertdialog')
.getByRole('button', { name: '전체 삭제 확인', exact: true })
.click();
await admin.getByText('관리 기록이 없습니다.', { exact: true }).waitFor();
const mobile = await page(390);
await login(mobile, 'hello@noname.company');
await mobile.getByRole('button', { name: '더보기', exact: true }).click();
await mobile.getByRole('menuitem', { name: '관리자', exact: true }).click();
await mobile.locator('.admin-stats').waitFor();
await mobile.getByRole('button', { name: '용량 설정', exact: true }).click();
assert.equal(
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
assert.equal(await mobile.locator('.admin-settings input').count(), 0);
await mobile.getByRole('button', { name: '수정', exact: true }).click();
assert.equal(
await mobile.getByLabel('파일 한 개 한도 (GB)', { exact: true }).inputValue(),
'2.000',
);
assert.equal(
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
await mobile.screenshot({ path: 'tests/e2e/admin-capacity-mobile.png', fullPage: true });
await mobile.getByRole('button', { name: '취소', exact: true }).click();
await mobile.getByRole('button', { name: '네트워크', exact: true }).click();
assert.equal(
await mobile.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
assert.equal(await mobile.getByRole('switch').count(), 3);
await mobile.getByRole('button', { name: '블랙리스트 추가', exact: true }).click();
const ruleModal = mobile.getByRole('dialog', { name: '블랙리스트 추가', exact: true });
assert.equal(
await ruleModal
.getByLabel('국가 코드', { exact: true })
.evaluate((element) => parseFloat(getComputedStyle(element).fontSize) >= 16),
true,
);
await ruleModal.getByLabel('국가 코드', { exact: true }).fill('JP');
await mobile.screenshot({ path: 'tests/e2e/network-rule-mobile.png', fullPage: true });
await ruleModal.getByRole('button', { name: '규칙 추가 닫기' }).click();
await mobile.screenshot({ path: 'tests/e2e/admin-mobile.png', fullPage: true });
await mobile.getByRole('button', { name: '메인으로', exact: true }).click();
await mobile.locator('.nearby-section').waitFor();
await mobile.locator('.login-button').click();
await mobile.getByRole('button', { name: '로그아웃', exact: true }).click();
await mobile.locator('.nearby-section').waitFor();
await mobile.getByRole('button', { name: '더보기', exact: true }).click();
assert.equal(await mobile.getByRole('menuitem', { name: '관리자', exact: true }).count(), 0);
assert.deepEqual(errors, []);
console.log(
'PASS: administrator authorization, live file-only ban, 1024-based GB and three-decimal input, explicit default restore/save/cancel, immediate network rules/toggles independent of numeric drafts, scoped detection editor, self-block rejection, section spacing, audit and user support, desktop/mobile layouts and logout.',
);
} finally {
await browser?.close();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
}