chore(test): проверить ограниченное восстановление из хранилища через интерфейс
This commit is contained in:
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(); }
|
||||
Reference in new issue
Block a user