Files
HY2XS_flamy/orchestrator/test/admin-credentials.test.ts
T
founder a8407cf16b fix(admin): вход в панель падал на теге правила, пережившего переименование
RC2 на чистом Debian 13 завершался INSTALL EXIT CODE: 0 при полностью
недоступной панели. На LoginDto.Username стоял тег `validateStr` — правило с
таким именем не регистрировалось: при переименовании в `credentialStr` правка
не доехала до одного файла, оставив мёртвую регистрацию и живую ссылку на
несуществующее имя. go-playground/validator на неизвестный тег ПАНИКУЕТ при
разборе структуры, то есть до всякой проверки логина и пароля, а gin.Recovery
превращал панику в HTTP 500 на каждый POST /api/auth/login.

Дефект пережил 311 Go-тестов, и это главное, что здесь чинится. Проверялся сам
регексп, в обход валидатора, а обработчика входа не касался ни один тест.
Очевидная замена не помогла бы: цепочка правил поля обрывается на первом
несработавшем, поэтому нулевое DTO отказывает по `required` и до испорченного
тега не доходит. Теперь TestEveryValidationTagIsRegistered обходит исходники
apps/model/**, вытаскивает каждый тег `validate:"…"` и предъявляет его
валидатору отдельно — незарегистрированное правило паникует так же, как в бою,
но на сборке. Барьер проверен возвратом исходного тега.

Установка тоже не отвечала на вопрос, ради которого проверялась. Smoke считал
панель работающей по трём признакам — юнит активен, порт в LISTEN, /healthz
отвечает ok, — и все три были истинны. Теперь smoke выполняет настоящий вход
bootstrap-учётными данными и требует конверт успеха с непустым токеном: по коду
HTTP это неотличимо, админка отвечает 200 OK и на отказ. Отрицательная проба
идёт в любом режиме операции и от актуальности пароля не зависит.

Рядом лежали три расхождения того же класса, найденные при разборе.

Оркестратор не знал контракта, который сам порождает: HY2XS_ADMIN_USER по
умолчанию был `admin` — пять символов при минимуме панели в шесть, — и такая
установка проходила целиком, создавая учётную запись, под которой невозможно
войти. Про одно имя существовало три расходящихся умолчания. Оба значения
теперь проверяются при разборе окружения — той стороной, которая их порождает:
отказ, пришедший установщику, чинится строкой в hy2xs.env, а неработающий вход
на готовом сервере — переустановкой.

Панель была строже сервера. Форма входа ограничивала пароль 32 символами при
серверном пределе в 64, а форма смены пароля назначала до 64: пароль,
назначенный штатной операцией, после этого не вводился. Набор символов на
пароле отвергал значение, которое сервер принял бы, — сервер его не
ограничивает нигде. Контракт учётных данных объявлен один раз в
service/admin_credentials.go, копии в панели и оркестраторе сверяются с ним
тестами, читающими Go-исходник.

Класс символов логина был записан диапазоном по опечатке: неэкранированный
дефис превращал `+-=` в диапазон, впускающий `, - . / 0-9 : ; < =`. С серверным
набором это совпадало только потому, что обе стороны несли одну опечатку. Набор
записан явно и НЕ сужен — он уже действует на установленных серверах.

Визуально: красная рамка отказа обводила не то, что видит оператор. Element Plus
рисует состояние ошибки на el-input__wrapper селектором из четырёх классов, а
форма входа рисует видимую рамку поля на el-form-item — внутрь поля кладутся
иконка, ввод и переключатель видимости — и гасила чужую тень селектором из трёх,
проигрывая по специфичности. Рамка ложилась вокруг одного лишь ввода: у логина
начиналась после иконки, у пароля обрывалась перед «глазом». Индикация
перенесена на элемент, который оператор и видит полем; чужая тень гасится
селектором, повторяющим её собственный и добавляющим атрибут scoped-стиля, —
конкретностью, а не !important. Остальные формы панели проверены: собственная
рамка на el-form-item есть только на форме входа.

Заодно: `last_login_at` объявлен в схеме и в entity, а писать его было некому —
UpdateAdminLastLoginAt не вызывался ниоткуда. Отметка ставится в service.Login
сразу после успешной проверки пароля; отказ записи вход не отменяет, но
попадает в журнал. Обработчик входа переехал из controller/peer.go в
controller/auth.go: стек в journal указывал на управление пирами.

Требование теперь называется, а не сообщается фактом нарушения. «Неверный
формат логина» и «Некорректное значение» не давали оператору способа узнать,
что от него хотят: набор символов приходит из hy2xs.env и в панели нигде не
показан. Фразы форм и серверная причина credential_format перечисляют границы
и набор.

Гейт сборки run_admin_login_acceptance удерживает барьеры от тихого удаления —
по той же причине, что и гейт детектора гонок. Каждое из его утверждений
проверено мутационной пробой на реальный отказ; две первые редакции оказались
вакуумными и переписаны.

Прогнано: go vet + go test ./... , bun test оркестратора (427) и контрактов
панели (66), vue-tsc --noEmit, production-сборка frontend, гейт приёмки
целиком. `go test -race` не прогонялся — на машине нет C-компилятора, это
релизный гейт сборщика.

Прогон задокументирован в
docs/acceptance/2026-09-04-v1.0.0-rc2-runtime-findings.md.
2026-09-04 02:32:50 +05:00

281 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import { parseRuntimeEnv } from "../src/config/env";
import {
ADMIN_LOGIN_PATH,
ADMIN_PASSWORD_MAX_LENGTH,
ADMIN_PASSWORD_MIN_LENGTH,
ADMIN_USERNAME_CHARSET,
ADMIN_USERNAME_MAX_LENGTH,
ADMIN_USERNAME_MIN_LENGTH,
ADMIN_USERNAME_PATTERN
} from "../src/config/profile";
import { baselineConfig, envText } from "./fixtures";
/**
* Контракт учётных данных администратора на стороне оркестратора.
*
* Оркестратор ПОРОЖДАЕТ имя и первый пароль администратора, а принимает их
* панель — по правилам, которых он раньше не знал вовсе. RC2 показал, чем это
* заканчивается: установка завершается `INSTALL EXIT CODE: 0`, а войти в панель
* нельзя. Отказ обязан приходить установщику, которому он чинится одной строкой
* в hy2xs.env, а не оператору на форме входа, где чинить его уже нечем.
*/
const REPO_ROOT = path.resolve(import.meta.dir, "..", "..");
function goContract(): string {
return fs.readFileSync(
path.join(REPO_ROOT, "apps", "service", "admin_credentials.go"),
"utf8"
);
}
function goConst(name: string): string {
const source = goContract();
const numeric = source.match(new RegExp(`\\b${name}\\s*=\\s*(\\d+)`));
if (numeric) {
return numeric[1];
}
const backticked = source.match(new RegExp(`\\b${name}\\s*=\\s*\`([^\`]*)\``));
if (backticked) {
return backticked[1];
}
throw new Error(`константа ${name} не найдена в apps/service/admin_credentials.go`);
}
describe("контракт учётных данных совпадает с админкой", () => {
// Копия контракта в TypeScript неизбежна: оркестратор собирается отдельно от
// Go-бинарника и импортировать его константы не может. Проверяется поэтому
// не отсутствие копии, а её совпадение с оригиналом — сверкой по исходнику.
test("границы длины совпадают с service.Admin*Length", () => {
expect(String(ADMIN_USERNAME_MIN_LENGTH)).toBe(goConst("AdminUsernameMinLength"));
expect(String(ADMIN_USERNAME_MAX_LENGTH)).toBe(goConst("AdminUsernameMaxLength"));
expect(String(ADMIN_PASSWORD_MIN_LENGTH)).toBe(goConst("AdminPasswordMinLength"));
expect(String(ADMIN_PASSWORD_MAX_LENGTH)).toBe(goConst("AdminPasswordMaxLength"));
});
test("набор символов логина совпадает с классом из Go", () => {
// Класс сравнивается как ЗНАЧЕНИЕ строки, а не как запись: в Go он живёт в
// raw-строке, в TypeScript — в обычной, где обратный слеш удваивается.
const declared = goConst("adminUsernameCharacterClass");
const mirrored = ADMIN_USERNAME_PATTERN.source.replace(
new RegExp(`^\\^\\[|\\]\\{${ADMIN_USERNAME_MIN_LENGTH},${ADMIN_USERNAME_MAX_LENGTH}\\}\\$$`, "g"),
""
);
expect(mirrored).toBe(declared);
});
test("человекочитаемый набор совпадает с показываемым админкой", () => {
expect(ADMIN_USERNAME_CHARSET).toBe(goConst("AdminUsernameCharset"));
});
// Дефис в классе символов ЭКРАНИРОВАН. Без экранирования `+-=` образует
// диапазон и молча впускает `, - . / 0-9 : ; < =` — ровно тот дефект, из-за
// которого фактическое множество разошлось с задуманным на всех четырёх
// сторонах продукта.
test("дефис в классе экранирован, а не образует диапазон", () => {
expect(ADMIN_USERNAME_PATTERN.source).toContain("\\-");
expect(ADMIN_USERNAME_PATTERN.source).not.toContain("+-=");
});
test("путь формы входа собран из базового пространства имён API", () => {
expect(ADMIN_LOGIN_PATH).toBe("/api/auth/login");
const router = fs.readFileSync(path.join(REPO_ROOT, "apps", "router", "auth.go"), "utf8");
expect(router).toContain('auth.POST("/login"');
expect(router).toContain('authApi.Group("/auth")');
});
});
describe("HY2XS_ADMIN_USER проверяется при разборе окружения", () => {
test("значение по умолчанию проходит контракт панели", () => {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
expect(config.adminUser).toBe("hy2xsadmin");
expect(ADMIN_USERNAME_PATTERN.test(config.adminUser)).toBe(true);
});
// Регрессия: умолчанием здесь стояло `admin` — пять символов при минимуме в
// шесть. Установка проходила целиком, а панель не впускала никого.
test("значение по умолчанию не короче минимума", () => {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_USER: null }));
expect(config.adminUser.length).toBeGreaterThanOrEqual(ADMIN_USERNAME_MIN_LENGTH);
});
// Умолчание обязано совпадать с запасным значением админки: иначе установка
// без явного HY2XS_ADMIN_USER создаёт одну учётную запись, а админка при
// повреждённом окружении ищет другую.
test("умолчание совпадает с запасным значением админки и с packaged env", () => {
const dao = fs.readFileSync(path.join(REPO_ROOT, "apps", "dao", "sqlite.go"), "utf8");
expect(dao).toContain('adminUser = "hy2xsadmin"');
const packaged = fs.readFileSync(
path.join(REPO_ROOT, "package", "config", "hy2xs.env"),
"utf8"
);
expect(packaged).toContain("HY2XS_ADMIN_USER=hy2xsadmin");
});
test("слишком короткий логин роняет установку", () => {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
/invalid HY2XS_ADMIN_USER/
);
});
test("слишком длинный логин роняет установку", () => {
expect(() =>
baselineConfig({ HY2XS_ADMIN_USER: "a".repeat(ADMIN_USERNAME_MAX_LENGTH + 1) })
).toThrow(/invalid HY2XS_ADMIN_USER/);
});
test("логин с пробелом, кириллицей или переводом строки роняет установку", () => {
for (const rejected of ["hy2xs admin", "админ12", "hy2xs\tadmin"]) {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: rejected })).toThrow(
/invalid HY2XS_ADMIN_USER/
);
}
});
test("отказ называет и границы, и набор символов", () => {
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(
new RegExp(`${ADMIN_USERNAME_MIN_LENGTH}.*${ADMIN_USERNAME_MAX_LENGTH}`)
);
expect(() => baselineConfig({ HY2XS_ADMIN_USER: "admin" })).toThrow(/a-z A-Z 0-9/);
});
// Значение уезжает в hy2xs.env и оттуда в базу. Пробел по краям превращался
// бы в часть имени учётной записи, и вход отказывал бы «неверным логином» —
// отказом, который невозможно связать с причиной.
test("окружающие пробелы снимаются, а не уезжают в имя учётной записи", () => {
expect(baselineConfig({ HY2XS_ADMIN_USER: " hy2xsadmin " }).adminUser).toBe("hy2xsadmin");
});
test("точка в логине принимается: набор не сужается задним числом", () => {
expect(baselineConfig({ HY2XS_ADMIN_USER: "admin.ops" }).adminUser).toBe("admin.ops");
});
});
describe("HY2XS_ADMIN_INITIAL_PASSWORD проверяется при разборе окружения", () => {
test("сгенерированный пароль проходит контракт панели", () => {
for (const raw of ["", "__GENERATE__"]) {
const config = parseRuntimeEnv(envText({ HY2XS_ADMIN_INITIAL_PASSWORD: raw }));
const length = [...config.adminInitialPassword].length;
expect(length).toBeGreaterThanOrEqual(ADMIN_PASSWORD_MIN_LENGTH);
expect(length).toBeLessThanOrEqual(ADMIN_PASSWORD_MAX_LENGTH);
}
});
test("слишком короткий пароль роняет установку", () => {
expect(() => baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: "12345" })).toThrow(
/invalid HY2XS_ADMIN_INITIAL_PASSWORD/
);
});
test("слишком длинный пароль роняет установку", () => {
expect(() =>
baselineConfig({
HY2XS_ADMIN_INITIAL_PASSWORD: "a".repeat(ADMIN_PASSWORD_MAX_LENGTH + 1)
})
).toThrow(/invalid HY2XS_ADMIN_INITIAL_PASSWORD/);
});
// Набор символов пароля сервером не ограничивается, и оркестратор не имеет
// права быть строже: отвергнутый здесь пароль сервер принял бы.
test("пароль из произвольных символов принимается", () => {
for (const password of ["p@ssw0rd?!", "пароль-администратора", "a b c d e f"]) {
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: password }).adminInitialPassword).toBe(
password
);
}
});
// Длина считается в РУНАХ: 32 кириллические буквы — это 64 байта, и проверка
// по байтам отвергла бы пароль, который панель принимает.
test("длина считается в символах, а не в байтах", () => {
const cyrillic = "я".repeat(ADMIN_PASSWORD_MAX_LENGTH);
expect(baselineConfig({ HY2XS_ADMIN_INITIAL_PASSWORD: cyrillic }).adminInitialPassword).toBe(
cyrillic
);
});
});
describe("smoke выполняет настоящий вход в панель", () => {
const smoke = () =>
fs.readFileSync(path.join(REPO_ROOT, "orchestrator", "src", "steps", "smoke.ts"), "utf8");
// Главный барьер этого релиза. Пока установка отвечала на вопрос «работает ли
// панель» только фактом LISTEN и ответом /healthz, RC2 объявлял успешной
// установку с полностью недоступной панелью.
test("smoke обращается к форме входа, а не только к healthz", () => {
const source = smoke();
expect(source).toContain("ADMIN_LOGIN_PATH");
expect(source).toContain("assertAdminLoginWorks");
expect(source).toContain("isSuccessfulLogin");
});
test("успех определяется конвертом ответа и выданным токеном", () => {
// Админка отвечает HTTP 200 и на отказ: проверка по коду HTTP приняла бы за
// успешный вход любой отказ.
const source = smoke();
expect(source).toMatch(/"code"\\s\*:\\s\*20000/);
expect(source).toMatch(/accessToken/);
});
test("учётные данные не попадают ни в команду ошибки, ни в сообщение", () => {
const source = smoke();
const start = source.indexOf("async function assertAdminLoginWorks");
expect(start).toBeGreaterThan(-1);
const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin"));
// runReadOnly (без Secret) кладёт команду в текст ошибки, а команда несёт
// пароль администратора.
expect(body).not.toMatch(/(?<!Secret)runReadOnly`/);
expect(body).toMatch(/runReadOnlySecret`/);
// Подстановка в tagged template — это НЕ утечка: значения там проходят
// через shellQuote и в текст ошибки не попадают. Утечкой была бы подстановка
// секрета в сообщение — в info() или в new Error(), — потому что оно уезжает
// в журнал установки и в diagnostics-бандл.
const messages = [
...body.matchAll(/\binfo\(([\s\S]*?)\);/g),
...body.matchAll(/new Error\(([\s\S]*?)\);/g)
].map((match) => match[1]);
expect(messages.length).toBeGreaterThan(0);
for (const message of messages) {
for (const secret of ["adminPassword", "payload", "response", "body}"]) {
expect(message).not.toContain(`\${${secret}`);
}
}
// Тело успешного ответа несёт токен доступа, поэтому наружу отдаётся только
// разбор ответа, а не он сам.
expect(body).toContain("describeLoginFailure(body, error)");
expect(body).toContain("describeIssuedToken(response)");
});
test("тело запроса собирается JSON.stringify, а не интерполяцией", () => {
// Пароль задаёт оператор: кавычка или обратный слеш в нём сломали бы сам
// запрос, и проверка объявила бы рабочую установку сломанной.
const body = smoke();
expect(body).toContain("JSON.stringify({ username: adminUser, pass: adminPassword })");
});
test("отрицательная проба не зависит от режима операции", () => {
// Пароль в bootstrap-admin.secret устаревает в момент, когда оператор сменил
// его в панели, поэтому положительная проба install-only. Отрицательная от
// пароля не зависит и обязана работать всегда: именно она воспроизводит
// дефект RC2.
const source = smoke();
const start = source.indexOf("async function assertAdminLoginWorks");
const body = source.slice(start, source.indexOf("\nfunction isSuccessfulLogin"));
const rejected = body.indexOf("admin login rejects wrong credentials");
const modeGate = body.indexOf('context.mode !== "install"');
expect(rejected).toBeGreaterThan(-1);
expect(modeGate).toBeGreaterThan(rejected);
});
});