import { describe, expect, test } from "bun:test"; import { parseRuntimeEnv, renderRuntimeEnv } from "../src/config/env"; import { GECKO_DEFAULT_MAX_PACKET_SIZE, GECKO_DEFAULT_MIN_PACKET_SIZE, HY2XS_CONFIG_SCHEMA_VERSION, normalizeHysteriaObfsType, validateGeckoPacketSizes } from "../src/config/profile"; import { baselineConfig, envText } from "./fixtures"; describe("obfs type resolution", () => { test("новая установка без явного значения выбирает gecko", () => { const config = parseRuntimeEnv(envText({ HY2XS_HYSTERIA_OBFS_TYPE: null })); expect(config.hysteriaObfsType).toBe("gecko"); }); test("явный gecko принимается", () => { expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" }).hysteriaObfsType).toBe("gecko"); }); test("явный salamander принимается как compatibility fallback", () => { expect(baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" }).hysteriaObfsType).toBe("salamander"); }); test("неизвестный тип отклоняется", () => { expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "none" })).toThrow( /invalid HY2XS_HYSTERIA_OBFS_TYPE/ ); }); test("тип нечувствителен к окружающим пробелам, но не к регистру", () => { expect(normalizeHysteriaObfsType(" gecko ")).toBe("gecko"); expect(() => normalizeHysteriaObfsType("Gecko")).toThrow(/invalid HY2XS_HYSTERIA_OBFS_TYPE/); }); test("пустой obfs-пароль означает автогенерацию, а не пустое значение в конфиге", () => { for (const raw of ["", "__GENERATE__"]) { const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: raw }); expect(config.hysteriaObfsPassword).not.toBe(""); expect(config.hysteriaObfsPassword).not.toBe("__GENERATE__"); expect(config.hysteriaObfsPassword.length).toBeGreaterThanOrEqual(24); } }); test("два разбора генерируют разные пароли", () => { const first = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" }); const second = baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: "__GENERATE__" }); expect(first.hysteriaObfsPassword).not.toBe(second.hysteriaObfsPassword); }); test("obfs-пароль с кавычкой или переводом строки отклоняется", () => { expect(() => baselineConfig({ HY2XS_HYSTERIA_OBFS_PASSWORD: 'pa"ss' })).toThrow( /forbidden characters/ ); }); }); describe("gecko packet sizes", () => { test("baseline фиксирует upstream defaults 512/1200", () => { const config = baselineConfig(); expect(config.hysteriaGeckoMinPacketSize).toBe(512); expect(config.hysteriaGeckoMaxPacketSize).toBe(1200); expect(config.hysteriaGeckoMinPacketSize).toBe(GECKO_DEFAULT_MIN_PACKET_SIZE); expect(config.hysteriaGeckoMaxPacketSize).toBe(GECKO_DEFAULT_MAX_PACKET_SIZE); }); test("валидная пара принимается", () => { expect(() => validateGeckoPacketSizes(512, 1200)).not.toThrow(); expect(() => validateGeckoPacketSizes(700, 700)).not.toThrow(); }); test("max < min отклоняется", () => { expect(() => validateGeckoPacketSizes(1200, 512)).toThrow(/must be >= minPacketSize/); }); test("max > 2048 отклоняется", () => { expect(() => validateGeckoPacketSizes(512, 2049)).toThrow(/upstream limit is 2048/); }); test("max == 2048 принимается как граничное значение", () => { expect(() => validateGeckoPacketSizes(512, 2048)).not.toThrow(); }); test("неположительный min отклоняется", () => { expect(() => validateGeckoPacketSizes(0, 1200)).toThrow(/positive integer/); expect(() => validateGeckoPacketSizes(-1, 1200)).toThrow(/positive integer/); }); test("нецелые значения отклоняются", () => { expect(() => validateGeckoPacketSizes(512.5, 1200)).toThrow(/positive integer/); }); }); describe("config schema version", () => { // Отсутствующий маркер — самый вероятный признак конфигурации 0.x: до v1 // этого поля не существовало. Любой fallback здесь молча принял бы legacy. test("отсутствие значения трактуется как legacy и отклоняется", () => { expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }))).toThrow( /HY2XS_CONFIG_SCHEMA_VERSION отсутствует/ ); }); test("отказ по отсутствующей схеме указывает на чистую установку", () => { expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: null }))).toThrow( /legacy-cleanup/ ); }); test("пустое значение отклоняется так же, как отсутствующее", () => { expect(() => parseRuntimeEnv(envText({ HY2XS_CONFIG_SCHEMA_VERSION: "" }))).toThrow( /HY2XS_CONFIG_SCHEMA_VERSION отсутствует/ ); }); test("текущая схема принимается", () => { expect(baselineConfig().configSchemaVersion).toBe(HY2XS_CONFIG_SCHEMA_VERSION); }); test("схема v0/v1 отклоняется с указанием на чистую установку", () => { expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "1" })).toThrow( /requires a clean installation/ ); }); test("нечисловая схема отклоняется", () => { expect(() => baselineConfig({ HY2XS_CONFIG_SCHEMA_VERSION: "two" })).toThrow( /invalid HY2XS_CONFIG_SCHEMA_VERSION/ ); }); }); describe("renderRuntimeEnv round-trip", () => { // Регрессия: renderRuntimeEnv раньше печатал литерал `salamander`, из-за чего // записанный на диск runtime-конфиг расходился с разобранным. test("gecko переживает render → parse без потерь", () => { const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "gecko" }); const reparsed = parseRuntimeEnv(renderRuntimeEnv(config)); expect(reparsed).toEqual(config); }); test("salamander переживает render → parse без подмены на дефолт", () => { const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" }); const rendered = renderRuntimeEnv(config); expect(rendered).toContain("HY2XS_HYSTERIA_OBFS_TYPE=salamander"); expect(rendered).not.toContain("HY2XS_HYSTERIA_OBFS_TYPE=gecko"); expect(parseRuntimeEnv(rendered)).toEqual(config); }); test("render не теряет ни одно поле runtime-конфига", () => { const config = baselineConfig({ HY2XS_HYSTERIA_OBFS_TYPE: "salamander" }); const reparsed = parseRuntimeEnv(renderRuntimeEnv(config)); for (const key of Object.keys(config) as (keyof typeof config)[]) { expect(reparsed[key]).toEqual(config[key]); } }); test("схема конфигурации попадает в runtime-файл", () => { expect(renderRuntimeEnv(baselineConfig())).toContain( `HY2XS_CONFIG_SCHEMA_VERSION=${HY2XS_CONFIG_SCHEMA_VERSION}` ); }); }); describe("остальные production-инварианты", () => { test("IPv6 остаётся запрещённым", () => { expect(() => baselineConfig({ HY2XS_IPV6_ENABLED: "true" })).toThrow(/IPv4-only/); }); test("auth mode зафиксирован в http", () => { expect(() => baselineConfig({ HY2XS_HYSTERIA_AUTH_MODE: "password" })).toThrow( /HY2XS_HYSTERIA_AUTH_MODE is fixed/ ); }); test("ACME DNS challenge пока запрещён в production-профиле", () => { expect(() => baselineConfig({ HY2XS_ACME_TYPE: "dns" })).toThrow(/not supported in production profile/); }); }); /** * Адрес Traffic Stats API — внутренний контракт двух компонентов, а не * настройка. * * Проверка существует потому, что прежняя валидация была не просто мягкой, а * согласованно неверной: env принимал любой IPv4, шаблон честно его * подставлял, а `assertHysteriaConfigMatchesProfile` сверял конфиг с тем же * значением. Все гейты проходили, а продукт при этом переставал работать — * админка читает из `trafficStats.listen` только порт и всегда обращается на * loopback, поэтому расхождение адресов выключает лимит устройств, учёт * трафика и принудительное отключение разом (fail-closed: пиры не проходят * авторизацию вовсе). */ describe("адрес Traffic Stats API", () => { test("канонический 127.0.0.1 принимается", () => { expect(baselineConfig().hysteriaTrafficStatsHost).toBe("127.0.0.1"); }); test("значение по умолчанию не зависит от наличия ключа", () => { expect(baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: null }).hysteriaTrafficStatsHost).toBe("127.0.0.1"); }); // Wildcard достижим по loopback, поэтому связь компонентов он не ломает — // но публикует внутренний control plane на всех интерфейсах, и при // HY2XS_FIREWALL_MODE=external|off его не прикрывает ничто. test("0.0.0.0 отклоняется", () => { expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "0.0.0.0" })).toThrow( /TRAFFIC_STATS_HOST is fixed/ ); }); test("другой адрес loopback отклоняется", () => { expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "127.0.0.2" })).toThrow( /TRAFFIC_STATS_HOST is fixed/ ); }); test("адрес LAN отклоняется", () => { expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "192.168.1.10" })).toThrow( /TRAFFIC_STATS_HOST is fixed/ ); }); test("публичный адрес отклоняется", () => { expect(() => baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_HOST: "203.0.113.10" })).toThrow( /TRAFFIC_STATS_HOST is fixed/ ); }); // Порт настройкой остаётся: обе половины продукта берут его из одного и того // же `trafficStats.listen`, поэтому разойтись по нему они не могут. test("порт остаётся настраиваемым", () => { expect(baselineConfig({ HY2XS_HYSTERIA_TRAFFIC_STATS_PORT: "34712" }).hysteriaTrafficStatsPort).toBe(34712); }); });