chore(test): проверить ограниченное восстановление из хранилища через интерфейс

This commit is contained in:
Denozordec committed 2026-10-14 14:41:26 +07:00
1 parent 0216415953
commit 4b028be241
10 files changed
+311 -9

No files matched your search

@@ -0,0 +1,66 @@
const fs = require('fs'), assert = require('node:assert/strict'), { createHash } = require('crypto');
const { chromium, expect } = require('@playwright/test');
(async () => {
const phase = process.argv[2], portal = JSON.parse(fs.readFileSync('tmp/portal195-private.json'));
const state = JSON.parse(fs.readFileSync('tmp/state239-public.json'));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
try {
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }), errors = [], receipts = [];
page.on('pageerror', e => errors.push(e.message));
page.on('response', r => { if (new URL(r.url()).pathname.startsWith('/api/backups') && r.request().method() !== 'GET') receipts.push({ method: r.request().method(), path: new URL(r.url()).pathname, status: r.status() }); });
await page.goto('http://localhost:3000/backups');
await page.getByLabel('Email', { exact: true }).fill(portal.email);
await page.getByLabel('Пароль', { exact: true }).fill(portal.password);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Резервные копии', exact: true })).toBeVisible({ timeout: 30000 });
if (phase === 'configure') {
const s = JSON.parse(fs.readFileSync('tmp/s3-ui239-private.json'));
const paused = await page.evaluate(async () => {
const r = await fetch('/api/backups/schedule', { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + localStorage.getItem('mmapp_token') }, body: JSON.stringify({ enabled: false }) });
return { status: r.status, body: await r.json() };
});
assert.equal(paused.status, 200); assert.equal(paused.body.enabled, false);
await page.getByRole('tab', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'S3', exact: true }).click();
for (const [label, value] of Object.entries({ Endpoint: s.endpoint, Region: s.region, Bucket: s.bucket, Prefix: state.prefix, 'Access key': s.accessKey, 'Secret key': s.secretKey })) await page.getByLabel(label, { exact: true }).fill(value);
const path = page.getByRole('switch', { name: 'Path-style', exact: true });
if (await path.getAttribute('aria-checked') === 'true') await path.click();
const saved = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/storage' && r.request().method() === 'PUT');
await page.getByRole('button', { name: 'Сохранить хранилище', exact: true }).click();
assert.equal((await saved).status(), 200);
await expect(page.getByLabel('Secret key', { exact: true })).toHaveValue('');
const tested = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/storage/test' && r.request().method() === 'POST');
await page.getByRole('button', { name: 'Проверить', exact: true }).click();
assert.equal((await tested).status(), 200);
} else {
await page.getByRole('button', { name: 'Обновить', exact: true }).click();
await expect(page.getByText(state.filename, { exact: true })).toBeVisible();
if (phase === 'restore') {
const download = page.waitForEvent('download');
await page.getByRole('button', { name: 'Скачать ' + state.filename, exact: true }).click();
const d = await download; assert.equal(d.suggestedFilename(), state.filename);
const bytes = fs.readFileSync(await d.path());
const expected = Buffer.from(`# Own limited UI restore 239\n/interface bridge add name=${state.bridge} comment=mm-ui239\n`);
assert.deepEqual(bytes, expected);
await page.getByRole('button', { name: 'Восстановить ' + state.filename, exact: true }).click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toContainText(state.filename);
const restored = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/' + state.id + '/restore' && r.request().method() === 'POST');
await dialog.getByRole('button', { name: 'Восстановить', exact: true }).click();
const r = await restored; assert.equal(r.status(), 200);
const result = await r.json(); assert.equal(result.backupId, state.id); assert.equal(result.serverId, state.serverId);
await expect(dialog).toBeHidden();
fs.writeFileSync('docs/ux-ui/acceptance/s3-ui239-browser.json', JSON.stringify({ date: '2026-10-14', block: '12.5.239', realPortalLogin: true, noMocks: true, passed: true, downloadBytes: bytes.length, downloadSha256: createHash('sha256').update(bytes).digest('hex'), restoreStatus: 200, restoreReceiptMatched: true, receipts, pageErrors: errors, fullConfigurationApplied: false }, null, 2) + '\n');
} else if (phase === 'delete') {
await page.getByRole('button', { name: 'Удалить ' + state.filename, exact: true }).click();
const deleted = page.waitForResponse(r => new URL(r.url()).pathname === '/api/backups/' + state.id && r.request().method() === 'DELETE');
await page.getByRole('alertdialog').getByRole('button', { name: 'Удалить', exact: true }).click();
assert.equal((await deleted).status(), 204); await expect(page.getByRole('alertdialog')).toBeHidden();
await expect(page.getByText(state.filename, { exact: true })).toHaveCount(0);
const p = 'docs/ux-ui/acceptance/s3-ui239-browser.json', report = JSON.parse(fs.readFileSync(p));
Object.assign(report, { deleteStatus: 204, ownRowRemovedThroughUi: true, deleteReceipts: receipts }); fs.writeFileSync(p, JSON.stringify(report, null, 2) + '\n');
} else throw Error('Unsupported phase');
}
assert.deepEqual(errors, []); console.log('S3 UI239 ' + phase + ' passed');
} finally { await browser.close(); }
})().catch(e => { console.error(e.message); process.exitCode = 1; });
@@ -0,0 +1,89 @@
import fs from 'node:fs';
import assert from 'node:assert/strict';
import { randomUUID, createHash } from 'node:crypto';
import { createRequire } from 'node:module';
const require = createRequire('/app/backend/package.json');
const { ListObjectVersionsCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { pool } = await import('/app/backend/dist/db/index.js');
const { MikrotikClient } = await import('/app/backend/dist/services/mikrotik.js');
const { createS3ClientFromConfig, s3PutObject, s3GetObject } = await import('/app/backend/dist/services/s3-backup-client.js');
const phase = process.argv[2], statePath = '/checks/state239-private.json', publicPath = '/checks/s3-ui239-native.json';
const config = { endpoint: process.env.MMAPP_TEST_S3_ENDPOINT, region: process.env.MMAPP_TEST_S3_REGION, bucket: process.env.MMAPP_TEST_S3_BUCKET, accessKeyId: process.env.MMAPP_TEST_S3_ACCESS_KEY, secretAccessKey: process.env.MMAPP_TEST_S3_SECRET_KEY, forcePathStyle: false };
assert.equal(config.endpoint, 'https://s3.ru-1.storage.selcloud.ru');
assert.equal(config.bucket, 'mikrotik-test-s3'); assert(config.secretAccessKey);
const s3 = createS3ClientFromConfig(config);
const server = (await pool.query("SELECT * FROM servers WHERE host='194.226.121.150' AND enabled=true")).rows;
assert.equal(server.length, 1);
const client = new MikrotikClient({ host: server[0].host, port: server[0].port, useSsl: server[0].use_ssl, verifySsl: false, username: server[0].username, password: server[0].password });
const rows = async table => (await pool.query(`SELECT to_jsonb(${table}) AS row FROM ${table} ORDER BY id`)).rows.map(r => r.row);
const read = endpoint => client.get(endpoint, 10000);
const save = state => fs.writeFileSync(statePath, JSON.stringify(state), { mode: 0o600 });
async function versions(state) {
const result = []; let keyMarker, versionMarker;
for (let n = 0; n < 10; n++) {
const r = await s3.send(new ListObjectVersionsCommand({ Bucket: config.bucket, Prefix: state.prefix + '/', KeyMarker: keyMarker, VersionIdMarker: versionMarker }));
result.push(...(r.Versions ?? []).map(v => ({ ...v, kind: 'version' })), ...(r.DeleteMarkers ?? []).map(v => ({ ...v, kind: 'delete-marker' })));
if (!r.IsTruncated) return result;
assert(r.NextKeyMarker || r.NextVersionIdMarker); keyMarker = r.NextKeyMarker; versionMarker = r.NextVersionIdMarker;
} throw Error('Own version pagination limit');
}
async function restoreTable(table, original) {
assert.equal(original.length, 1); assert.equal(original[0].id, 1);
const columns = Object.keys(original[0]).filter(k => k !== 'id');
assert(columns.every(k => /^[a-z][a-z0-9_]*$/.test(k)));
await pool.query(`UPDATE ${table} SET ${columns.map((k, n) => `"${k}"=$${n + 1}`).join(',')} WHERE id=1`, columns.map(k => {
const value = original[0][k]; return value && typeof value === 'object' ? JSON.stringify(value) : value;
}));
assert.deepEqual(await rows(table), original);
}
try {
if (phase === 'init') {
assert(!fs.existsSync(statePath));
const uuid = randomUUID(), bridge = 'mm-ui239-' + uuid.slice(0, 12), filename = 'mm-ui239-' + uuid + '.rsc';
const state = { id: 'mm-ui239-' + uuid, prefix: 'mmapp-acceptance/' + uuid, bridge, filename, key: 'mmapp-acceptance/' + uuid + '/' + filename, serverId: Number(server[0].id), serverName: server[0].name, storage: await rows('backup_storage_settings'), schedule: await rows('backup_schedule_settings'), catalog: await rows('backup_entries'), bridges: await read('/interface/bridge'), files: await read('/file') };
assert.equal(state.storage[0].provider, 'local'); assert(state.catalog.every(r => r.storage === 'local' && !r.s3_key));
assert(!state.bridges.some(b => b.name === bridge)); assert(!state.files.some(f => f.name === filename));
assert.equal((await versions(state)).length, 0); save(state);
fs.writeFileSync('/checks/state239-public.json', JSON.stringify({ id: state.id, prefix: state.prefix, filename, bridge, serverId: state.serverId }));
console.log('Own scope initialized; original settings/catalog saved privately');
} else {
const state = JSON.parse(fs.readFileSync(statePath));
assert(/^mm-ui239-[a-f0-9-]+\.rsc$/.test(state.filename)); assert(state.key === state.prefix + '/' + state.filename);
if (phase === 'upload') {
const storage = (await rows('backup_storage_settings'))[0];
assert.equal(storage.provider, 's3'); assert.equal(storage.s3_prefix, state.prefix); assert.equal(storage.s3_bucket, config.bucket);
assert.equal((await rows('backup_schedule_settings'))[0].enabled, false);
const script = Buffer.from(`# Own limited UI restore 239\n/interface bridge add name=${state.bridge} comment=mm-ui239\n`);
assert.equal((await versions(state)).length, 0);
await s3PutObject(s3, config.bucket, state.key, script);
assert.deepEqual(await s3GetObject(s3, config.bucket, state.key), script);
await pool.query('INSERT INTO backup_entries(id,server_id,server_name,filename,size_bytes,kind,notes,storage,s3_key,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,now())', [state.id, state.serverId, state.serverName, state.filename, script.length, 'manual', 'Own limited UI restore 239: bridge only', 's3', state.key]);
state.bytes = script.length; state.sha256 = createHash('sha256').update(script).digest('hex'); save(state);
console.log(JSON.stringify({ ownS3ScriptUploaded: true, bytes: state.bytes, catalogRowCreated: true }));
} else if (phase === 'verify') {
const bridges = await read('/interface/bridge'), own = bridges.filter(b => b.name === state.bridge);
assert.equal(own.length, 1); assert.equal(own[0].comment, 'mm-ui239');
assert.equal((await read('/interface/bridge/port')).filter(p => p.bridge === state.bridge).length, 0);
assert.equal((await read('/ip/address')).filter(p => p.interface === state.bridge).length, 0);
assert.deepEqual(bridges.filter(b => b.name !== state.bridge), state.bridges);
state.nativeVerified = true; save(state);
console.log('Native bridge confirmed; no ports/addresses, original bridges unchanged');
} else if (phase === 'cleanup') {
const own = (await read('/interface/bridge')).filter(b => b.name === state.bridge);
assert(own.length <= 1);
if (own.length) { assert.equal(own[0].comment, 'mm-ui239'); await client.delete('/interface/bridge/' + encodeURIComponent(own[0]['.id'])); }
const ownFiles = (await read('/file')).filter(f => f.name === state.filename); assert(ownFiles.length <= 1);
for (const file of ownFiles) await client.removeFile(file['.id']);
await pool.query('DELETE FROM backup_entries WHERE id=$1 AND filename=$2 AND s3_key=$3', [state.id, state.filename, state.key]);
const existing = await versions(state); assert(existing.every(v => v.Key === state.key && v.VersionId));
for (const v of existing) await s3.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: v.Key, VersionId: v.VersionId }));
assert.equal((await versions(state)).length, 0);
assert.deepEqual(await rows('backup_entries'), state.catalog);
assert.deepEqual(await read('/interface/bridge'), state.bridges);
assert(!(await read('/file')).some(f => f.name === state.filename));
await restoreTable('backup_storage_settings', state.storage); await restoreTable('backup_schedule_settings', state.schedule);
const report = { date: '2026-10-14', block: '12.5.239', host: server[0].host, prefix: state.prefix, limitedScriptBytes: state.bytes ?? null, sha256: state.sha256 ?? null, nativeBridgeVerified: Boolean(state.nativeVerified), bridgeAndUploadedFileRemoved: true, originalBridgesUnchanged: true, originalCatalogAllFieldsMatched: true, originalCatalogRows: state.catalog.length, originalSettingsAllFieldsMatched: true, s3VersionsAndMarkersRemoved: existing.length, s3VersionsAndMarkersRemaining: 0, passwordChanges: 0, fullConfigurationApplied: false };
fs.writeFileSync(publicPath, JSON.stringify(report, null, 2) + '\n'); fs.unlinkSync(statePath); console.log(JSON.stringify(report));
} else throw Error('Unsupported phase');
}
} finally { s3.destroy(); await pool.end(); }