Add full IKAR administration console with secure session controls
Tests / test (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"""Private cookie/CSRF client for IKAR deployment checks. Never logs credentials."""
|
||||
import json
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
class AdminHttp:
|
||||
def __init__(self, base='http://127.0.0.1:5099'):
|
||||
self.base = base.rstrip('/')
|
||||
self.cookies = {}
|
||||
self.csrf = None
|
||||
|
||||
def request(self, path, method='GET', payload=None, reason=None):
|
||||
headers = {'Accept': 'application/json', 'X-Forwarded-Proto': 'https'}
|
||||
if self.cookies: headers['Cookie'] = '; '.join(key + '=' + value for key, value in self.cookies.items())
|
||||
if method not in ('GET', 'HEAD') and self.csrf: headers['X-Ikar-CSRF'] = self.csrf
|
||||
if reason: headers['X-Admin-Reason'] = quote(reason)
|
||||
data = None if payload is None else json.dumps(payload).encode()
|
||||
if data is not None: headers['Content-Type'] = 'application/json'
|
||||
with urlopen(Request(self.base + path, data=data, method=method, headers=headers), timeout=30) as response:
|
||||
for value in response.headers.get_all('Set-Cookie', []):
|
||||
cookie = SimpleCookie(); cookie.load(value)
|
||||
for key, morsel in cookie.items():
|
||||
if morsel['max-age'] == '0' or not morsel.value: self.cookies.pop(key, None)
|
||||
else: self.cookies[key] = morsel.value
|
||||
body = response.read()
|
||||
return response.status, json.loads(body) if body else None
|
||||
|
||||
def login(self, settings):
|
||||
_, state = self.request('/admin/api/session'); self.csrf = state['csrfToken']
|
||||
status, _ = self.request('/admin/api/session/login', 'POST', {'username': settings['Admin__Username'], 'password': settings['Admin__Password']})
|
||||
if status != 204: raise RuntimeError('Admin login failed')
|
||||
_, state = self.request('/admin/api/session'); self.csrf = state['csrfToken']
|
||||
if not state['authenticated']: raise RuntimeError('Admin cookie authentication failed')
|
||||
|
||||
def logout(self):
|
||||
self.request('/admin/api/session/logout', 'POST')
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Delete only the reserved identities created by this release's tests."""
|
||||
from pathlib import Path
|
||||
import json,sqlite3,urllib.request,uuid
|
||||
from admin_http import AdminHttp
|
||||
root=Path('/mnt/data/ikar_server')
|
||||
with sqlite3.connect(f'file:{root / "shared/data/ikar.db"}?mode=ro',uri=True) as db:
|
||||
candidates=db.execute('select Id,NormalizedPhoneNumber,DisplayName from Users where IsBot=0 and IsBlocked=0').fetchall()
|
||||
@@ -9,12 +10,10 @@ with sqlite3.connect(f'file:{root / "shared/data/ikar.db"}?mode=ro',uri=True) as
|
||||
ids=[user_id for user_id,phone,name in candidates if phone=='2025550101' and name=='IKAR']
|
||||
if ids:
|
||||
settings=dict(line.split('=',1) for line in (root/'shared/server.env').read_text().splitlines() if '=' in line)
|
||||
body=json.dumps({'username':settings['Admin__Username'],'password':settings['Admin__Password']}).encode()
|
||||
request=urllib.request.Request('http://127.0.0.1:5099/admin/api/session/login',data=body,headers={'Content-Type':'application/json'})
|
||||
with urllib.request.urlopen(request,timeout=15) as response:
|
||||
cookies='; '.join(value.split(';',1)[0] for value in response.headers.get_all('Set-Cookie',[]))
|
||||
assert cookies and response.status==204
|
||||
for user_id in ids:
|
||||
request=urllib.request.Request(f'http://127.0.0.1:5099/admin/api/users/{uuid.UUID(user_id)}',method='DELETE',headers={'Cookie':cookies})
|
||||
with urllib.request.urlopen(request,timeout=15) as response: assert response.status==204
|
||||
admin=AdminHttp(); admin.login(settings)
|
||||
try:
|
||||
for user_id in ids:
|
||||
status,_=admin.request(f'/admin/api/users/{uuid.UUID(user_id)}','DELETE',reason='Release smoke-test identity cleanup')
|
||||
assert status==204
|
||||
finally: admin.logout()
|
||||
print(json.dumps({'reservedTestAccountsDeleted':len(ids)}))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Deploy the complete tested IKAR API build, preserving other containers."""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -55,7 +56,21 @@ text = compose.read_text()
|
||||
old_image = f'image: ikar_server:{previous_name}'
|
||||
if text.count(old_image) != 1:
|
||||
raise RuntimeError('Unexpected API image in compose')
|
||||
compose.write_text(text.replace(old_image, f'image: ikar_server:{release_name}', 1))
|
||||
text = text.replace(old_image, f'image: ikar_server:{release_name}', 1)
|
||||
# The loopback-bound Docker port reaches ASP.NET from the bridge gateway.
|
||||
# Trust that exact address so Caddy's HTTPS/IP headers work without trusting all peers.
|
||||
inspection = json.loads(subprocess.check_output(['docker', 'inspect', 'ikar_server'], text=True))[0]
|
||||
gateways = [str(ipaddress.ip_address(item['Gateway'])) for item in inspection['NetworkSettings']['Networks'].values() if item.get('Gateway')]
|
||||
if len(gateways) != 1:
|
||||
raise RuntimeError('Expected exactly one API bridge gateway')
|
||||
setting = ' ReverseProxy__KnownProxies:'
|
||||
if setting in text:
|
||||
text = re.sub(r'(?m)^ ReverseProxy__KnownProxies:.*$', f'{setting} "{gateways[0]}"', text)
|
||||
else:
|
||||
marker = ' IkarUpdates__LocalOnly: "true"'
|
||||
if text.count(marker) != 1: raise RuntimeError('Unexpected API environment section')
|
||||
text = text.replace(marker, marker + f'\n{setting} "{gateways[0]}"', 1)
|
||||
compose.write_text(text)
|
||||
print('Building IKAR API image', flush=True)
|
||||
with (root / f'build-{release_name}.log').open('w') as log:
|
||||
subprocess.run(['docker', 'build', '-f', 'Dockerfile.release', '-t', f'ikar_server:{release_name}', '.'],
|
||||
@@ -89,6 +104,11 @@ try:
|
||||
if integrity != 'ok' or 'PublicLastSeenUnixMs' not in columns:
|
||||
raise RuntimeError('Database or presence schema verification failed')
|
||||
subprocess.run(['docker', 'exec', 'ikar_server', 'ffmpeg', '-version'], stdout=subprocess.DEVNULL, check=True)
|
||||
request = urllib.request.Request('http://127.0.0.1:5099/admin/api/session', headers={'X-Forwarded-Proto': 'https'})
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
admin_state = json.load(response)
|
||||
if not admin_state.get('configured') or not admin_state.get('csrfToken'):
|
||||
raise RuntimeError('Production admin login or HTTPS proxy configuration failed')
|
||||
pending = root / f'current.next-{release_name}'
|
||||
pending.symlink_to(release, target_is_directory=True)
|
||||
os.replace(pending, current)
|
||||
@@ -102,6 +122,7 @@ report = {
|
||||
'backup': str(backup), 'backupIntegrity': 'ok', 'health': health,
|
||||
'databaseIntegrity': integrity, 'publicPresenceColumn': True,
|
||||
'serverSha256': running_sha, 'ffmpegAvailable': True,
|
||||
'adminLoginConfigured': True, 'adminCsrfAvailable': True, 'trustedProxy': gateways[0],
|
||||
'protectedServicesUnchanged': snapshot() == protected_before,
|
||||
'protectedServices': protected_before,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Seed the isolated local admin UI test database; never accepts a production path."""
|
||||
import json, sqlite3, uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
server = json.loads((repo / 'artifacts/admin-local-server.json').read_text(encoding='utf-8-sig'))
|
||||
root = Path(server['root']).resolve()
|
||||
if not root.name.startswith('ikar-admin-ui-') or server['url'] != 'http://127.0.0.1:15199':
|
||||
raise RuntimeError('Only the isolated loopback fixture is allowed')
|
||||
db = sqlite3.connect(root / 'Data/ikar.db')
|
||||
db.execute('PRAGMA foreign_keys=ON')
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
def row(table, values):
|
||||
for _, name, kind, required, default, primary in db.execute(f'PRAGMA table_info("{table}")'):
|
||||
if name not in values and required and default is None and not primary:
|
||||
values[name] = 0 if kind == 'INTEGER' else now if name.endswith(('At', 'Until')) else ''
|
||||
fields = ','.join('"' + key + '"' for key in values)
|
||||
db.execute(f'INSERT INTO "{table}" ({fields}) VALUES ({",".join("?" for _ in values)})', list(values.values()))
|
||||
def user(name, bot=False):
|
||||
identifier = str(uuid.uuid4()).upper()
|
||||
row('Users', {'Id': identifier, 'Username': name, 'NormalizedUsername': name.upper(), 'DisplayName': name, 'IsBot': int(bot)})
|
||||
return identifier
|
||||
owner = user('UI пользователь <script>alert(1)</script>')
|
||||
botuser = user('ui-test-bot', True)
|
||||
for number in range(29): user(f'UI пользователь {number:02}')
|
||||
session = str(uuid.uuid4()).upper()
|
||||
row('UserSessions', {'Id': session, 'UserId': owner, 'TokenHash': 'test-fixture-secret', 'ClientPlatform': 'android', 'ClientVersionCode': 5, 'ClientVersionName': '1.0.4'})
|
||||
chat = str(uuid.uuid4()).upper()
|
||||
row('Chats', {'Id': chat, 'Type': 2, 'Title': 'UI группа', 'CreatedById': owner, 'ReactionSettingsJson': '{}'})
|
||||
row('ChatMembers', {'ChatId': chat, 'UserId': owner, 'IsOwner': 1, 'Role': 2})
|
||||
message = str(uuid.uuid4()).upper()
|
||||
row('Messages', {'Id': message, 'ChatId': chat, 'SenderId': owner, 'Text': 'Сообщение для проверки модерации'})
|
||||
bot = str(uuid.uuid4()).upper()
|
||||
row('Bots', {'Id': bot, 'UserId': botuser, 'OwnerUserId': owner, 'TokenHash': 'not-exposed-test-secret', 'IsEnabled': 1})
|
||||
row('ModerationCases', {'SourceLogId': 100001, 'ReporterId': owner, 'ReporterName': 'UI заявитель', 'TargetKind': 'message', 'TargetId': message, 'ChatId': chat, 'PayloadJson': '{"reasonId":1,"typeId":4,"ids":[1]}', 'Status': 'open', 'CreatedUnixMs': int(datetime.now(timezone.utc).timestamp()*1000)})
|
||||
db.commit(); db.close()
|
||||
(repo / 'artifacts/admin-ui-fixture.json').write_text(json.dumps({'userId': owner.lower(), 'chatId': chat.lower(), 'messageId': message.lower(), 'botId': bot.lower()}))
|
||||
print(json.dumps({'seeded': True, 'users': 32, 'reports': 1}))
|
||||
@@ -0,0 +1,27 @@
|
||||
param([int]$Port = 15199)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$taskRepo = Split-Path $PSScriptRoot -Parent
|
||||
$taskRunDir = Join-Path ([IO.Path]::GetTempPath()) ('ikar-admin-ui-' + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $taskRunDir | Out-Null
|
||||
Copy-Item -LiteralPath "$taskRepo/ikar_server/src/Ikar.Server/wwwroot" -Destination $taskRunDir -Recurse
|
||||
Copy-Item -LiteralPath "$taskRepo/ikar_server/src/Ikar.Server/bin/Debug/net10.0" -Destination "$taskRunDir/runtime" -Recurse
|
||||
$taskEnvironment = @{
|
||||
ASPNETCORE_ENVIRONMENT = 'Development'
|
||||
ASPNETCORE_URLS = "http://127.0.0.1:$Port"
|
||||
SeedDemoData = 'false'
|
||||
ConnectionStrings__IkarDb = "Data Source=$taskRunDir/Data/ikar.db;Pooling=False"
|
||||
Admin__Username = 'admin-test'
|
||||
Admin__Password = 'test-only-password'
|
||||
IkarUpdates__LocalOnly = 'true'
|
||||
Logging__LogLevel__Default = 'Warning'
|
||||
}
|
||||
$taskProcess = Start-Process -FilePath dotnet -ArgumentList @("$taskRunDir/runtime/Ikar.Server.dll") -WorkingDirectory $taskRunDir -WindowStyle Hidden -RedirectStandardOutput "$taskRunDir/server.stdout.log" -RedirectStandardError "$taskRunDir/server.stderr.log" -Environment $taskEnvironment -PassThru
|
||||
$taskReady = $false
|
||||
for ($taskAttempt = 0; $taskAttempt -lt 30; $taskAttempt++) {
|
||||
if ($taskProcess.HasExited) { throw "Test server exited; inspect $taskRunDir/server.stderr.log" }
|
||||
try { $taskHealth = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 2; if ($taskHealth.status -eq 'ok') { $taskReady = $true; break } } catch { }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if (!$taskReady) { Stop-Process -Id $taskProcess.Id; throw 'Test server did not become healthy' }
|
||||
@{pid=$taskProcess.Id;root=$taskRunDir;url="http://127.0.0.1:$Port"} | ConvertTo-Json | Set-Content "$taskRepo/artifacts/admin-local-server.json"
|
||||
Get-Content "$taskRepo/artifacts/admin-local-server.json"
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Authenticate privately on the Pi and verify the deployed administration API."""
|
||||
import json, sqlite3
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
from admin_http import AdminHttp
|
||||
|
||||
root = Path('/mnt/data/ikar_server')
|
||||
settings = dict(line.split('=', 1) for line in (root/'shared/server.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
|
||||
base = 'http://127.0.0.1:5099'
|
||||
for route in ('users/list', 'reports', 'bots', 'audit', 'operations', 'admin-sessions'):
|
||||
try:
|
||||
urlopen(base + '/admin/api/' + route, timeout=15)
|
||||
raise RuntimeError('Unauthenticated admin access is possible')
|
||||
except HTTPError as error:
|
||||
if error.code != 401: raise
|
||||
client = AdminHttp(base)
|
||||
client.login(settings)
|
||||
try:
|
||||
endpoints = {}
|
||||
for route in ('status', 'users/list', 'chats/list', 'attachments/list', 'reports', 'bots', 'audit', 'admin-sessions', 'operations', 'releases', 'push-queue', 'app-updates/compatibility-report'):
|
||||
code, data = client.request('/admin/api/' + route)
|
||||
if code != 200: raise RuntimeError('Admin endpoint failed: ' + route)
|
||||
endpoints[route] = code
|
||||
if route == 'admin-sessions' and not any(item['current'] for item in data): raise RuntimeError('Current admin session is missing')
|
||||
if route == 'operations':
|
||||
if not data['dataProtectionPersistent'] or not data['backups']: raise RuntimeError('Persistent keys or backups are missing')
|
||||
operations = {key: data[key] for key in ('diskFreeBytes', 'diskTotalBytes', 'openReports', 'queuedPush')}
|
||||
if route == 'releases': release = data['release']
|
||||
# A missing CSRF token must fail without terminating the valid session.
|
||||
cookies = '; '.join(key + '=' + value for key, value in client.cookies.items())
|
||||
try:
|
||||
urlopen(Request(base + '/admin/api/session/logout', method='POST', headers={'Cookie': cookies, 'X-Forwarded-Proto': 'https'}), timeout=15)
|
||||
raise RuntimeError('A mutation without CSRF was accepted')
|
||||
except HTTPError as error:
|
||||
if error.code != 400: raise
|
||||
if not client.request('/admin/api/session')[1]['authenticated']: raise RuntimeError('CSRF check damaged valid session')
|
||||
finally:
|
||||
client.logout()
|
||||
with sqlite3.connect(f'file:{root}/shared/data/ikar.db?mode=ro', uri=True) as db:
|
||||
if db.execute('PRAGMA integrity_check').fetchone()[0] != 'ok': raise RuntimeError('Database integrity failed')
|
||||
source = db.execute("SELECT COUNT(*) FROM ModerationLogEntries WHERE Action='complaint.submitted'").fetchone()[0]
|
||||
cases = db.execute('SELECT COUNT(*) FROM ModerationCases').fetchone()[0]
|
||||
if cases < source: raise RuntimeError('Complaint migration is incomplete')
|
||||
report = {'passed': True, 'authenticatedEndpoints': endpoints, 'anonymousDenied': True, 'csrfRequired': True, 'logout': True,
|
||||
'databaseIntegrity': 'ok', 'complaintsMigrated': cases, 'operations': operations,
|
||||
'androidRelease': {'versionName': release['versionName'], 'versionCode': release['versionCode'], 'sha256': release['sha256']}}
|
||||
(root/'admin-console-verification.json').write_text(json.dumps(report, indent=2))
|
||||
print(json.dumps(report))
|
||||
@@ -0,0 +1,32 @@
|
||||
// Authenticated read-only browser verification; credentials stay in the ignored local file.
|
||||
const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict');
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const repo = path.resolve(__dirname, '..');
|
||||
const account = JSON.parse(fs.readFileSync(path.join(repo, '.secrets/ikar-admin-access.json'), 'utf8').replace(/^\uFEFF/, ''));
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless: true, executablePath: process.env.ADMIN_BROWSER || 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', args: ['--host-resolver-rules=MAP ikar.kusoft.xyz 195.216.241.165']});
|
||||
try {
|
||||
const context = await browser.newContext({viewport: {width: 1440, height: 1000}}); const page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
const errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(account.url);
|
||||
await page.locator('#loginUsername').fill(account.username); await page.locator('#loginPassword').fill(account.password);
|
||||
await page.locator('#loginForm button').click(); await page.waitForFunction(() => document.querySelector('#usersCount').textContent.includes('пользователей'));
|
||||
assert.equal(await page.locator('#adminIdentity').textContent(), account.username);
|
||||
const cookie = (await context.cookies()).find(x => x.name === 'Ikar.Admin');
|
||||
assert.ok(cookie && cookie.secure && cookie.httpOnly && cookie.sameSite === 'Strict');
|
||||
await page.screenshot({path: path.join(repo, 'artifacts/admin-live-overview.png'), fullPage: true});
|
||||
const sections = ['users', 'chats', 'media', 'reports', 'bots', 'moderation', 'audit', 'security', 'updates', 'operations'];
|
||||
for (const section of sections) {
|
||||
await page.locator(`.nav-item[data-section=${section}]`).click();
|
||||
await page.waitForFunction(section => document.querySelector(`.section[data-panel=${section}]`).classList.contains('active'), section);
|
||||
if (section === 'security') await page.waitForFunction(() => document.querySelector('#securityContent').textContent.includes('Эта сессия'));
|
||||
if (section === 'updates') await page.waitForFunction(() => document.querySelector('#releasesContent').textContent.includes('1.0.4'));
|
||||
if (section === 'operations') await page.waitForFunction(() => document.querySelector('#operationsDetails').textContent.includes('Свободно на диске'));
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
await page.locator('#logoutButton').click(); await page.locator('#loginForm').waitFor({state: 'visible'});
|
||||
const result = {passed: true, publicUrl: account.url, authenticatedBrowser: true, secureCookie: true, sections, browserErrors: errors, logout: true};
|
||||
fs.writeFileSync(path.join(repo, 'artifacts/admin-public-ui-verification.json'), JSON.stringify(result, null, 2)); console.log(JSON.stringify(result));
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
@@ -0,0 +1,74 @@
|
||||
// Run against the isolated fixture from start_admin_test_server.ps1, never production.
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const repo = path.resolve(__dirname, '..');
|
||||
const run = JSON.parse(fs.readFileSync(path.join(repo, 'artifacts/admin-local-server.json'), 'utf8').replace(/^\uFEFF/, ''));
|
||||
const fixture = JSON.parse(fs.readFileSync(path.join(repo, 'artifacts/admin-ui-fixture.json'), 'utf8'));
|
||||
if (run.url !== 'http://127.0.0.1:15199') throw new Error('Only the loopback fixture is allowed');
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless: true, executablePath: process.env.ADMIN_BROWSER || 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'});
|
||||
try {
|
||||
const page = await browser.newPage({viewport: {width: 1440, height: 1000}});
|
||||
page.setDefaultTimeout(15000);
|
||||
const errors = []; page.on('pageerror', e => errors.push(e.message));
|
||||
page.on('dialog', d => d.accept());
|
||||
await page.goto(run.url + '/admin/');
|
||||
await page.locator('#loginUsername').fill('admin-test');
|
||||
await page.locator('#loginPassword').fill('test-only-password');
|
||||
await page.locator('#loginForm button').click();
|
||||
await page.waitForFunction(() => document.querySelector('#usersCount').textContent.includes('пользователей'));
|
||||
assert.equal(await page.locator('#adminIdentity').textContent(), 'admin-test');
|
||||
await page.screenshot({path: path.join(repo, 'artifacts/admin-overview.png'), fullPage: true});
|
||||
await page.locator('.nav-item[data-section=users]').click();
|
||||
await page.locator('#usersPager [data-page="2"]').click();
|
||||
await page.waitForFunction(() => document.querySelector('#usersPager').textContent.includes('Страница 2'));
|
||||
await page.locator('#globalSearch').fill('UI пользователь <script>');
|
||||
await page.waitForFunction(() => document.querySelectorAll('#usersTable tr').length === 1);
|
||||
assert.equal(await page.locator('#usersTable script').count(), 0);
|
||||
await page.locator(`[data-action=console-user][data-id="${fixture.userId}"]`).click();
|
||||
await page.locator('#consoleDialog input[name=displayName]').fill('Имя после редактирования');
|
||||
await page.locator('form[data-console-form=user] button').click();
|
||||
await page.waitForFunction(() => document.querySelector('#dialogTitle').textContent.startsWith('Имя после редактирования'));
|
||||
await page.locator('[data-action=console-revoke-all]').click();
|
||||
await page.waitForFunction(() => !document.querySelector('[data-action=console-revoke-session]'));
|
||||
await page.locator('[data-action=console-close]').click();
|
||||
await page.locator('#globalSearch').fill('');
|
||||
await page.locator('.nav-item[data-section=reports]').click();
|
||||
await page.locator('[data-action=console-report]').click();
|
||||
await page.locator('form[data-console-form=report] textarea').fill('Спам подтверждён, сообщение скрыто');
|
||||
await page.locator('form[data-console-form=report] select[name=action]').selectOption('delete-message');
|
||||
await page.locator('form[data-console-form=report] button').click();
|
||||
await page.waitForFunction(() => !document.querySelector('#consoleDialog').open);
|
||||
await page.locator('#reportStatus').selectOption('resolved');
|
||||
await page.locator('[data-action=console-report]').waitFor();
|
||||
await page.locator('.nav-item[data-section=bots]').click();
|
||||
await page.locator(`[data-action=console-bot][data-id="${fixture.botId}"]`).click();
|
||||
await page.locator('form[data-console-form=bot] input[name=enabled]').uncheck();
|
||||
await page.locator('form[data-console-form=bot] button').click();
|
||||
await page.waitForFunction(() => document.querySelector('#botsContent').textContent.includes('Отключён'));
|
||||
await page.locator('.nav-item[data-section=chats]').click();
|
||||
await page.locator(`[data-action=open-chat][data-chat-id="${fixture.chatId}"]`).click();
|
||||
await page.locator('[data-action=console-messages]').click();
|
||||
await page.locator(`[data-action=console-message][data-id="${fixture.messageId}"][data-mode=restore]`).click();
|
||||
await page.locator(`[data-action=console-message][data-id="${fixture.messageId}"][data-mode=hide]`).waitFor();
|
||||
await page.locator('[data-action=console-close]').click();
|
||||
for (const section of ['media', 'moderation', 'audit', 'security', 'updates', 'operations']) {
|
||||
await page.locator(`.nav-item[data-section=${section}]`).click();
|
||||
if (section === 'audit') await page.waitForFunction(() => document.querySelector('#auditContent').textContent.includes('admin-test'));
|
||||
if (section === 'security') await page.waitForFunction(() => document.querySelector('#securityContent').textContent.includes('Эта сессия'));
|
||||
if (section === 'operations') await page.waitForFunction(() => document.querySelector('#operationsDetails').textContent.includes('Свободно на диске'));
|
||||
}
|
||||
await page.locator('.nav-item[data-section=overview]').click();
|
||||
await page.setViewportSize({width: 390, height: 844});
|
||||
await page.screenshot({path: path.join(repo, 'artifacts/admin-mobile.png'), fullPage: true});
|
||||
const width = await page.evaluate(() => ({viewport: innerWidth, content: document.documentElement.scrollWidth}));
|
||||
assert.ok(width.content <= width.viewport + 1, `Mobile overflow: ${JSON.stringify(width)}`);
|
||||
await page.locator('#logoutButton').click(); await page.locator('#loginForm').waitFor({state: 'visible'});
|
||||
assert.deepEqual(errors, []);
|
||||
const result = {passed: true, login: true, pagination: true, escapedHtml: true, profileEdit: true, sessionRevocation: true, reportResolution: true, botSettings: true, messageRestore: true, allSections: true, responsive: width, browserErrors: errors, logout: true};
|
||||
fs.writeFileSync(path.join(repo, 'artifacts/admin-ui-verification.json'), JSON.stringify(result, null, 2));
|
||||
console.log(JSON.stringify(result));
|
||||
} finally { await browser.close(); }
|
||||
})().catch(e => { console.error(e); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user