From 941a4f01aa4478d358c613bb2655d959ea616c18 Mon Sep 17 00:00:00 2001 From: Panfilov Konstantin Date: Mon, 26 Oct 2026 05:42:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D1=80=D1=83=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82=D1=8B=20UTM?= =?UTF-8?q?=20=D0=B8=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20config=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/README.md | 37 +++++++++++++++++++----------- admin/composer.json | 2 +- admin/deploy/export-catalog.php | 33 -------------------------- admin/deploy/refresh-catalog.sh | 24 ------------------- admin/public/assets/admin.css | 2 ++ admin/public/assets/admin.js | 25 ++++++++------------ admin/public/index.php | 5 ++-- admin/src/Catalog.php | 14 ----------- admin/src/Config.php | 11 ++------- admin/src/Copy.php | 4 ++-- admin/src/Jobs.php | 2 +- admin/src/LinkBuilder.php | 4 +--- admin/tests/backend-http.php | 2 ++ admin/tests/backend-review.php | 10 ++++---- admin/tests/backend-security.php | 12 +++++----- admin/tests/catalog-publish.sh | 32 -------------------------- admin/tests/copy.php | 8 ++++--- admin/tests/link-builder.php | 7 +++++- admin/tests/manual-variants.php | 18 +++++++++++++++ admin/tests/obsolete-utm.php | 4 ++-- admin/tests/percent-weights.php | 2 +- admin/tests/ui-manual-variants.cjs | 7 ++++++ admin/tests/ui-matrix.cjs | 4 ++-- admin/tests/ui-read-status.cjs | 2 +- admin/tests/ui-set-new-replace.cjs | 5 ++++ admin/tests/ui-smoke.cjs | 2 +- admin/tests/ui-utm.cjs | 2 +- admin/views/dashboard.php | 2 +- 28 files changed, 108 insertions(+), 174 deletions(-) delete mode 100644 admin/deploy/export-catalog.php delete mode 100644 admin/deploy/refresh-catalog.sh delete mode 100644 admin/src/Catalog.php delete mode 100644 admin/tests/catalog-publish.sh create mode 100644 admin/tests/manual-variants.php create mode 100644 admin/tests/ui-manual-variants.cjs create mode 100644 admin/tests/ui-set-new-replace.cjs diff --git a/admin/README.md b/admin/README.md index 29b8a68..f540a36 100644 --- a/admin/README.md +++ b/admin/README.md @@ -13,13 +13,13 @@ ## Сводка и конструктор ссылки Первый экран — редактор фактических конфигов всех подключений. Основной сайт, -варианты, веса и default редактируются в соответствующей колонке. Доля вычисляется -автоматически:3:1 →75%/25%; нулевые веса дают0%. Default выбирается radio обычного +веса и default редактируются в соответствующей колонке. Веса хранятся в процентах: +0..100 с двумя знаками; сумма группы — 100% либо все веса — 0%. Default выбирается radio обычного начертания. Отсутствующая группа добавляется только явно. «Сохранить» и «Отмена» работают отдельно для каждого домена; ошибка и конфликт сохраняют черновик. Перечитывание другой колонки сохраняет DOM, фокус и курсор. После конфликта нужно явно загрузить сохранённое либо отменить черновик. Версии CAS остаются внутренними; -даты заметок и версии конфигов в таблице не показываются. +даты заметок и версии конфигов в таблице не показываются. Дата последнего чтения с хостинга видна у каждого домена. Под таблицей — копирование сохранённых host и восьми канонических UTM из одного источника в несколько получателей. Источник исключён из получателей. Черновики @@ -39,7 +39,7 @@ `{target,selection}` с ровно восемью каноническими UTM-ключами. Действуют общий вход и CSRF. Состояние находится в приватном `state/link-builder-ID.json` с режимом0600. При переименовании домена меняется origin ссылки, выбор и сохранённые метки остаются. -Неизвестная в текущем runtime/catalog группа сопровождается предупреждением; +Неизвестный в текущем config номер сопровождается предупреждением; конструктор не исправляет конфигурацию автоматически. ## Компактная сводка и заметки — preland#23 / #24 @@ -123,15 +123,27 @@ Imagick не нужен. Замена реквизитов сбрасывает Имена numeric/legacy UTM адаптируются отдельно для каждой цели, конфликтующие aliases блокируются. Удаление набора из реестра оставляет файлы; используемый номер удалить нельзя. -## Каталог групп и приватная публикация +## Ручные варианты и независимость — preland#30 -`deploy/export-catalog.php` выполняется внутри существующего API-контейнера минимальным -Yii bootstrap, в read-only DB-транзакции. Он экспортирует только номера/названия degr/tp/cons, -не выдавая приложению доступ к БД. `deploy/refresh-catalog.sh` запускается host cron раз -в минуту от root. Подготовка/проверка/chown происходят в root-only `secrets/`, затем -`mv -fT` заменяет фиксированный `state/catalog.json` без следования ссылке назначения. -Снимок действует180секунд только для совпадающего основного host. Недоступный/старый -каталог даёт явную пометку непроверенного существования групп, а не выдуманный список. +Во всех восьми UTM ссылка «Добавить вариант» создаёт пустую строку: номер, вес и +неотмеченный default. До нажатия пустых строк нет. Существующие номера показаны +текстом; новые вводятся вручную (1–9). После сохранения новый номер тоже становится +обычным текстом. Нулевой вес сохраняется. Отсутствующую группу можно создать через +ту же ссылку; для неё default выбирается явно. «Отмена» возвращает последний прочитанный конфиг. + +Редактор не обращается к каталогу основной админки и не проверяет реализацию номера +на сайте: можно записать theme=3 или indpic=9 независимо от наличия темы/файлов. +Соответствие номера содержимому сайта обеспечивает пользователь. Формат номера, +дубликаты, aliases, проценты и CAS проверяются; конфиг не нормализуется автоматически. +Сохранение, копирование и восстановление одинаково независимы от каталога/наборов. + +Новый сет загружается отдельно: выбрать домены, вручную указать номер, выбрать ZIP, +нажать «Загрузить сет». Для существующего номера нужно подтвердить замену. +Проверка ZIP и изображений сохраняется, как и проверка файлов, загруженных по SFTP. +Загрузка не меняет config.json. После неё пользователь сам добавляет вариант indpic, +заполняет вес/default и сохраняет настройки. Автоматической связи нет. + +## Приватное состояние и публикация наборов Резервы и journal локального транспорта хранятся в приватном state. Для exact bind mount папка подготовки `.preland-admin-staging` создаётся внутри корня с0700; публичный веб-сервер @@ -151,7 +163,6 @@ supervisor сразу пытается восстановить полный п ```sh docker build -t preland-admin: -f admin/Dockerfile admin docker run --rm --entrypoint composer -v "$PWD:/work:ro" -w /work/admin preland-admin: test -docker run --rm --entrypoint bash -v "$PWD:/work:ro" preland-admin: -c 'sed "s/\r$//" /work/admin/tests/catalog-publish.sh | bash' ``` Дополнительные Docker проверки: `tests/backend-sftp.Dockerfile` — настоящий SSH/SFTP, diff --git a/admin/composer.json b/admin/composer.json index c54a103..60d8966 100644 --- a/admin/composer.json +++ b/admin/composer.json @@ -4,5 +4,5 @@ "license": "proprietary", "require": {"php": "^8.3", "ext-json": "*", "ext-zip": "*", "ext-imagick": "*", "ext-zlib": "*", "phpseclib/phpseclib": "^3.0"}, "config": {"sort-packages": true}, - "scripts": {"test": ["php tests/percent-weights.php", "php tests/percent-migration.php", "php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php"], "test:worker": "php tests/backend-worker.php"} + "scripts": {"test": ["php tests/manual-variants.php", "php tests/percent-weights.php", "php tests/percent-migration.php", "php tests/backend-review.php", "php tests/link-builder.php", "php tests/targets.php", "php tests/backend-http.php", "php tests/session-persistence.php", "php tests/remember-auth.php", "php tests/transport-command-limit.php", "php tests/utm-notes.php", "php tests/obsolete-utm.php", "php tests/copy.php"], "test:worker": "php tests/backend-worker.php"} } diff --git a/admin/deploy/export-catalog.php b/admin/deploy/export-catalog.php deleted file mode 100644 index 312e62a..0000000 --- a/admin/deploy/export-catalog.php +++ /dev/null @@ -1,33 +0,0 @@ -beginTransaction(yii\db\Transaction::REPEATABLE_READ); -try { - $db->createCommand('SET TRANSACTION READ ONLY')->execute(); - $db->createCommand("SET LOCAL statement_timeout = '5s'")->execute(); - $groups = []; - foreach (['degr' => 'degr_groups', 'tp' => 'tarif_groups', 'cons' => 'consent_groups'] as $marker => $table) { - $query = (new yii\db\Query())->select(['number', 'name']) - ->from('{{%' . $table . '}}')->where(['between', 'number', 1, 9])->orderBy(['number' => SORT_ASC]); - if ($marker !== 'degr') { - $query->andWhere(['deleted_at' => null]); - } - $groups[$marker] = array_map(static fn(array $row): array => [ - 'number' => (int) $row['number'], 'name' => (string) $row['name'], - ], $query->all($db)); - } - $transaction->commit(); - echo json_encode(['host' => 'zoomzilla.ru', 'generated_at' => gmdate(DATE_ATOM), 'groups' => $groups], - JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n"; -} catch (Throwable $exception) { - if ($transaction->isActive) { - $transaction->rollBack(); - } - // Сообщения PDO могут содержать DSN: наружу только безопасный результат. - fwrite(STDERR, "Каталог групп не обновлён.\n"); - exit(1); -} diff --git a/admin/deploy/refresh-catalog.sh b/admin/deploy/refresh-catalog.sh deleted file mode 100644 index 175f425..0000000 --- a/admin/deploy/refresh-catalog.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -set -eu -umask 077 -base=/srv/preland-admin -# Подготовка root-процесса находится вне app-writable mount: UID приложения -# не может подменить файл ссылкой между проверкой, chown и chmod. -temporary=$(mktemp "$base/secrets/catalog.XXXXXX") -trap 'rm -f "$temporary"' EXIT HUP INT TERM -if ! /usr/bin/docker exec -i config-api-1 php < "$base/deploy/export-catalog.php" > "$temporary" 2>/dev/null; then - echo 'Каталог групп не обновлён; прежний снимок сохранён.' >&2 - exit 1 -fi -/usr/bin/python3 - "$temporary" <<'PY' -import json, sys -with open(sys.argv[1], encoding='utf-8') as stream: - value = json.load(stream) -assert value['host'] == 'zoomzilla.ru' -assert set(value['groups']) == {'degr', 'tp', 'cons'} -assert all(isinstance(items, list) for items in value['groups'].values()) -PY -chown 997:987 "$temporary" -chmod 600 "$temporary" -# -T заменяет саму ссылку назначения, не следует в указанный ею каталог. -mv -fT "$temporary" "$base/state/catalog.json" diff --git a/admin/public/assets/admin.css b/admin/public/assets/admin.css index 8ddaf99..e242a88 100644 --- a/admin/public/assets/admin.css +++ b/admin/public/assets/admin.css @@ -17,3 +17,5 @@ .overview-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}.hidden-link{border:0;background:none;color:#52677c;text-decoration:underline;cursor:pointer;padding:6px 0}.domain-heading{display:flex;align-items:center;gap:7px}.domain-heading .overview-domain{margin:0;min-width:0;overflow-wrap:anywhere}.domain-grip,.domain-hide{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;border:0;background:transparent;color:#89949c;padding:3px;width:24px;height:24px;border-radius:4px}.domain-grip{cursor:grab;font-size:22px;touch-action:none}.domain-hide{cursor:pointer}.domain-grip:hover,.domain-hide:hover{background:#edf1f4;color:#40566b}.domain-hide:focus-visible,.domain-grip:focus-visible{outline:2px solid #337ab7}.domain-grip:disabled,.domain-hide:disabled{opacity:.4;cursor:wait}.hidden-domain-row{display:flex;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid #e2e7ec;flex-wrap:wrap}.domain-dragging{user-select:none;cursor:grabbing}.domain-drag-preview{position:fixed;z-index:1001;pointer-events:none;background:#fff;border:1px solid #9bb7cf;border-radius:6px;padding:12px;box-shadow:0 8px 24px #23394d30;opacity:.92}.domain-drag-preview>p,.domain-drag-preview>button{display:none}.domain-insertion{position:fixed;z-index:1000;pointer-events:none;width:3px;background:#337ab7}.hidden-domain-row .grow{min-width:180px} .matrix-read-at{font-size:11px;font-weight:400;margin:4px 0 8px;line-height:1.4}.matrix-variant-number{font-size:13px;padding:3px 5px} + +.matrix-add-variant{display:inline-flex;align-items:center;border:0;background:transparent;color:#337ab7;padding:8px 0 0;font-size:12px;cursor:pointer}.matrix-add-variant:hover{color:#23527c;text-decoration:underline}.matrix-add-variant:focus-visible{outline:2px solid #337ab7;outline-offset:3px}.matrix-add-variant:disabled{opacity:.5;cursor:default}.matrix-new-number{width:44px}.indpic-add-form{align-items:end} diff --git a/admin/public/assets/admin.js b/admin/public/assets/admin.js index 678e64a..a6c0e0c 100644 --- a/admin/public/assets/admin.js +++ b/admin/public/assets/admin.js @@ -15,7 +15,7 @@ const keys=[marker,...(marker==='reg'?['registration']:marker==='banch'?['chob','bchoice']:[])].filter(key=>Object.hasOwn(config?.utm||{},key)); return {key:keys[0],value:keys.length?config.utm[keys[0]]:null,conflict:keys.length>1?keys:[]}; } - function digitFor(marker,value){const raw=String(value??'');const semantic=Object.entries(enums[marker]||{}).find(([,name])=>name===raw)?.[0];const digit=semantic||raw;return /^[1-9]$/.test(digit)&&(!enums[marker]||Object.hasOwn(enums[marker],digit))?digit:null;} + function digitFor(marker,value){const raw=String(value??'');const semantic=Object.entries(enums[marker]||{}).find(([,name])=>name===raw)?.[0];const digit=semantic||raw;return /^[1-9]$/.test(digit)?digit:null;} const utmNotes={entries:new Map(),loading:false,error:'',serial:0}; function noteEntry(marker){ if(utmNotes.entries.has(marker)){const entry=utmNotes.entries.get(marker);entry.cell.colSpan=Math.max(1,state.targets.length);return entry;} @@ -96,16 +96,16 @@ function matrixGroup(entry,marker,cell){ cell.replaceChildren();const group=groupFor(entry.data.config,marker),raw=group.value; if(group.conflict.length){cell.append(el('p',{class:'overview-error',text:'Конфликт ключей: '+group.conflict.join(', ')}));return;} - if(!group.key){cell.append(el('span',{class:'text-muted',text:'Группа отсутствует'}));return;} - if(!raw||typeof raw!=='object'||Array.isArray(raw)||!raw.weights||typeof raw.weights!=='object'||Array.isArray(raw.weights)||!digitFor(marker,raw.default)||Object.entries(raw.weights).some(([key,w])=>!digitFor(marker,key)||typeof w!=='number'||!Number.isFinite(w)||w<0)||new Set(Object.keys(raw.weights).map(k=>digitFor(marker,k))).size!==Object.keys(raw.weights).length){cell.append(el('p',{class:'overview-error',text:'Некорректная группа. Редактирование недоступно.'}));return;} - const draft={key:group.key,original:structuredClone(raw),rows:[],defaultRow:null,added:false};entry.groups.set(marker,draft);renderGroup(draft);for(const [key,w]of Object.entries(raw.weights))addVariant(draft,key,w);const defaultDigit=digitFor(marker,raw.default);if(!draft.rows.some(row=>digitFor(marker,row.variant.value)===defaultDigit))addVariant(draft,String(raw.default),0);draft.defaultRow=draft.rows.find(row=>digitFor(marker,row.variant.value)===defaultDigit);draft.defaultRow.radio.checked=true;percentages(draft); - function renderGroup(draft){draft.marker=marker;cell.replaceChildren();draft.list=el('div',{class:'matrix-variants'});draft.total=el('p',{class:'matrix-total text-muted','aria-live':'polite'});draft.validation=el('p',{class:'matrix-validation overview-error','aria-live':'polite'});cell.append(el('div',{class:'matrix-labels','aria-hidden':'true'},el('span',{text:'Вариант'}),el('span',{text:'Вес, %'}),el('span',{text:'Default'})),draft.list,draft.total,draft.validation);} - function addVariant(draft,key,weight){const wrapper=el('div',{class:'matrix-variant'}),variant={value:key};const input=numberInput(weight,0);input.max='100';input.step='0.01';input.setAttribute('aria-label',marker+' вес '+key+' '+entry.id);const radio=el('input',{type:'radio',name:'default-'+entry.id+'-'+marker,'aria-label':marker+' default '+key+' '+entry.id});const row={wrapper,variant,weight:input,radio};draft.rows.push(row);wrapper.append(el('span',{class:'matrix-variant-number',text:digitFor(marker,key)}),input,el('label',{class:'matrix-default'},radio,el('span',{text:'default'})));draft.list.append(wrapper);input.addEventListener('input',()=>{percentages(draft);draft.touched=true;matrixChanged(entry);});radio.addEventListener('change',()=>{draft.defaultRow=row;draft.touched=true;matrixChanged(entry);});percentages(draft);} + + if(group.key&&(!raw||typeof raw!=='object'||Array.isArray(raw)||!raw.weights||typeof raw.weights!=='object'||Array.isArray(raw.weights)||!digitFor(marker,raw.default)||Object.entries(raw.weights).some(([key,w])=>!digitFor(marker,key)||typeof w!=='number'||!Number.isFinite(w)||w<0)||new Set(Object.keys(raw.weights).map(k=>digitFor(marker,k))).size!==Object.keys(raw.weights).length)){cell.append(el('p',{class:'overview-error',text:'Некорректная группа. Редактирование недоступно.'}));return;} + const draft={key:group.key,original:structuredClone(raw||{weights:{}}),rows:[],defaultRow:null,added:false};entry.groups.set(marker,draft);renderGroup(draft);if(!group.key)return;for(const [key,w]of Object.entries(raw.weights))addVariant(draft,key,w);const defaultDigit=digitFor(marker,raw.default);if(!draft.rows.some(row=>digitFor(marker,row.variant.value)===defaultDigit))addVariant(draft,String(raw.default),0);draft.defaultRow=draft.rows.find(row=>digitFor(marker,row.variant.value)===defaultDigit);draft.defaultRow.radio.checked=true;percentages(draft); + function renderGroup(draft){draft.marker=marker;cell.replaceChildren();draft.list=el('div',{class:'matrix-variants'});draft.total=el('p',{class:'matrix-total text-muted','aria-live':'polite'});draft.validation=el('p',{class:'matrix-validation overview-error','aria-live':'polite'});cell.append(el('div',{class:'matrix-labels','aria-hidden':'true'},el('span',{text:'Вариант'}),el('span',{text:'Вес, %'}),el('span',{text:'Default'})),draft.list,el('button',{type:'button',class:'matrix-add-variant',text:'+ Добавить вариант','aria-label':'Добавить вариант '+marker+' '+entry.id,title:'Добавить вариант',onclick:()=>{addVariant(draft,'','',true);draft.added=!group.key;draft.touched=true;matrixChanged(entry);draft.rows.at(-1).variant.focus();}}),draft.total,draft.validation);} + function addVariant(draft,key,weight,isNew=false){const wrapper=el('div',{class:'matrix-variant'}),variant=isNew?el('input',{type:'text',inputMode:'numeric',maxLength:1,class:'form-control matrix-new-number',value:'','aria-label':'Новый вариант '+marker+' '+entry.id}):{value:key};const input=numberInput(weight,0);input.max='100';input.step='0.01';input.setAttribute('aria-label',marker+' вес '+key+' '+entry.id);const radio=el('input',{type:'radio',name:'default-'+entry.id+'-'+marker,'aria-label':marker+' default '+key+' '+entry.id});const row={wrapper,variant,weight:input,radio,isNew};draft.rows.push(row);wrapper.append(isNew?variant:el('span',{class:'matrix-variant-number',text:digitFor(marker,key)}),input,el('label',{class:'matrix-default'},radio,el('span',{text:'default'})));draft.list.append(wrapper);if(isNew){const label=()=>{input.setAttribute('aria-label',marker+' вес '+(variant.value||'новый')+' '+entry.id);radio.setAttribute('aria-label',marker+' default '+(variant.value||'новый')+' '+entry.id);};label();variant.addEventListener('input',()=>{label();draft.touched=true;matrixChanged(entry);});}input.addEventListener('input',()=>{percentages(draft);draft.touched=true;matrixChanged(entry);});radio.addEventListener('change',()=>{draft.defaultRow=row;draft.touched=true;matrixChanged(entry);});percentages(draft);} } function percentError(group){let sum=0;for(const row of group.rows){const text=row.weight.value,w=Number(text);if(text===''||!Number.isFinite(w)||w<0||w>100)return group.marker+': вес должен быть числом от 0 до 100%.';if(w!==Number(w.toFixed(2)))return group.marker+': в весе допустимо не более двух знаков после запятой.';sum+=Math.round(w*100);}return sum!==0&&sum!==10000?group.marker+': сумма весов должна быть 100% или все веса должны быть 0%.':'';} function percentages(group){const values=group.rows.map(row=>row.weight.value===''?NaN:Number(row.weight.value)),sum=values.reduce((total,w)=>total+w,0);group.total.textContent='Сумма: '+(Number.isFinite(sum)?String(Math.round(sum*1000000)/1000000)+'%':'—');group.validation.textContent=percentError(group);group.validation.hidden=!group.validation.textContent;} - function matrixValidation(entry){for(const marker of markers){const group=groupFor(entry.data?.config,marker);if(group.key&&!entry.groups.has(marker))return marker+': некорректная группа. Обновите конфигурацию.';const draft=entry.groups.get(marker);if(draft){const error=percentError(draft);if(error)return error;}}return '';} - function matrixChanges(entry){const invalid=matrixValidation(entry);if(invalid)throw Error(invalid);const changes=[];if(entry.hostTouched&&entry.host&&entry.host.value!==entry.data.config.host){if(!/^(?:https:\/\/)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/i.test(entry.host.value)||entry.host.value.includes('..'))throw Error('Адрес сайта должен быть HTTPS-хостом без пути.');changes.push({path:['host'],value:entry.host.value});}for(const [marker,group]of entry.groups){if(!group.touched&&!group.added)continue;if(!group.rows.length)throw Error(marker+': добавьте хотя бы один вариант.');const weights={};for(const row of group.rows){const key=row.variant.value,w=Number(row.weight.value);if(!Object.hasOwn(group.original.weights,key)&&w===0)continue;if(!digitFor(marker,key)||Object.hasOwn(weights,key))throw Error(marker+': варианты должны быть уникальны.');if(row.weight.value===''||!Number.isFinite(w)||w<0)throw Error(marker+': вес должен быть неотрицательным числом.');weights[key]=w;}if(!group.defaultRow)throw Error(marker+': выберите default.');const digit=digitFor(marker,group.defaultRow.variant.value),original=group.original;const rawDefault=original&&digitFor(marker,original.default)===digit?original.default:typeof original?.default==='number'?Number(digit):Object.values(enums[marker]||{}).includes(String(original?.default))?enums[marker][digit]:digit;if(group.added){changes.push({path:['utm',marker],value:{default:rawDefault,weights}});}else{if(!equivalent(weights,original.weights))changes.push({path:['utm',group.key,'weights'],value:weights});if(!equivalent(rawDefault,original.default))changes.push({path:['utm',group.key,'default'],value:rawDefault});}}return changes;} + function matrixValidation(entry){for(const marker of markers){const group=groupFor(entry.data?.config,marker);if(group.key&&!entry.groups.has(marker))return marker+': некорректная группа. Обновите конфигурацию.';const draft=entry.groups.get(marker);if(draft){if(draft.rows.some(row=>!digitFor(marker,row.variant.value)))return marker+': укажите номер варианта от 1 до 9.';if(new Set(draft.rows.map(row=>digitFor(marker,row.variant.value))).size!==draft.rows.length)return marker+': варианты должны быть уникальны.';const error=percentError(draft);if(error)return error;if(draft.touched&&!draft.defaultRow)return marker+': выберите default.';}}return '';} + function matrixChanges(entry){const invalid=matrixValidation(entry);if(invalid)throw Error(invalid);const changes=[];if(entry.hostTouched&&entry.host&&entry.host.value!==entry.data.config.host){if(!/^(?:https:\/\/)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?$/i.test(entry.host.value)||entry.host.value.includes('..'))throw Error('Адрес сайта должен быть HTTPS-хостом без пути.');changes.push({path:['host'],value:entry.host.value});}for(const [marker,group]of entry.groups){if(!group.touched&&!group.added)continue;if(!group.rows.length)throw Error(marker+': добавьте хотя бы один вариант.');const weights={};for(const row of group.rows){const key=row.variant.value,w=Number(row.weight.value);if(!row.isNew&&!Object.hasOwn(group.original.weights,key)&&w===0)continue;if(!digitFor(marker,key)||Object.hasOwn(weights,key))throw Error(marker+': варианты должны быть уникальны.');if(row.weight.value===''||!Number.isFinite(w)||w<0)throw Error(marker+': вес должен быть неотрицательным числом.');weights[key]=w;}if(!group.defaultRow)throw Error(marker+': выберите default.');const digit=digitFor(marker,group.defaultRow.variant.value),original=group.original;const rawDefault=original&&digitFor(marker,original.default)===digit?original.default:typeof original?.default==='number'?Number(digit):Object.values(enums[marker]||{}).includes(String(original?.default))?(enums[marker][digit]||digit):digit;if(group.added){changes.push({path:['utm',marker],value:{default:rawDefault,weights}});}else{if(!equivalent(weights,original.weights))changes.push({path:['utm',group.key,'weights'],value:weights});if(!equivalent(rawDefault,original.default))changes.push({path:['utm',group.key,'default'],value:rawDefault});}}return changes;} function populateMatrix(entry){entry.groups.clear();entry.hostTouched=false;entry.validation='';for(const [key,cell]of entry.cells){cell.replaceChildren();if(key==='host'){entry.host=el('input',{class:'form-control',value:entry.data.config.host??'','data-host':'','aria-label':'Основной сайт '+entry.id});entry.host.addEventListener('input',()=>{entry.hostTouched=true;matrixChanged(entry);});cell.append(entry.host);}else matrixGroup(entry,key,cell);}entry.validation=matrixValidation(entry);updateMatrixStatus(entry);} function renderOverview(){ const focused=document.activeElement,selection=focused&&'selectionStart'in focused?{start:focused.selectionStart,end:focused.selectionEnd}:null;const signature=state.targets.map(t=>t.id).join('|'),table=$('overview-table');table.style.minWidth=Math.max(760,110+state.targets.length*292)+'px';if(matrix.structure!==signature&&matrix.structure.split('|').slice().sort().join('|')===signature.split('|').slice().sort().join('|')&&table.querySelector('thead tr')){const ids=state.targets.map(t=>t.id);for(const row of table.querySelectorAll('tr')){if(row.hasAttribute('data-utm-note'))continue;const children=[...row.children];if(children.length===ids.length+1)for(const id of ids){const oldIndex=matrix.structure.split('|').indexOf(id);row.append(children[oldIndex+1]);}}matrix.structure=signature;if(focused?.isConnected){focused.focus({preventScroll:true});if(selection&&selection.start!==null)focused.setSelectionRange(selection.start,selection.end);}}if(matrix.structure!==signature||!table.querySelector('thead tr')){matrix.structure=signature;const head=el('tr',{},el('th',{scope:'col',text:'Параметр'})),body=table.querySelector('tbody');body.replaceChildren();table.querySelector('thead').replaceChildren(head);const hostrow=el('tr',{'data-overview-extra':'host'},el('th',{scope:'row',text:'Основной сайт'}));body.append(hostrow);for(const target of state.targets){const entry=matrixEntry(target.id);entry.statusNode=el('p',{class:'matrix-status',role:'status'});entry.readAtNode=el('p',{class:'matrix-read-at text-muted'});entry.reload=button('Обновить',()=>{if(entry.dirty&&!confirm('Загрузить сохранённые настройки и сбросить черновик '+target.domain+'?'))return;return refreshOverviewTarget(target.id,true);});entry.reload.setAttribute('aria-label','Обновить настройки '+target.id);head.append(el('th',{'data-overview-target':target.id},domainHeading(target),el('div',{class:'text-muted matrix-environment',text:target.environment+(target.enabled?'':' · отключено')+(target.verified?'':' · соединение не проверено')}),entry.statusNode,entry.readAtNode,entry.reload));const cell=entry.cells.get('host')||el('td',{'data-matrix-target':target.id});entry.cells.set('host',cell);hostrow.append(cell);} @@ -176,15 +176,10 @@ const result=[],snapshot=overview.entries.get(builder.target),config=snapshot?.data?.config; if(!config){result.push('Текущий runtime-конфиг не прочитан; фактическое применение выбранных значений не проверено.');return result;} if(snapshot.status!=='success')result.push('Проверка выбора использует прежний снимок runtime-конфига; обновите сводку.'); - const catalog=snapshot.data.catalog,generatedAt=Date.parse(catalog?.generated_at||''),now=Date.now(); - const host=value=>{try{return new URL(String(value).includes('://')?String(value):'https://'+value).hostname.toLowerCase().replace(/[.]$/,'');}catch(_){return '';}}; - const catalogFresh=snapshot.status==='success'&&Number.isFinite(generatedAt)&&generatedAt>=now-180000&&generatedAt<=now+30000&&host(catalog?.host)&&host(catalog.host)===host(config.host); - if(!catalogFresh)result.push('degr/tp/cons: актуальный каталог основного сайта недоступен; существование групп не проверено.'); for(const marker of markers){const choice=entry.draft[marker],group=groupFor(config,marker);if(group.conflict.length){result.push(marker+': конфликт ключей '+group.conflict.join(', ')+'. Публичный resolver может выбрать другое значение.');continue;} if(choice!=='rnd'&&!digitFor(marker,choice)){result.push(marker+': выберите допустимое значение. Сохранённый выбор не заменён автоматически.');continue;} const weights=group.value?.weights||{},variants=Object.keys(weights).map(v=>digitFor(marker,v)); if(['indpic','degr','tp'].includes(marker)&&choice!=='rnd'&&!variants.includes(choice))result.push(marker+'='+choice+': варианта нет в текущих весах; при открытии ссылки resolver использует default (или штатный fallback). Конфигурация не меняется.'); - if(catalogFresh&&['degr','tp','cons'].includes(marker)&&Array.isArray(catalog.groups?.[marker])&&choice!=='rnd'&&digitFor(marker,choice)&&!catalog.groups[marker].some(item=>String(item.number)===choice))result.push(marker+'='+choice+': группы нет в актуальном каталоге основного сайта. '+(marker==='cons'?'Прелендинг передаст выбранное значение; основной сайт может не принять эту группу.':'Основной сайт может не принять эту группу; наличие варианта в весах не подтверждает её существование.')); if(!group.key)result.push(marker+': группа отсутствует в runtime; при открытии применяется штатный fallback.'); } return result; @@ -193,7 +188,7 @@ const entry=builder.entries.get(builder.target);$('builder-fields').replaceChildren();$('builder-result').hidden=true;$('builder-warnings').replaceChildren();$('builder-save').disabled=true; if(!builder.target){$('builder-state').textContent='Выберите домен.';return;} if(!entry?.ready){$('builder-state').textContent=entry?.error?'Ошибка чтения выбора: '+entry.error:'Чтение последнего выбора…';if(entry?.error)$('builder-fields').append(button('Повторить чтение выбора',()=>openBuilder(builder.target,true)));return;} - for(const marker of markers){const values=[...(enums[marker]?Object.keys(enums[marker]):Array.from({length:9},(_,i)=>String(i+1))),'rnd'];const select=el('select',{class:'form-control','data-marker':marker,'aria-label':'Ссылка: '+marker},el('option',{value:'',text:'Выберите значение'}),...values.map(value=>el('option',{value,text:value==='rnd'?'Рандом':optionLabel(marker,value)}))); + for(const marker of markers){const values=[...Array.from({length:9},(_,i)=>String(i+1)),'rnd'];const select=el('select',{class:'form-control','data-marker':marker,'aria-label':'Ссылка: '+marker},el('option',{value:'',text:'Выберите значение'}),...values.map(value=>el('option',{value,text:value==='rnd'?'Рандом':optionLabel(marker,value)}))); if(entry.draft[marker]&&!values.includes(entry.draft[marker]))select.append(el('option',{value:entry.draft[marker],text:'Недопустимый сохранённый выбор: '+entry.draft[marker]}));select.value=entry.draft[marker]||'';select.disabled=entry.saving; select.addEventListener('change',()=>{entry.draft[marker]=select.value;entry.error='';updateBuilderState();});$('builder-fields').append(labelControl(marker,select)); } @@ -310,7 +305,7 @@ $('copy-source').addEventListener('change',e=>{matrix.copySource=e.target.value;matrix.copyError='';matrix.copyResults=[];matrix.copyTargets.delete(e.target.value);renderCopy();}); $('copy-all').addEventListener('click',()=>{matrix.copyTargets=new Set(state.targets.filter(t=>t.id!==matrix.copySource&&t.enabled&&t.verified).map(t=>t.id));renderCopy();}); $('copy-none').addEventListener('click',()=>{matrix.copyTargets.clear();renderCopy();}); - $('copy-submit').addEventListener('click',()=>run(copySettings)); $('set-add').elements.sftp.addEventListener('change',event=>{const form=$('set-add');form.querySelector('.zip-field').hidden=event.target.checked;form.elements.file.required=!event.target.checked;form.querySelector('button').textContent=event.target.checked?'Проверить и добавить':'Загрузить и добавить';}); + $('copy-submit').addEventListener('click',()=>run(copySettings)); $('set-add').elements.sftp.addEventListener('change',event=>{const form=$('set-add');form.querySelector('.zip-field').hidden=event.target.checked;form.elements.file.required=!event.target.checked;form.querySelector('button').textContent=event.target.checked?'Проверить и добавить':'Загрузить сет';}); $('set-add').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const number=Number(form.elements.number.value);if(form.elements.sftp.checked){const r=await api('sets/register',{targets:safeTargets(),number,name:form.elements.name.value});await showJob(r.job,true);}else await uploadSet(form.elements.file.files[0],number,form.elements.name.value,false);});}); $('connection-new').addEventListener('click',()=>{$('connection-form').reset();$('connection-form').elements.id.readOnly=false;$('connection-title').textContent='Добавить подключение';}); $('connection-form').addEventListener('submit',event=>{event.preventDefault();run(async()=>{const form=event.currentTarget;const target={};for(const key of ['id','name','domain','environment','type','host','user','root','host_key'])target[key]=form.elements[key].value;target.port=Number(form.elements.port.value);target.enabled=form.elements.enabled.checked;const data={target};if(form.elements.secret.value)data.secret=form.elements.secret.value;await mutateTargets('targets',data);form.elements.secret.value='';notice('Подключение сохранено. Проверьте соединение перед записью.','success');});}); diff --git a/admin/public/index.php b/admin/public/index.php index 1d8d4da..f171c9e 100644 --- a/admin/public/index.php +++ b/admin/public/index.php @@ -1,7 +1,7 @@ target($id);(new Transport($store,$t))->test();$store->verifyTarget($id,$t);reply(['message'=>'Пути, блокировка, чтение и права записи проверены.']);} if($path==='/api/config'&&$method==='GET'){ $target=$store->target((string)($_GET['target']??''));$read=(new Transport($store,$target))->readConfig();$readAt=gmdate(DATE_ATOM); - $catalog=Catalog::read($store,$read['config']);$warnings=[];if($catalog===null)foreach(['degr','tp','cons'] as $key)if(isset($read['config']->utm->$key))$warnings[]="{$key}: существование группы на основном сайте не проверено."; - reply(['config'=>$read['config'],'version'=>$read['version'],'read_at'=>$readAt,'warnings'=>$warnings,'catalog'=>$catalog,'sets'=>$sets->list($target,$read['config'])]); + reply(['config'=>$read['config'],'version'=>$read['version'],'read_at'=>$readAt,'warnings'=>[],'sets'=>$sets->list($target,$read['config'])]); } if($path==='/api/copy'){ if($method!=='POST')throw new Problem('Метод копирования не поддерживается.',405); diff --git a/admin/src/Catalog.php b/admin/src/Catalog.php deleted file mode 100644 index 2d01a00..0000000 --- a/admin/src/Catalog.php +++ /dev/null @@ -1,14 +0,0 @@ -read('catalog.json',null);if(!is_array($catalog)||!is_string($catalog['generated_at']??null))return null; - $at=strtotime($catalog['generated_at']);if($at===false||$attime()+30)return null; - $host=static fn($value)=>strtolower(rtrim((string)parse_url(str_contains((string)$value,'://')?(string)$value:'https://'.$value,PHP_URL_HOST),'.')); - if(!$host($config->host??'')||$host($catalog['host']??'')!==$host($config->host??''))return null; - foreach(['degr','tp','cons'] as $group){if(!is_array($catalog['groups'][$group]??null))return null;foreach($catalog['groups'][$group] as $entry)if(!is_array($entry)||!is_int($entry['number']??null)||$entry['number']<1||$entry['number']>9||!is_string($entry['name']??null))return null;} - return $catalog; - } -} diff --git a/admin/src/Config.php b/admin/src/Config.php index c9b96ca..2dd823e 100644 --- a/admin/src/Config.php +++ b/admin/src/Config.php @@ -54,7 +54,7 @@ final class Config } $change['path']=$path;return $change; } - public static function validate(\stdClass $config, callable $setExists,?array $catalog=null): array { + public static function validate(\stdClass $config): array { self::assertWritable($config); PercentWeights::validate($config); if (isset($config->host)) { @@ -74,7 +74,7 @@ final class Config $number=static function($value) use($maps,$canonical): string { if (!is_int($value) && !is_string($value)) throw new Problem('Вариант UTM должен быть номером.'); $value=(string)$value; - if (isset($maps[$canonical])) { $digit=array_search($value,$maps[$canonical],true); if ($digit!==false) return (string)$digit; if (!isset($maps[$canonical][$value])) throw new Problem("Недопустимый вариант {$canonical}."); } + if (isset($maps[$canonical])) { $digit=array_search($value,$maps[$canonical],true); if ($digit!==false) return (string)$digit; } if (!preg_match('/^[1-9]$/D',$value)) throw new Problem('Вариант UTM должен быть 1–9.'); return $value; }; @@ -83,15 +83,8 @@ final class Config $digit=$number($variant); if (isset($seen[$digit])) throw new Problem('Конфликт semantic и numeric вариантов UTM.'); $seen[$digit]=true; if ((!is_int($weight) && !is_float($weight)) || !is_finite((float)$weight) || $weight<0) throw new Problem('Вес должен быть конечным неотрицательным числом.'); - if ($canonical==='indpic' && !$setExists((int)$digit)) throw new Problem("Набор {$digit} отсутствует или не прошёл проверку."); - if($catalog!==null&&in_array($canonical,['degr','tp','cons'],true)&&!in_array((int)$digit,array_column($catalog['groups'][$canonical]??[],'number'),true))throw new Problem("Группа {$canonical} {$digit} отсутствует в доступном каталоге основного сайта."); } if (in_array($canonical,['indpic','degr','tp'],true)&&!isset($seen[$default])) throw new Problem("Добавьте вариант {$default} в таблицу весов {$key} (допустим вес 0)."); - if ($canonical==='indpic' && !$setExists((int)$default)) throw new Problem("Набор default {$default} отсутствует или не прошёл проверку."); - if (in_array($canonical,['degr','tp','cons'],true)) { - if($catalog===null)$warnings[]="{$canonical}: существование группы на основном сайте не проверено."; - elseif(!in_array((int)$default,array_column($catalog['groups'][$canonical]??[],'number'),true))throw new Problem("Группа default {$canonical} {$default} отсутствует в доступном каталоге основного сайта."); - } } return $warnings; } diff --git a/admin/src/Copy.php b/admin/src/Copy.php index de00764..52d9c92 100644 --- a/admin/src/Copy.php +++ b/admin/src/Copy.php @@ -93,8 +93,8 @@ final class Copy $changes[]=['path'=>['utm',$canonical,'default'],'value'=>$default]; $changes[]=['path'=>['utm',$canonical,'weights'],'value'=>$weights]; } - // Наличие физических наборов и каталог проверяются отдельно на каждом получателе. - Config::validate($projection,static fn(int $number): bool=>true); + // Редактор проверяет формат конфига, а не реализацию вариантов на сайтах. + Config::validate($projection); return $changes; } } diff --git a/admin/src/Jobs.php b/admin/src/Jobs.php index 203ee69..8954e1f 100644 --- a/admin/src/Jobs.php +++ b/admin/src/Jobs.php @@ -41,7 +41,7 @@ final class Jobs if(isset($p['changes']))foreach($p['changes'] as &$change){if(isset($change['value_json']))$change['value']=json_decode($change['value_json'],false,64,JSON_THROW_ON_ERROR);}unset($change); if($type==='copy')Copy::assertAliases($current['config']); $next=$type==='restore'?Config::decode($this->store->backupContent($target['id'],$p['id'])):Config::patch($current['config'],$p['changes']??[]); - $checked=[];$warnings=Config::validate($next,function($number)use($target,&$checked){if(array_key_exists($number,$checked))return $checked[$number];try{(new Sets($this->store))->check($target,$number);return $checked[$number]=true;}catch(\Throwable){return $checked[$number]=false;}},Catalog::read($this->store,$next)); + $warnings=Config::validate($next); $content=Config::encode($next);$job['results'][$index]['desired_hash']=hash('sha256',$content);$save(); $written=$transport->writeConfig($expected,$content);$out=['message'=>'Настройки сохранены и проверены.','version'=>$written['version'],'warnings'=>$warnings]; }else{ diff --git a/admin/src/LinkBuilder.php b/admin/src/LinkBuilder.php index 39ac9c1..5c67667 100644 --- a/admin/src/LinkBuilder.php +++ b/admin/src/LinkBuilder.php @@ -7,7 +7,6 @@ namespace PrelandAdmin; final class LinkBuilder { private const KEYS = ['indpic', 'theme', 'degr', 'reg', 'banch', 'tp', 'cons', 'focus']; - private const ENUMS = ['theme', 'reg', 'banch', 'focus']; public function __construct(private readonly Store $store) {} @@ -72,8 +71,7 @@ final class LinkBuilder $selection = []; foreach (self::KEYS as $key) { $value = $input[$key] ?? null; - if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value)) - || (in_array($key, self::ENUMS, true) && !in_array($value, ['1', '2', 'rnd'], true))) { + if (!is_string($value) || ($value !== 'rnd' && !preg_match('/^[1-9]$/D', $value))) { throw new Problem("Недопустимое значение параметра {$key} конструктора ссылки."); } $selection[$key] = $value; diff --git a/admin/tests/backend-http.php b/admin/tests/backend-http.php index 1b1e716..990f441 100644 --- a/admin/tests/backend-http.php +++ b/admin/tests/backend-http.php @@ -44,6 +44,8 @@ try{ [$status]=request('/api/sets/check','POST',['targets'=>['delete-fixture'],'number'=>1],$csrf);check($status===404&&count(glob($store->dir.'/jobs/*.json'))===$countBefore,'HTTP stale enqueue cannot create job after connection deletion'); [$status,$body]=request('/api/apply','POST',['targets'=>[],'changes'=>[]]);check($status===403,'HTTP mutation rejects missing CSRF'); [$status,$body]=request('/api/config?target=test');check($status===200&&isset($body['version'],$body['config']),'HTTP config reads actual target version');$version=$body['version']; + file_put_contents($store->dir.'/catalog.json','invalid obsolete catalog'); + [$status,$withoutCatalog]=request('/api/config?target=test');check($status===200&&!array_key_exists('catalog',$withoutCatalog)&&$withoutCatalog['warnings']===[],'HTTP config ignores even corrupt obsolete catalog without warnings'); check(is_string($body['read_at']??null)&&preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$/D',$body['read_at'])===1&&abs(time()-strtotime($body['read_at']))<10,'HTTP config includes actual successful read timestamp as response metadata'); check(!array_key_exists('read_at',$store->target('test'))&&!property_exists(json_decode(file_get_contents($root.'/site/config.json')),'read_at'),'read timestamp does not enter target or runtime config'); [$status,$body]=request('/api/apply','POST',['targets'=>[['id'=>'test','version'=>$version]],'changes'=>[['path'=>['host'],'value'=>'http.example']]],$csrf);check($status===202&&isset($body['job']['id']),'HTTP apply queues persistent job');$id=$body['job']['id']; diff --git a/admin/tests/backend-review.php b/admin/tests/backend-review.php index b294a23..399bdcb 100644 --- a/admin/tests/backend-review.php +++ b/admin/tests/backend-review.php @@ -2,7 +2,7 @@ declare(strict_types=1); if(($argv[1]??'')==='--crash-child') {require dirname(__DIR__).'/bootstrap.php';$p=json_decode(file_get_contents($argv[2]),true,64,JSON_THROW_ON_ERROR);PrelandAdmin\Remote::execute($p);exit;} require __DIR__.'/backend-security.php'; -use PrelandAdmin\{Config,Catalog,Sets,Conflict}; +use PrelandAdmin\{Config,Sets,Conflict}; require dirname(__DIR__,2).'/components/utm-codec.php'; $resolverConfig=['utm'=>['indpic'=>['default'=>'2','weights'=>['1'=>0,'2'=>0]]]]; check(prelandResolveUtm(['indpic'=>'abcd2'],$resolverConfig)['digits']['indpic']==='2','configured zero-weight indpic remains selectable explicitly by actual resolver'); @@ -13,10 +13,10 @@ $changed=Config::patch($legacy,[['path'=>['utm','reg','weights'],'value'=>(objec $modern=Config::decode('{"host":"zoomzilla.ru","utm":{"reg":{"default":"1","weights":{"1":100,"2":0}}}}');$changed=Config::patch($modern,[['path'=>['utm','registration','default'],'value'=>'after']]);check($changed->utm->reg->default==='2'&&!isset($changed->utm->registration),'legacy default adapts to modern target'); $changed=Config::patch($modern,[['path'=>['utm','registration','weights','before'],'value'=>5]]);check($changed->utm->reg->weights->{'1'}===5,'legacy weight path adapts to numeric target'); rejects(fn()=>Config::patch(Config::decode('{"utm":{"reg":{},"registration":{}}}'),[['path'=>['utm','reg','default'],'value'=>'2']]),'pre-existing two aliases rejected before adapter'); -rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":100,"9":0}}}}'),static fn($n)=>$n===1),'zero-weight indpic variant requires complete physical set'); -check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'),static fn()=>true)===[],'enum default does not invent weights-membership restriction'); -$catalog=['host'=>'https://ZOOMZILLA.RU:443','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[['number'=>1,'name'=>'one']],'cons'=>[['number'=>1,'name'=>'one']]]];$store->write('catalog.json',$catalog);$cfg=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"1","weights":{"1":100}}}}');$fresh=Catalog::read($store,$cfg);check($fresh!==null&&Config::validate($cfg,static fn()=>true,$fresh)===[],'fresh matching catalog validates actual available number without fallback warning'); -$invalid=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":100}}}}');rejects(fn()=>Config::validate($invalid,static fn()=>true,$fresh),'fresh catalog rejects unknown number');$catalog['generated_at']=gmdate(DATE_ATOM,time()-181);$store->write('catalog.json',$catalog);check(Catalog::read($store,$cfg)===null&&count(Config::validate($invalid,static fn()=>true,null))===1,'stale catalog falls back to explicit unverified warning'); +check(Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":100,"9":0}}}}'))===[],'zero-weight indpic can reference manually entered variant'); +check(Config::validate(Config::decode('{"utm":{"theme":{"default":"1","weights":{}}}}'))===[],'enum default does not invent weights-membership restriction'); +$store->write('catalog.json',['host'=>'zoomzilla.ru','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[],'tp'=>[],'cons'=>[]]]); +$manual=Config::decode('{"host":"zoomzilla.ru","utm":{"degr":{"default":"9","weights":{"9":100}}}}');check(Config::validate($manual)===[],'manual groups require no catalog lookup or availability warnings'); $validPrepared=$sets->prepareZip($zipPath,1);$source=$sets->manifest($target,1);$desired=Sets::preparedManifest($validPrepared,1); $original=file_get_contents($root.'/site/1/01_desk_1-source.jpg');$img=new Imagick();$img->newImage(1696,920,'#aabbcc');$img->setImageFormat('jpg');file_put_contents($root.'/site/1/01_desk_1-source.jpg',$img->getImagesBlob());$img->clear();$foreign=$sets->manifest($target,1); rejects(fn()=>$sets->upload($target,1,$validPrepared,'CAS',$source,true),'set CAS rejects third-party manifest changed after read');check($sets->manifest($target,1)===$foreign,'set CAS preserves third-party images'); diff --git a/admin/tests/backend-security.php b/admin/tests/backend-security.php index 0423742..0308e14 100644 --- a/admin/tests/backend-security.php +++ b/admin/tests/backend-security.php @@ -3,11 +3,11 @@ declare(strict_types=1); require __DIR__.'/backend.php'; use PrelandAdmin\{Config,Jobs,Sets,IndpicSetStorage,Auth,Problem}; function rejects(callable $call,string $message): void {try{$call();}catch(\Throwable $e){check(true,$message);return;}throw new RuntimeException('Accepted invalid input: '.$message);} -rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":100}},"registration":{"default":"before","weights":{"before":100}}}}'),static fn()=>true),'old/new UTM alias conflict rejected'); -rejects(static fn()=>Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'),static fn()=>true),'invalid enum rejected'); -rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}'),static fn()=>true),'negative UTM weight rejected'); -rejects(static fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'),static fn()=>false),'incomplete default rejected'); -check(Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":0,"light":0}}}}'),static fn()=>true)===[],'legacy semantic enum remains valid without rewriting'); +rejects(static fn()=>Config::validate(Config::decode('{"utm":{"reg":{"default":"1","weights":{"1":100}},"registration":{"default":"before","weights":{"before":100}}}}')),'old/new UTM alias conflict rejected'); +check(Config::validate(Config::decode('{"utm":{"theme":{"default":"3","weights":{}}}}'))===[],'manual numeric theme variant accepted'); +rejects(static fn()=>Config::validate(Config::decode('{"utm":{"degr":{"default":"1","weights":{"1":-1}}}}')),'negative UTM weight rejected'); +check(Config::validate(Config::decode('{"utm":{"indpic":{"default":"1","weights":{"1":0}}}}'))===[],'config editor does not require a physical set'); +check(Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":0,"light":0}}}}'))===[],'legacy semantic enum remains valid without rewriting'); $current=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$current['version']]],'changes'=>[['path'=>['custom','empty'],'value'=>(object)[]]]]);$jobs->run($job['id']); check($transport->readConfig()['config']->custom->empty instanceof stdClass,'empty JSON object survives persisted job'); $state=$transport->readConfig();$id=$jobs->create('apply',['targets'=>[['id'=>'test','version'=>$state['version']]],'changes'=>[['path'=>['host'],'value'=>'resumed.example']]])['id']; @@ -28,7 +28,7 @@ $inflated=$root.'/inflated.zip';file_put_contents($inflated,rawZip([['1/01_desk_ $result=$sets->upload($target,1,$prepared,'Тестовый набор');check(str_contains($result['message'],'90'),'local set publication revalidates delivered files'); $sets->check($target,1);check(is_file($root.'/site/1/01_desk_1-source.jpg'),'set directory published'); check((fileperms($root.'/site/1')&0777)===0755,'published set directory is readable by independent web-server UID'); -rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":100}}}}'),static fn()=>true),'default without weight variant rejected instead of runtime fallback'); +rejects(fn()=>Config::validate(Config::decode('{"utm":{"indpic":{"default":"2","weights":{"1":100}}}}')),'default without weight variant rejected instead of runtime fallback'); $kept=hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg');$badFiles=[];foreach(array_keys(IndpicSetStorage::expectedDimensions()) as $filename)$badFiles[$filename]=['data'=>base64_encode(file_get_contents($prepared.'/1/'.$filename)),'sha256'=>str_repeat('0',64)]; rejects(fn()=>$transport->call(['op'=>'set-write','number'=>1,'files'=>$badFiles,'expected_manifest'=>$sets->manifest($target,1)]),'delivery hash mismatch rejects activation');check(hash_file('sha256',$root.'/site/1/01_desk_1-source.jpg')===$kept,'failed set publication preserves previous working image'); // Настоящий формат должен совпадать с расширением, даже если размеры верны. diff --git a/admin/tests/catalog-publish.sh b/admin/tests/catalog-publish.sh deleted file mode 100644 index 5564995..0000000 --- a/admin/tests/catalog-publish.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -# Запуск только в одноразовом Docker-контейнере от root, без host state/socket. -set -eu -test "$(id -u)" = 0 -test ! -e /srv/preland-admin -test ! -e /usr/bin/docker -base=/srv/preland-admin -install -d -m 700 "$base" "$base/secrets" "$base/deploy" /tmp/catalog-victim -install -d -m 700 -o 997 -g 987 "$base/state" -printf 'unchanged\n' > /tmp/catalog-victim/original -printf 'not executed\n' > "$base/deploy/export-catalog.php" -cat > /usr/bin/docker <<'SH' -#!/bin/sh -printf '%s\n' '{"host":"zoomzilla.ru","generated_at":"2026-10-25T00:00:00Z","groups":{"degr":[],"tp":[],"cons":[]}}' -SH -chmod 755 /usr/bin/docker -# Унаследованный cwd моделирует доступ к корню bind mount внутри приложения, -# минуя закрытый host-parent /srv/preland-admin. -(cd "$base/state" && setpriv --reuid=997 --regid=987 --clear-groups ln -s /tmp/catalog-victim catalog.json) -sed 's/\r$//' /work/admin/deploy/refresh-catalog.sh > /tmp/catalog-refresh.sh -bash /tmp/catalog-refresh.sh -test ! -L "$base/state/catalog.json" -test -f "$base/state/catalog.json" -test "$(stat -c %u /tmp/catalog-victim/original)" = 0 -test "$(cat /tmp/catalog-victim/original)" = unchanged -test "$(find /tmp/catalog-victim -type f | wc -l)" = 1 -test "$(stat -c '%u:%g:%a' "$base/state/catalog.json")" = 997:987:600 -if setpriv --reuid=997 --regid=987 --clear-groups ls "$base/secrets" >/dev/null 2>&1; then - echo 'FAIL: app UID can inspect root preparation directory' >&2 - exit 1 -fi -echo 'PASS root catalog publisher replaces destination symlink without following it; victim and owner intact; preparation inaccessible to app UID' diff --git a/admin/tests/copy.php b/admin/tests/copy.php index ce47910..b0dcf04 100644 --- a/admin/tests/copy.php +++ b/admin/tests/copy.php @@ -44,9 +44,8 @@ try{ foreach(['{"utm":{"chob":{"default":"a","weights":{"a":100}},"bchoice":{"default":"a","weights":{"a":100}}}}','{"utm":{"reg":null}}','{"utm":{"theme":{"default":"dark","weights":{"dark":33.33,"1":66.67}}}}','{"utm":{"focus":{"default":true,"weights":{"1":100}}}}','{"host":null}','{"utm":[]}'] as $json){fixture('source',$json);[$status]=copyRequest(copyBody(),$csrf);copyCheck($status===422,'source invalid/ambiguous canonical structure rejected before job');} fixture('source','{"utm":{"reg":{"default":"before","weights":{"before":100}}}}');fixture('c','{"host":"keep.example","utm":{"focus":{"default":2,"weights":{"2":0},"meta":[]}},"x":{}}'); [$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);$c=readCopy('c')['config'];copyCheck($c->host==='keep.example'&&$c->utm->focus->default===2&&$c->utm->focus->meta===[]&&$c->x instanceof stdClass,'missing source host/groups leave recipient values and types unchanged'); - fixture('source','{"utm":{"indpic":{"default":"1","weights":{"1":100}}}}');fixture('c','{"utm":{}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobId=$body['job']['id'];$before=readCopy('c')['content'];$backupCount=count($store->backups('c'));$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='error'&&readCopy('c')['content']===$before&&count($store->backups('c'))===$backupCount,'copy validates physical indpic on recipient and fails before write/backup'); + fixture('source','{"utm":{"indpic":{"default":"1","weights":{"1":100}}}}');fixture('c','{"utm":{}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobId=$body['job']['id'];$before=readCopy('c')['content'];$backupCount=count($store->backups('c'));$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='success'&&readCopy('c')['config']->utm->indpic->default==='1'&&count($store->backups('c'))===$backupCount+1&&!is_dir($paths['c'].'/1'),'copy writes manual indpic without requiring physical images'); mkdir($paths['c'].'/1',0700);foreach(IndpicSetStorage::expectedDimensions() as $name=>$size){$image=new Imagick();$image->newImage($size['width'],$size['height'],'#777777');$image->setImageFormat(pathinfo($name,PATHINFO_EXTENSION));file_put_contents($paths['c'].'/1/'.$name,$image->getImagesBlob());$image->clear();} - $jobs->retry($jobId,['c']);$jobs->run($jobId);copyCheck($jobs->get($jobId)['results'][0]['status']==='success','failed copy retries same snapshot after missing physical set is repaired'); fixture('source','{"host":"both.example","utm":{"tp":{"default":"2","weights":{"2":100}},"cons":{"default":1,"weights":{"1":0}},"chb":null}}'); [$status,$body]=copyRequest(copyBody(['a','c']),$csrf);$both=$body['job']['id'];$cBefore=readCopy('c');$jobs->run($both);$done=$jobs->get($both);$c=readCopy('c'); copyCheck($done['results'][0]['status']==='success'&&$done['results'][1]['status']==='success'&&readCopy('a')['config']->utm->tp->weights->{'2'}===100&&$c['config']->utm->cons->default===1,'one saved snapshot copies to two successful recipients including tp/cons'); @@ -55,7 +54,10 @@ try{ copyCheck($jobs->get($both)['results'][1]['status']==='success'&&readCopy('c')['content']===$c['content']&&count($store->backups('c'))===$backupCount,'copy desired-hash recovery confirms already-written config without second write or backup'); $rejected=false;try{$jobs->retry($both,['a','c']);}catch(Problem $e){$rejected=$e->status===422;} copyCheck($rejected&&count($store->backups('c'))===$backupCount,'successful copy recipients cannot be retried and do not acquire duplicate backups'); - fixture('source','{"host":"catalog.example","utm":{"degr":{"default":"2","weights":{"2":100}}}}');fixture('c','{"host":"old.example","utm":{}}');$store->write('catalog.json',['host'=>'catalog.example','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[],'cons'=>[]]]);[$status,$body]=copyRequest(copyBody(['c']),$csrf);$before=readCopy('c')['content'];$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&readCopy('c')['content']===$before,'copy validates target result against fresh catalog for copied host'); + fixture('source','{"host":"catalog.example","utm":{"degr":{"default":"2","weights":{"2":100}}}}');fixture('c','{"host":"old.example","utm":{}}');$store->write('catalog.json',['host'=>'catalog.example','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[['number'=>1,'name'=>'one']],'tp'=>[],'cons'=>[]]]);[$status,$body]=copyRequest(copyBody(['c']),$csrf);$before=readCopy('c')['content'];$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='success'&&readCopy('c')['config']->utm->degr->default==='2','copy ignores catalog and preserves manually selected group'); + fixture('source','{"utm":{"theme":{"default":"3","weights":{"1":0,"2":0,"3":100}}}}');fixture('c','{"utm":{"theme":{"default":"light","weights":{"dark":0,"light":100},"extra":"keep"},"focus":{"default":"2","weights":{"2":100}}}}'); + [$status,$body]=copyRequest(copyBody(['c']),$csrf);copyCheck($status===202,'copy queues arbitrary theme3');$jobs->run($body['job']['id']);$copied=readCopy('c')['config']; + copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='success'&&$copied->utm->theme->default==='3'&&get_object_vars($copied->utm->theme->weights)===['dark'=>0,'light'=>0,3=>100]&&$copied->utm->theme->extra==='keep'&&$copied->utm->focus->default==='2','copy keeps numeric theme3 beside semantic aliases and retains recipient-only group'); fixture('source','{"host":"safe.example"}');fixture('c','{"utm":{"chb":null}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error'&&property_exists(readCopy('c')['config']->utm,'chb'),'copy preserves obsolete chb write guard rather than silently deleting it'); fixture('c','{"utm":{"chob":{"default":"a","weights":{"a":100}},"bchoice":{"default":"b","weights":{"b":100}}}}');[$status,$body]=copyRequest(copyBody(['c']),$csrf);$jobs->run($body['job']['id']);copyCheck($jobs->get($body['job']['id'])['results'][0]['status']==='error','ambiguous target aliases rejected per domain even if source group absent'); fixture('c','{"host":"safe.example"}');$backup=$store->backup('c','{"utm":{"chb":null}}');$restore=$jobs->create('restore',['target'=>'c','version'=>readCopy('c')['version'],'id'=>$backup]);$jobs->run($restore['id']);copyCheck($jobs->get($restore['id'])['results'][0]['status']==='error'&&readCopy('c')['config']->host==='safe.example','restore retains chb publication guard after copy integration'); diff --git a/admin/tests/link-builder.php b/admin/tests/link-builder.php index e08fe03..3b16646 100644 --- a/admin/tests/link-builder.php +++ b/admin/tests/link-builder.php @@ -53,7 +53,12 @@ $targets = $linkStore->read('targets.json');$targets['first']['domain']='renamed $renamed = $builder->get('first'); linkCheck(str_starts_with($renamed['url'],'https://renamed.example/?') && !str_contains($renamed['url'],'first.example'), 'domain rename rebases saved URL to current origin'); linkCheck($renamed['selection']===$modified && $renamed['saved_at']===$updated['saved_at'] && parse_url($renamed['url'],PHP_URL_QUERY)===parse_url($updated['url'],PHP_URL_QUERY), 'domain rename preserves selection, timestamp and existing query noise'); -foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'9']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) { +foreach (array_keys($selection) as $key) { + $manualSelection=array_fill_keys(array_keys($selection),'rnd');$manualSelection[$key]='9'; + $manual=$builder->save('second',$manualSelection);parse_str(parse_url($manual['url'],PHP_URL_QUERY),$manualParameters); + linkCheck($builder->get('second')===$manual&&$manual['selection'][$key]==='9'&&$manualParameters[$key][4]==='9','manual digit 9 encodes and persists for '.$key); +} +foreach ([[], array_diff_key($selection,['focus'=>true]), $selection+['unexpected'=>'1'], array_replace($selection,['theme'=>'10']), array_replace($selection,['indpic'=>'0']), array_replace($selection,['cons'=>1]), array_replace($selection,['tp'=>'rnd&host=evil']), array_replace($selection,['reg'=>['1']])] as $invalid) { linkReject(fn()=>$builder->save('first',$invalid),'strict selection rejects missing, unknown, wrong-type or injected value'); } linkReject(fn()=>$builder->get('../targets'),'target traversal rejected'); diff --git a/admin/tests/manual-variants.php b/admin/tests/manual-variants.php new file mode 100644 index 0000000..b265300 --- /dev/null +++ b/admin/tests/manual-variants.php @@ -0,0 +1,18 @@ +['utm','theme','weights'],'value'=>(object)['1'=>25,'2'=>25,'3'=>50]],['path'=>['utm','theme','default'],'value'=>'3']]); +Config::validate($next); +manualCheck($next->utm->theme->default==='3'&&get_object_vars($next->utm->theme->weights)===['dark'=>25,'light'=>25,3=>50]&&$next->utm->theme->note==='keep','semantic target retains aliases while adding numeric variant 3'); +foreach(['0','10','-1','green'] as $variant){try{Config::validate(Config::decode('{"utm":{"theme":{"default":"'.$variant.'","weights":{}}}}'));throw new RuntimeException('Accepted invalid number');}catch(Problem){manualCheck(true,'invalid variant rejected '.$variant);}} +try{Config::validate(Config::decode('{"utm":{"theme":{"default":"dark","weights":{"dark":50,"1":50}}}}'));throw new RuntimeException('Accepted duplicate');}catch(Problem){manualCheck(true,'semantic numeric duplicate remains rejected');} +$root=sys_get_temp_dir().'/manual-variants-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'site','domain'=>'site.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('site');file_put_contents($root.'/site/config.json','{"utm":{"theme":{"default":"dark","weights":{"dark":100,"light":0}}}}');$transport=new Transport($store,$store->target('site'));$jobs=new Jobs($store); +$read=$transport->readConfig();$job=$jobs->create('apply',['targets'=>[['id'=>'site','version'=>$read['version']]],'changes'=>[['path'=>['utm','theme','weights'],'value'=>(object)['1'=>0,'2'=>0,'3'=>100]],['path'=>['utm','theme','default'],'value'=>'3'],['path'=>['utm','indpic'],'value'=>(object)['default'=>'9','weights'=>(object)['9'=>100]]]]]);$jobs->run($job['id']);$read=$transport->readConfig();manualCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$read['config']->utm->theme->default==='3'&&$read['config']->utm->indpic->default==='9'&&!is_dir($root.'/site/9'),'apply writes theme3 and indpic9 without probing public files'); +$backup=$store->backup('site','{"utm":{"theme":{"default":"3","weights":{"3":100}},"degr":{"default":"9","weights":{"9":100}}}}');$store->write('catalog.json',['host'=>'zoomzilla.ru','generated_at'=>gmdate(DATE_ATOM),'groups'=>['degr'=>[],'tp'=>[],'cons'=>[]]]);$job=$jobs->create('restore',['target'=>'site','version'=>$read['version'],'id'=>$backup]);$jobs->run($job['id']);manualCheck($jobs->get($job['id'])['results'][0]['status']==='success'&&$transport->readConfig()['config']->utm->degr->default==='9','restore accepts manual variants without catalog'); diff --git a/admin/tests/obsolete-utm.php b/admin/tests/obsolete-utm.php index 4a08443..17521e8 100644 --- a/admin/tests/obsolete-utm.php +++ b/admin/tests/obsolete-utm.php @@ -6,7 +6,7 @@ function obsoleteCheck(bool $ok,string $message): void {if(!$ok)throw new Runtim function obsoleteReject(callable $call,string $message): void {try{$call();}catch(Problem $e){obsoleteCheck($e->status===422&&str_contains($e->getMessage(),'utm.chb'),$message);return;}throw new RuntimeException('Accepted obsolete key: '.$message);} $legacy='{"host":"zoomzilla.ru","utm":{"chb":null,"unknown":{"raw":true},"cons":{"default":"2","weights":{"1":0,"2":100}}},"custom":{"chb":true}}'; $raw=Config::decode($legacy);obsoleteCheck(property_exists($raw->utm,'chb')&&Config::decode(Config::encode($raw))->custom->chb===true,'legacy raw remains readable and encodable without migration'); -obsoleteReject(fn()=>Config::validate($raw,static fn()=>true),'validation rejects exact deprecated key even null'); +obsoleteReject(fn()=>Config::validate($raw),'validation rejects exact deprecated key even null'); foreach([ [['path'=>['host'],'value'=>'other.example']], [['path'=>['utm','chb'],'value'=>(object)['default'=>'1','weights'=>(object)['1'=>1]]]], @@ -14,7 +14,7 @@ foreach([ [['path'=>['utm'],'value'=>(object)['chb'=>null]]], ] as $changes)obsoleteReject(fn()=>Config::patch($changes[0]['path']===['utm','chb','default']?Config::decode('{"utm":{"chb":{}}}'):$raw,$changes),'patch refuses resulting chb at any depth or parent replacement'); $clean=Config::patch($raw,[['path'=>['utm','chb'],'delete'=>true]]);obsoleteCheck(!property_exists($clean->utm,'chb')&&Config::encode($clean->utm->cons)===Config::encode($raw->utm->cons)&&Config::encode($clean->utm->unknown)===Config::encode($raw->utm->unknown)&&$clean->custom->chb===true,'explicit delete removes only utm.chb and preserves unknown keys and cons'); -obsoleteCheck(Config::validate($clean,static fn()=>true)!==null,'cleaned config passes write validation'); +obsoleteCheck(Config::validate($clean)!==null,'cleaned config passes write validation'); $root=sys_get_temp_dir().'/obsolete-utm-'.bin2hex(random_bytes(6));mkdir($root,0700);mkdir($root.'/site',0700);$store=new Store($root.'/state');putenv('ADMIN_LOCAL_ROOTS='.$root.'/site');$store->saveTarget(['id'=>'a','name'=>'A','domain'=>'a.invalid','type'=>'local','root'=>$root.'/site','enabled'=>true]);$store->verifyTarget('a');$target=$store->target('a');$transport=new Transport($store,$target);file_put_contents($root.'/site/config.json',$legacy);$read=$transport->readConfig();obsoleteCheck($read['content']===$legacy&&property_exists($read['config']->utm,'chb'),'actual transport reads obsolete raw without rewriting'); obsoleteReject(fn()=>$transport->writeConfig($read['version'],$legacy),'direct transport rejects obsolete write');obsoleteCheck(file_get_contents($root.'/site/config.json')===$legacy&&$store->backups('a')===[],'rejected direct transport creates no backup and changes no runtime bytes'); $remotePrivate=$root.'/remote-private';$remote=Remote::execute(['op'=>'write','root'=>$root.'/site','local_private'=>$remotePrivate,'version'=>$read['version'],'content'=>$legacy]);obsoleteCheck(!$remote['ok']&&str_contains($remote['error'],'utm.chb'),'standalone remote rejects obsolete write');obsoleteCheck(!file_exists($remotePrivate)&&file_get_contents($root.'/site/config.json')===$legacy,'remote rejection happens before private directory, backup or temp creation'); diff --git a/admin/tests/percent-weights.php b/admin/tests/percent-weights.php index eb4d8fa..d272c2b 100644 --- a/admin/tests/percent-weights.php +++ b/admin/tests/percent-weights.php @@ -13,7 +13,7 @@ percentCheck($acceptedPrecision===[],'strict precision does not use binary toler foreach([(object)['1'=>1],(object)['1'=>33.333,'2'=>66.667],(object)['1'=>100.01],(object)['1'=>-1,'2'=>101],(object)['1'=>'100'],(object)['1'=>true],(object)['1'=>null],(object)['1'=>INF],(object)['1'=>NAN],(object)['1'=>50,'2'=>49.99],[],null] as $weights)percentReject(fn()=>PercentWeights::validate(percentConfig($weights)),'invalid stored weights rejected'); percentReject(fn()=>PercentWeights::validate((object)['utm'=>[]]),'invalid utm container rejected'); percentReject(fn()=>PercentWeights::validate((object)['utm'=>(object)['theme'=>null]]),'invalid known group rejected'); -percentReject(fn()=>Config::validate(percentConfig((object)['1'=>1]),static fn()=>true),'Config validation rejects relative weights'); +percentReject(fn()=>Config::validate(percentConfig((object)['1'=>1])),'Config validation rejects relative weights'); $source=percentConfig((object)['1'=>3,'2'=>1],'registration');$before=Config::encode($source);$migrated=PercentWeights::normalizeRelative($source);PercentWeights::validate($migrated); percentCheck(get_object_vars($migrated->utm->registration->weights)===['1'=>75,'2'=>25],'explicit migration produces integer percentages'); $rounded=PercentWeights::normalizeRelative(percentConfig((object)['1'=>2,'2'=>1]));percentCheck(get_object_vars($rounded->utm->theme->weights)===['1'=>66.67,'2'=>33.33],'migration rounds each share without redistribution'); diff --git a/admin/tests/ui-manual-variants.cjs b/admin/tests/ui-manual-variants.cjs new file mode 100644 index 0000000..8e13baa --- /dev/null +++ b/admin/tests/ui-manual-variants.cjs @@ -0,0 +1,7 @@ +/* preland#30: native manual config editing; Browser plugin not available. */ +const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs'); +(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',ids=['a','b','d'],original=new Map(ids.map(id=>[id,fs.readFileSync(root+'/'+id+'/config.json','utf8')]));const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>d.accept());page.setDefaultTimeout(7000);const cell=(id,m)=>page.locator('[data-matrix-target='+id+'][data-marker='+m+']'),save=id=>page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}),cancel=id=>page.getByRole('button',{name:'Отменить настройки '+id,exact:true}),read=id=>JSON.parse(fs.readFileSync(root+'/'+id+'/config.json','utf8'));const settled=async id=>page.waitForFunction(id=>/^(Прочитано с хостинга|Записано на хостинг)$/.test(document.querySelector('[data-overview-target='+id+'] .matrix-status').textContent),id,{timeout:60000});const add=async(id,m,n)=>{await cell(id,m).getByRole('button',{name:'Добавить вариант '+m+' '+id,exact:true}).click();const number=cell(id,m).getByLabel('Новый вариант '+m+' '+id,{exact:true}).last();assert.equal(await number.inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' вес новый '+id,{exact:true}).last().inputValue(),'');assert.equal(await cell(id,m).getByLabel(m+' default новый '+id,{exact:true}).last().isChecked(),false);assert.equal(await save(id).isDisabled(),true);await number.fill('0');assert.equal(await save(id).isDisabled(),true);await number.fill(n);await cell(id,m).getByLabel(m+' вес '+n+' '+id,{exact:true}).last().fill('0');};try{const semantic=read('a');semantic.utm.theme={default:'dark',weights:{dark:100,light:0}};semantic.utm.focus={default:'1',weights:{'2':100}};fs.writeFileSync(root+'/a/config.json',JSON.stringify(semantic));const d=read('d');delete d.utm.cons;fs.writeFileSync(root+'/d/config.json',JSON.stringify(d));await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b','c','d'])await settled(id); +for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus']){await add('a',m,'9');assert.equal(await cell('a',m).getByLabel(m+' вес 9 a',{exact:true}).inputValue(),'0');assert.equal(await cell('a',m).getByLabel(m+' default 9 a',{exact:true}).isChecked(),false);}await save('a').click();await settled('a');for(const m of ['indpic','theme','degr','reg','banch','tp','cons','focus'])assert.equal(read('a').utm[m].weights['9'],0,'new zero variant must persist '+m); +assert.equal(Object.hasOwn(read('a').utm.focus.weights,'1'),false,'synthetic missing default row must not be materialized by a manual zero addition');await add('a','theme','9');assert.match(await page.locator('[data-overview-target=a] .matrix-status').innerText(),/уникальны/);await cell('a','theme').getByLabel('Новый вариант theme a',{exact:true}).last().fill('3');await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).check();await save('a').click();await settled('a');assert.equal(read('a').utm.theme.default,'3');assert.equal(read('a').utm.theme.weights['3'],0);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await settled('a');assert.equal(await cell('a','theme').getByLabel('theme default 3 a',{exact:true}).isChecked(),true); +await add('d','cons','3');assert.equal(await save('d').isDisabled(),true,'new group requires explicit default');await cell('d','cons').getByLabel('cons default 3 d',{exact:true}).check();await save('d').click();await settled('d');assert.deepEqual(read('d').utm.cons,{default:'3',weights:{'3':0}}); +await page.locator('#copy-source').selectOption('a');await page.getByLabel('Копировать в b.invalid',{exact:true}).check();await page.locator('#copy-submit').click();await page.waitForFunction(()=>!document.querySelector('#copy-source').disabled,null,{timeout:60000});assert.equal(read('b').utm.theme.default,'3');assert.equal(read('b').utm.theme.weights['3'],0);assert.equal(read('b').utm.focus.weights['9'],0);await add('a','theme','8');await cancel('a').click();assert.equal(await cell('a','theme').getByLabel('theme вес 8 a',{exact:true}).count(),0);await page.locator('#builder-target').selectOption('a');await page.getByLabel('Ссылка: theme',{exact:true}).selectOption('3');await page.locator('#builder-save').click();await page.locator('#builder-result').waitFor({state:'visible'});assert.equal((await page.request.get('/api/link-builder?target=a').then(r=>r.json())).selection.theme,'3');assert.doesNotMatch(await page.locator('#builder-warnings').innerText(),/каталог/);await page.locator('#overview').screenshot({path:out+'/manual-variants-desktop.png'});await page.setViewportSize({width:390,height:844});await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:out+'/manual-variants-mobile.png'});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS all8 manual variants, zero Save/reread, theme3/default/builder, duplicate recovery, missing group/default, cancel, mobile, no JS errors');}finally{await browser.close();for(const [id,raw]of original)fs.writeFileSync(root+'/'+id+'/config.json',raw);}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-matrix.cjs b/admin/tests/ui-matrix.cjs index 63e4c2a..e752842 100644 --- a/admin/tests/ui-matrix.cjs +++ b/admin/tests/ui-matrix.cjs @@ -15,8 +15,8 @@ const {chromium}=require('playwright-core');const assert=require('node:assert/st const weights=cell('a','theme').locator('input[type=number]');assert.equal(await page.locator('.matrix-percent').count(),0);assert.equal(await weights.first().getAttribute('min'),'0');assert.equal(await weights.first().getAttribute('max'),'100');assert.equal(await weights.first().getAttribute('step'),'0.01');assert.match(await cell('a','theme').locator('.matrix-labels').innerText(),/Вес, %/); const defaultBefore=readFile('a').utm.theme.default;await weights.nth(0).fill('33.33');assert.equal(await weights.nth(1).inputValue(),'75','typing never redistributes other weights');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/сумма/);await weights.nth(1).fill('66.67');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 100%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':33.33,'2':66.67});assert.equal(readFile('a').utm.theme.default,defaultBefore);await page.getByRole('button',{name:'Обновить настройки a',exact:true}).click();await saved('a');assert.equal(await weights.first().inputValue(),'33.33'); await weights.first().fill('33.333');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('0.00000000001');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/двух знаков/);await weights.first().fill('-1');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('101');assert.equal(await save('a').isDisabled(),true);assert.match(await cell('a','theme').locator('.matrix-validation').innerText(),/от 0 до 100/);await weights.first().fill('');assert.equal(await save('a').isDisabled(),true);await weights.first().fill('0');await weights.nth(1).fill('0');assert.equal(await save('a').isEnabled(),true);assert.equal(await cell('a','theme').locator('.matrix-total').innerText(),'Сумма: 0%');await save('a').click();await saved('a');assert.deepEqual(readFile('a').utm.theme.weights,{'1':0,'2':0});assert.equal(readFile('a').utm.theme.default,defaultBefore);await weights.first().fill('25');await weights.nth(1).fill('75');await save('a').click();await saved('a'); - assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] button').count(),0); - assert.equal(await cell('d','cons').innerText(),'Группа отсутствует');assert.equal(readFile('d').utm.cons,undefined); + assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] .matrix-remove').count(),0); + assert.equal(await cell('d','cons').locator('.matrix-variant').count(),0);assert.equal(await cell('d','cons').locator('.matrix-add-variant').count(),1);assert.equal(readFile('d').utm.cons,undefined); const beforeDefault=readFile('a');await cell('a','theme').locator('input[type=radio]').first().check();await save('a').click();await saved('a');assert.equal(await header('a').locator('.matrix-status').innerText(),'Записано на хостинг');assert.deepEqual(readFile('a').utm.theme.weights,beforeDefault.utm.theme.weights);assert.equal(readFile('a').utm.theme.default,'1');assert.equal(typeof readFile('a').utm.theme.default,typeof beforeDefault.utm.theme.default);await cell('a','theme').locator('input[type=radio]').nth(1).check();await save('a').click();await saved('a');assert.deepEqual(readFile('a'),beforeDefault); console.log('PASS edit/default/missing'); // Two independent drafts and note cursor remain through foreign-column save and read. await host('a').fill('draft-a.invalid');const handle=await host('a').elementHandle();await host('a').evaluate(n=>{n.focus();n.setSelectionRange(2,5);});await cell('b','theme').locator('input[type=number]').first().fill('70');await cell('b','theme').locator('input[type=number]').nth(1).fill('30');await save('b').click();await saved('b');assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);assert.equal(readFile('b').utm.theme.weights.dark,70);assert.equal(readFile('b').utm.theme.default,'light');assert.deepEqual(readFile('b').custom,JSON.parse(original.get('b')).custom);assert.deepEqual(readFile('b').requisites,JSON.parse(original.get('b')).requisites);await refresh();assert.equal(await host('a').inputValue(),'draft-a.invalid');assert.equal(await handle.evaluate(n=>n.isConnected),true);await cancel('a').click(); diff --git a/admin/tests/ui-read-status.cjs b/admin/tests/ui-read-status.cjs index c569f90..b6e9ddb 100644 --- a/admin/tests/ui-read-status.cjs +++ b/admin/tests/ui-read-status.cjs @@ -1,6 +1,6 @@ /* preland#29: actual rendered read lifecycle and immutable UTM identifiers. Browser plugin not available. */ const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs'); (async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[];page.on('pageerror',e=>errors.push(e.message));const header=id=>page.locator('[data-overview-target="'+id+'"]'),reload=id=>page.getByRole('button',{name:'Обновить настройки '+id,exact:true});const idle=()=>page.waitForFunction(()=>[...document.querySelectorAll('.matrix-status')].every(n=>!n.textContent.includes('Чтение')));try{await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await idle(); -assert.equal(await page.locator('[data-overview-utm] select').count(),0,'UTM numbers must be static');assert.equal(await page.locator('[data-overview-utm] button').count(),0,'UTM variants/groups cannot be added or removed');assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);const date=header('a').locator('.matrix-read-at');assert.match(await date.innerText(),/Последнее чтение: .*\d{2}:\d{2}:\d{2} МСК/);const before=await date.innerText(),foreign=await header('b').locator('.matrix-read-at').innerText();let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);const delay=async route=>{const response=await route.fetch();started();await gate;await route.fulfill({response});};await page.route('**/api/config?target=a',delay);await reload('a').click();await seen;assert.equal(await reload('a').isDisabled(),true);assert.match(await header('a').locator('.matrix-status').innerText(),/Чтение с хостинга/);assert.equal(await date.innerText(),before);await page.waitForTimeout(1100);release();await page.waitForFunction(()=>!document.querySelector('[data-overview-target=a] .matrix-status').textContent.includes('Чтение'));await page.unroute('**/api/config?target=a',delay);assert.equal(await reload('a').isEnabled(),true);assert.equal(await header('b').locator('.matrix-read-at').innerText(),foreign);const success=await date.innerText(); +assert.equal(await page.locator('[data-overview-utm] select').count(),0,'UTM numbers must be static');assert.equal(await page.locator('[data-overview-utm] .matrix-remove').count(),0,'UTM variants cannot be removed');assert.equal(await page.locator('[data-overview-utm] .matrix-add-variant').count(),32,'manual add on every UTM/domain');assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);const date=header('a').locator('.matrix-read-at');assert.match(await date.innerText(),/Последнее чтение: .*\d{2}:\d{2}:\d{2} МСК/);const before=await date.innerText(),foreign=await header('b').locator('.matrix-read-at').innerText();let release,started;const gate=new Promise(r=>release=r),seen=new Promise(r=>started=r);const delay=async route=>{const response=await route.fetch();started();await gate;await route.fulfill({response});};await page.route('**/api/config?target=a',delay);await reload('a').click();await seen;assert.equal(await reload('a').isDisabled(),true);assert.match(await header('a').locator('.matrix-status').innerText(),/Чтение с хостинга/);assert.equal(await date.innerText(),before);await page.waitForTimeout(1100);release();await page.waitForFunction(()=>!document.querySelector('[data-overview-target=a] .matrix-status').textContent.includes('Чтение'));await page.unroute('**/api/config?target=a',delay);assert.equal(await reload('a').isEnabled(),true);assert.equal(await header('b').locator('.matrix-read-at').innerText(),foreign);const success=await date.innerText(); const fail=route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({ok:false,error:'Ошибка чтения fixture #29'})});await page.route('**/api/config?target=a',fail);await reload('a').click();await header('a').getByText(/Ошибка чтения fixture/).waitFor();assert.equal(await date.innerText(),success);assert.equal(await reload('a').isEnabled(),true);await page.unroute('**/api/config?target=a',fail);await page.waitForTimeout(1100);await reload('a').click();await idle();assert.match(await header('a').locator('.matrix-status').innerText(),/Прочитано с хостинга/);assert.notEqual(await date.innerText(),success); const host=page.getByLabel('Основной сайт a',{exact:true});await host.fill('draft-29.invalid');page.once('dialog',d=>d.dismiss());await reload('a').click();assert.equal(await host.inputValue(),'draft-29.invalid');page.once('dialog',d=>d.accept());await reload('a').click();await idle();assert.notEqual(await host.inputValue(),'draft-29.invalid');assert.deepEqual(errors,[]);await page.locator('#overview').screenshot({path:'/output/read-desktop.png'});await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);await page.locator('#overview').scrollIntoViewIfNeeded();await page.screenshot({path:'/output/read-mobile.png'});console.log('PASS static UTM / actual GET loading+disabled/date / error retains date / retry / foreign date / dirty dismiss+confirm / desktop/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-set-new-replace.cjs b/admin/tests/ui-set-new-replace.cjs new file mode 100644 index 0000000..65feb0f --- /dev/null +++ b/admin/tests/ui-set-new-replace.cjs @@ -0,0 +1,5 @@ +/* preland#30: upload a new physical set, manually add its UTM, replace through row control. */ +const {chromium}=require('playwright-core'),assert=require('node:assert/strict'),fs=require('node:fs'); +(async()=>{const root=process.env.FIXTURE_ROOT||'/fixture',out=process.env.UI_OUTPUT||'/output',raw=fs.readFileSync(root+'/a/config.json','utf8'),stateFiles=['sets.json','checks.json'],snap=new Map(stateFiles.map(name=>[name,fs.existsSync(root+'/state/'+name)?fs.readFileSync(root+'/state/'+name):null]));assert.ok(!fs.existsSync(root+'/a/9'),'isolated fixture must have no set9');const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH,args:['--no-sandbox']}),page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://host.docker.internal:18093',viewport:{width:1440,height:1000}}),errors=[],dialogs=[];page.on('pageerror',e=>errors.push(e.message));page.on('dialog',d=>{dialogs.push(d.message());d.accept();});page.setDefaultTimeout(15000);const config=()=>JSON.parse(fs.readFileSync(root+'/a/config.json','utf8')),done=async()=>{await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();};try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill('admin');await page.getByLabel('Пароль',{exact:true}).fill('test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#set-add input[name=number]').fill('9');await page.locator('#set-add input[name=name]').fill('Новый сет 9');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add').screenshot({path:out+'/set-new-form.png'});await page.locator('#set-add button').click();await done();assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),raw,'new ZIP does not change config');assert.equal(fs.readdirSync(root+'/a/9').length,90);const row=page.locator('#sets-body tr').filter({has:page.getByLabel('Название Set 9',{exact:true})});await row.waitFor();assert.equal(await row.getByLabel('Название Set 9',{exact:true}).inputValue(),'Новый сет 9');assert.equal(dialogs.length,0,'new set upload does not ask replacement'); +const cell=page.locator('[data-matrix-target=a][data-marker=indpic]');assert.equal(await cell.getByLabel('indpic вес 9 a',{exact:true}).count(),0);await cell.getByRole('button',{name:'Добавить вариант indpic a',exact:true}).click();await cell.screenshot({path:out+'/variant-empty.png'});await cell.getByLabel('Новый вариант indpic a',{exact:true}).fill('9');await cell.getByLabel('indpic вес 9 a',{exact:true}).fill('0');const refreshedSets=page.waitForResponse(r=>r.url().endsWith('/api/sets?target=a'));await page.getByRole('button',{name:'Сохранить настройки a',exact:true}).click();await page.waitForFunction(()=>document.querySelector('[data-overview-target=a] .matrix-status').textContent==='Записано на хостинг',null,{timeout:60000});assert.equal(config().utm.indpic.weights['9'],0);const after=fs.readFileSync(root+'/a/config.json','utf8');await (await refreshedSets).finished();await row.getByLabel('Название Set 9',{exact:true}).waitFor(); +await row.getByLabel('ZIP Set 9',{exact:true}).setInputFiles(process.env.VALID_ZIP);await row.getByRole('button',{name:'Загрузить ZIP',exact:true}).click();await done();assert.equal(fs.readFileSync(root+'/a/config.json','utf8'),after,'replace ZIP does not change config');assert.equal(dialogs.length,1);assert.match(dialogs[0],/Заменить существующий набор 9/);assert.equal(fs.readdirSync(root+'/a/9').length,90);await page.locator('#indpic').screenshot({path:out+'/set-new-replaced.png'});assert.deepEqual(errors,[]);console.log('PASS absent set9 upload90files/no configwrites/no replaceconfirm; manual indpic9 zero Save; existing set9 ZIP replace90files/confirm/no configwrites');}finally{await browser.close();fs.writeFileSync(root+'/a/config.json',raw);fs.rmSync(root+'/a/9',{recursive:true,force:true});for(const[name,content]of snap){if(content===null)fs.rmSync(root+'/state/'+name,{force:true});else fs.writeFileSync(root+'/state/'+name,content);}}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-smoke.cjs b/admin/tests/ui-smoke.cjs index 966789c..e51337b 100644 --- a/admin/tests/ui-smoke.cjs +++ b/admin/tests/ui-smoke.cjs @@ -1,3 +1,3 @@ /* preland#25: readonly smoke + real local draft/cancel. Writes are covered by ui-matrix.cjs. */ const {chromium}=require('playwright-core'),assert=require('node:assert/strict'); -(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const id=(await page.request.get('/api/state').then(r=>r.json())).targets[0].id,host=page.getByLabel('Основной сайт '+id,{exact:true});await host.waitFor();const original=await host.inputValue();await host.fill('draft-smoke.invalid');assert.match(await page.locator('[data-overview-target="'+id+'"] .matrix-status').innerText(),/Есть изменения/);await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await host.inputValue(),original);assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] button').count(),0);assert.match(await page.locator('.matrix-read-at').first().innerText(),/МСК/);assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS login/nonblank/matrix input-cancel/ZIP table/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin',viewport:{width:1440,height:1000}});const errors=[];page.on('pageerror',e=>errors.push(e.message));await page.goto('/');assert.match(await page.title(),/Прелендинги/);await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();const id=(await page.request.get('/api/state').then(r=>r.json())).targets[0].id,host=page.getByLabel('Основной сайт '+id,{exact:true});await host.waitFor();const original=await host.inputValue();await host.fill('draft-smoke.invalid');assert.match(await page.locator('[data-overview-target="'+id+'"] .matrix-status').innerText(),/Есть изменения/);await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await host.inputValue(),original);assert.equal(await page.locator('[data-overview-utm] select,[data-overview-utm] .matrix-remove').count(),0);assert.match(await page.locator('.matrix-read-at').first().innerText(),/МСК/);assert.equal(await page.locator('#settings,#apply-panel,#config-fields').count(),0);assert.equal(await page.locator('.indpic-sets-table th').allTextContents().then(x=>x.join('|')),'Номер / название|Default|Проверка|ZIP|Действия');await page.setViewportSize({width:390,height:844});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);assert.deepEqual(errors,[]);console.log('PASS login/nonblank/matrix input-cancel/ZIP table/mobile/no JS errors');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/tests/ui-utm.cjs b/admin/tests/ui-utm.cjs index 96b46a2..defab3f 100644 --- a/admin/tests/ui-utm.cjs +++ b/admin/tests/ui-utm.cjs @@ -3,6 +3,6 @@ const {chromium}=require('playwright-core'),assert=require('node:assert/strict') (async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});const page=await browser.newPage({baseURL:process.env.ADMIN_URL||'http://admin'});page.on('dialog',d=>d.accept());const errors=[];page.on('pageerror',e=>errors.push(e.message));const originals=new Map(); const cell=id=>page.locator('[data-matrix-target="'+id+'"][data-marker=indpic]');const saved=async id=>page.waitForFunction(id=>document.querySelector('[data-overview-target="'+id+'"] .matrix-status').textContent.match(/^(Прочитано с хостинга|Записано на хостинг)$/),id,{timeout:90000});const save=async id=>{await page.getByRole('button',{name:'Сохранить настройки '+id,exact:true}).click();await saved(id);};const read=async id=>{const r=await page.request.get('/api/config?target='+id).then(r=>r.json());assert.ok(r.ok,r.error);return r;}; try{await page.goto('/');await page.getByLabel('Логин',{exact:true}).fill(process.env.ADMIN_USER||'admin');await page.getByLabel('Пароль',{exact:true}).fill(process.env.ADMIN_PASSWORD||'test-password');await page.getByRole('button',{name:'Войти',exact:true}).click();await page.locator('#workspace').waitFor();for(const id of ['a','b']){await saved(id);originals.set(id,(await read(id)).config);assert.ok(!Object.hasOwn(originals.get(id).utm.indpic.weights,'3'),'fixture originally omits3');} -await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();assert.ok(process.env.VALID_ZIP,'VALID_ZIP with set3 required');await page.locator('#set-add input[name=number]').fill('3');await page.locator('#set-add input[name=name]').fill('UI #25 — набор 3');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add button').click();await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();for(const id of ['a','b']){assert.deepEqual((await read(id)).config,originals.get(id),'ZIP leaves config unchanged');assert.equal(await cell(id).locator('input[type=radio][aria-label="indpic default 3 '+id+'"]').count(),0,'missing variant cannot be silently selected as default');assert.equal(await cell(id).locator('select,button').count(),0);const rows=cell(id).locator('.matrix-variant');const changed=rows.first(),oldWeight=await changed.locator('input[type=number]').inputValue();await changed.locator('input[type=number]').fill(String(Number(oldWeight)+1));await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await rows.first().locator('input[type=number]').inputValue(),oldWeight);const choice=rows.nth(await rows.evaluateAll(ns=>ns.findIndex(n=>!n.querySelector('input[type=radio]').checked))),digit=await choice.locator('.matrix-variant-number').innerText();await choice.locator('input[type=radio]').check();await save(id);const expected=structuredClone(originals.get(id));expected.utm.indpic.default=typeof expected.utm.indpic.default==='number'?Number(digit):digit;assert.deepEqual((await read(id)).config,expected);await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await saved(id);assert.equal(await cell(id).locator('input[type=radio]:checked').evaluate(n=>n.closest('.matrix-variant').querySelector('.matrix-variant-number').textContent),digit);} +await page.locator('#source-target').selectOption('a');await page.locator('#sets-body tr').first().waitFor();await page.locator('#targets input[value=b]').check();assert.ok(process.env.VALID_ZIP,'VALID_ZIP with set3 required');await page.locator('#set-add input[name=number]').fill('3');await page.locator('#set-add input[name=name]').fill('UI #25 — набор 3');await page.locator('#set-add input[type=file]').setInputFiles(process.env.VALID_ZIP);await page.locator('#set-add button').click();await page.locator('#result-body').getByText('Сохранено и проверено',{exact:false}).waitFor({timeout:90000});await page.getByRole('button',{name:'Готово',exact:true}).click();for(const id of ['a','b']){assert.deepEqual((await read(id)).config,originals.get(id),'ZIP leaves config unchanged');assert.equal(await cell(id).locator('input[type=radio][aria-label="indpic default 3 '+id+'"]').count(),0,'missing variant cannot be silently selected as default');assert.equal(await cell(id).locator('select,.matrix-remove').count(),0);const rows=cell(id).locator('.matrix-variant');const changed=rows.first(),oldWeight=await changed.locator('input[type=number]').inputValue();await changed.locator('input[type=number]').fill(String(Number(oldWeight)+1));await page.getByRole('button',{name:'Отменить настройки '+id,exact:true}).click();assert.equal(await rows.first().locator('input[type=number]').inputValue(),oldWeight);const choice=rows.nth(await rows.evaluateAll(ns=>ns.findIndex(n=>!n.querySelector('input[type=radio]').checked))),digit=await choice.locator('.matrix-variant-number').innerText();await choice.locator('input[type=radio]').check();await save(id);const expected=structuredClone(originals.get(id));expected.utm.indpic.default=typeof expected.utm.indpic.default==='number'?Number(digit):digit;assert.deepEqual((await read(id)).config,expected);await page.getByRole('button',{name:'Обновить настройки '+id,exact:true}).click();await saved(id);assert.equal(await cell(id).locator('input[type=radio]:checked').evaluate(n=>n.closest('.matrix-variant').querySelector('.matrix-variant-number').textContent),digit);} assert.deepEqual(await page.locator('#targets input:checked').evaluateAll(n=>n.map(i=>i.value)),['a','b']);assert.deepEqual(errors,[]);console.log('PASS actual two-target ZIP / static variants + existing default + weight cancel / per-column Save+reread / otherfields preserved'); }finally{for(const [id,config]of originals){const def=String(config.utm.indpic.default);for(const row of await cell(id).locator('.matrix-variant').all())if(await row.locator('.matrix-variant-number').innerText()===def)await row.locator('input[type=radio]').check();const button=page.getByRole('button',{name:'Сохранить настройки '+id,exact:true});if(await button.isEnabled()){await save(id);assert.deepEqual((await read(id)).config,config);}}await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/admin/views/dashboard.php b/admin/views/dashboard.php index ff56e9c..377449c 100644 --- a/admin/views/dashboard.php +++ b/admin/views/dashboard.php @@ -10,7 +10,7 @@ $adminJsVersion = substr(hash_file('sha256', __DIR__ . '/../public/assets/admin.