617 lines
31 KiB
JavaScript
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 });
|
|
}
|