- audiorecords вместо transcribe_jobs: приложения (texts, structures, recognitions, record_events, topics) живут своими коллекциями, ссылки на исходник и на приведённую копию перестали переставляться - рубеж называет достигнутое, отказ стал признаком остановки с причиной, а сторожей стало двое: число отказов и время в рубеже - воркеры потеряли специализацию, их число задаётся [pipeline] workers, шаг выбирается по рубежу, а захват отдаёт идентификатор и признак захвата
346 lines
15 KiB
Go
346 lines
15 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Проверка входа — единственная страховка от того, чтобы сервис поднялся с
|
|
// молча выключенным входом, то есть открытым наружу. До этих проверок она не
|
|
// исполнялась ни разу.
|
|
|
|
func validAuthConfig() AuthConfig {
|
|
return AuthConfig{
|
|
AuthURL: "https://auth.example.com/api/oidc/authorization",
|
|
TokenURL: "https://auth.example.com/api/oidc/token",
|
|
UserInfoURL: "https://auth.example.com/api/oidc/userinfo",
|
|
ClientID: "transcriber",
|
|
ClientSecret: "secret-value",
|
|
RedirectURL: "https://transcriber.example.com/auth/callback",
|
|
}
|
|
}
|
|
|
|
func TestAuthConfigValidateAcceptsFilled(t *testing.T) {
|
|
if err := validAuthConfig().Validate(); err != nil {
|
|
t.Fatalf("заполненный конфиг отвергнут: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAuthConfigValidateNamesEveryMissingKey(t *testing.T) {
|
|
cases := map[string]func(*AuthConfig){
|
|
"auth_url": func(c *AuthConfig) { c.AuthURL = "" },
|
|
"token_url": func(c *AuthConfig) { c.TokenURL = "" },
|
|
"user_info_url": func(c *AuthConfig) { c.UserInfoURL = "" },
|
|
"client_id": func(c *AuthConfig) { c.ClientID = "" },
|
|
"client_secret": func(c *AuthConfig) { c.ClientSecret = "" },
|
|
"redirect_url": func(c *AuthConfig) { c.RedirectURL = "" },
|
|
}
|
|
|
|
for key, clear := range cases {
|
|
t.Run(key, func(t *testing.T) {
|
|
cfg := validAuthConfig()
|
|
clear(&cfg)
|
|
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatalf("пустой ключ %s пропущен — сервис поднимется с выключенным входом", key)
|
|
}
|
|
if !strings.Contains(err.Error(), key) {
|
|
t.Fatalf("имя ключа %s не названо: %v", key, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAuthConfigValidateHidesSecretValue: сообщение об отказе уезжает в журнал,
|
|
// и значения секрета в нём быть не должно — только имя ключа.
|
|
func TestAuthConfigValidateHidesSecretValue(t *testing.T) {
|
|
cfg := validAuthConfig()
|
|
cfg.ClientSecret = "super-secret-value"
|
|
cfg.AuthURL = ""
|
|
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("отказа нет")
|
|
}
|
|
if strings.Contains(err.Error(), "super-secret-value") {
|
|
t.Fatalf("значение секрета попало в текст отказа: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestAuthConfigValidateRejectsMalformedURL: непустая строка, не похожая на
|
|
// адрес, отвергается здесь, а не позже — хранилище отказало бы уже из хука
|
|
// подъёма, до регистрации пробы здоровья, и сервис упал бы молча целиком.
|
|
func TestAuthConfigValidateRejectsMalformedURL(t *testing.T) {
|
|
cases := map[string]string{
|
|
"без схемы": "auth.example.com/api/oidc/authorization",
|
|
"пробел спереди": " https://auth.example.com/authorize",
|
|
"чужая схема": "ftp://auth.example.com/authorize",
|
|
"пустой хост": "https:///authorize",
|
|
"не адрес вовсе": "todo: заполнить",
|
|
}
|
|
|
|
for name, value := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
cfg := validAuthConfig()
|
|
cfg.AuthURL = value
|
|
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatalf("негодный адрес %q пропущен", value)
|
|
}
|
|
if !strings.Contains(err.Error(), "auth_url") {
|
|
t.Fatalf("имя ключа не названо: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Признак включения объявляет намерение, ключ доступа означает только доступ.
|
|
// Пока эти два значения жили в одном поле, пустой токен читался разом как
|
|
// «вход выключен» и как «ключ не доехал».
|
|
|
|
func TestTelegramConfigValidateAcceptsEnabledWithToken(t *testing.T) {
|
|
cfg := TelegramConfig{Enabled: true, BotToken: "123456:AA-fake"}
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatalf("включённый вход с ключом отвергнут: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTelegramConfigValidateRejectsEnabledWithoutToken(t *testing.T) {
|
|
cfg := TelegramConfig{Enabled: true, BotToken: ""}
|
|
|
|
err := cfg.Validate()
|
|
if err == nil {
|
|
t.Fatal("включённый вход без ключа доступа пропущен")
|
|
}
|
|
if !strings.Contains(err.Error(), "bot_token") {
|
|
t.Fatalf("имя ключа не названо: %v", err)
|
|
}
|
|
}
|
|
|
|
// Выключенный вход на ключ доступа не смотрит вовсе: пустой ключ при нём —
|
|
// обычное состояние локального прогона, а не ошибка настройки.
|
|
func TestTelegramConfigValidateIgnoresTokenWhenDisabled(t *testing.T) {
|
|
cfg := TelegramConfig{Enabled: false, BotToken: ""}
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
t.Fatalf("выключенный вход без ключа отвергнут: %v", err)
|
|
}
|
|
}
|
|
|
|
// Половина требования «сообщение не несёт значения ключа» на этой проверке
|
|
// **вакуумна**, и честнее это назвать, чем изображать сторожа.
|
|
//
|
|
// `Validate()` отказывает ровно на пустом ключе — значения, которым можно
|
|
// проговориться, на этом пути не существует. Прежняя редакция сторожа искала
|
|
// подстроку, которой в сообщении нет ни при каком входе, и потому не могла
|
|
// упасть вовсе: правка на `%q` от токена оставила бы её зелёной. В проекте это
|
|
// третий пойманный случай проверки, не способной упасть.
|
|
//
|
|
// Настоящий сторож той же нормы живёт там, где непустой ключ в отказ попасть
|
|
// действительно может, — `TestLoadConfigMalformedSecretLineHidesValue` и
|
|
// `TestLoadConfigMalformedBeforeAnyKeyHidesValue`. Здесь проверяется то, что
|
|
// проверяемо: заполненный ключ проходит, пустой отвергается с именем ключа.
|
|
func TestTelegramConfigValidateNamesKeyWithoutValue(t *testing.T) {
|
|
filled := TelegramConfig{Enabled: true, BotToken: "123456:AAHfake-secret-token-value"}
|
|
if err := filled.Validate(); err != nil {
|
|
t.Fatalf("включённый вход с заполненным ключом отвергнут: %v", err)
|
|
}
|
|
|
|
err := TelegramConfig{Enabled: true, BotToken: ""}.Validate()
|
|
if err == nil {
|
|
t.Fatal("включённый вход без ключа доступа пропущен")
|
|
}
|
|
if !strings.Contains(err.Error(), "bot_token") {
|
|
t.Fatalf("имя ключа не названо: %v", err)
|
|
}
|
|
}
|
|
|
|
func writeConfig(t *testing.T, body string) string {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "config.toml")
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("не удалось записать файл настроек: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
const validConfigBody = `
|
|
[telegram]
|
|
enabled = false
|
|
bot_token = ""
|
|
`
|
|
|
|
// Признак обязателен: файл без него негоден. Умолчание было бы угаданным
|
|
// намерением, а отличить «не задан» от «задан ложным» умеет только разбор —
|
|
// нулевое значение bool у обоих одинаковое.
|
|
func TestLoadConfigRejectsMissingTelegramEnabled(t *testing.T) {
|
|
path := writeConfig(t, "[telegram]\nbot_token = \"123456:AA-fake\"\n")
|
|
|
|
_, err := LoadConfig(path)
|
|
if err == nil {
|
|
t.Fatal("файл без признака включения принят")
|
|
}
|
|
if !strings.Contains(err.Error(), "enabled") {
|
|
t.Fatalf("имя недостающего ключа не названо: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigReadsBothValuesOfTelegramEnabled(t *testing.T) {
|
|
for _, enabled := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("%t", enabled), func(t *testing.T) {
|
|
body := fmt.Sprintf("[telegram]\nenabled = %t\nbot_token = \"123456:AA-fake\"\n", enabled)
|
|
|
|
cfg, err := LoadConfig(writeConfig(t, body))
|
|
if err != nil {
|
|
t.Fatalf("годный файл отвергнут: %v", err)
|
|
}
|
|
if cfg.Telegram.Enabled != enabled {
|
|
t.Fatalf("признак доехал как %t, а в файле %t", cfg.Telegram.Enabled, enabled)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Инвариант «секрет не покидает конфиг»: текст отказа разбора собирает чужая
|
|
// библиотека из разбираемого куска файла, и оборванная строка ключа доступа
|
|
// уехала бы в журнал вместе со значением. Отсюда собственное сообщение.
|
|
func TestLoadConfigMalformedSecretLineHidesValue(t *testing.T) {
|
|
const secret = "123456:AAHfake-secret-token-value"
|
|
// Кавычка не закрыта: разбор оборвётся на значении.
|
|
path := writeConfig(t, "[telegram]\nenabled = true\nbot_token = \""+secret+"\n")
|
|
|
|
_, err := LoadConfig(path)
|
|
if err == nil {
|
|
t.Fatal("поломанный файл настроек принят")
|
|
}
|
|
|
|
message := err.Error()
|
|
for _, part := range []string{secret, "AAHfake", "secret-token-value", "123456"} {
|
|
if strings.Contains(message, part) {
|
|
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
|
|
}
|
|
}
|
|
// Без места и ключа отказ нечинибелен: скрыть значение мало.
|
|
if !strings.Contains(message, "строке 3") {
|
|
t.Fatalf("номер строки не назван, чинить нечего: %v", err)
|
|
}
|
|
if !strings.Contains(message, "bot_token") {
|
|
t.Fatalf("ключ не назван, чинить нечего: %v", err)
|
|
}
|
|
}
|
|
|
|
// Обратная сторона того же разреза: отказ несовпадения типов собран из имён
|
|
// ключей и типов, значений в нём нет, и выбрасывать его текст незачем.
|
|
func TestLoadConfigTypeMismatchKeepsDiagnostics(t *testing.T) {
|
|
path := writeConfig(t, "[server]\nport = \"8080\"\n"+validConfigBody)
|
|
|
|
_, err := LoadConfig(path)
|
|
if err == nil {
|
|
t.Fatal("строка вместо числа принята")
|
|
}
|
|
if !strings.Contains(err.Error(), "port") {
|
|
t.Fatalf("имя ключа не названо, чинить нечего: %v", err)
|
|
}
|
|
}
|
|
|
|
// Вторая ветка разреза: разбор оборвался до всякого ключа, и последнего ключа
|
|
// нет вовсе. Пропущенная скобка секции — обычная опечатка, а ветка эта самая
|
|
// уязвимая: именно в ней будущая правка легче всего протащит текст библиотеки
|
|
// обратно.
|
|
func TestLoadConfigMalformedBeforeAnyKeyHidesValue(t *testing.T) {
|
|
const secret = "123456:AAHsecret-token-value"
|
|
// У секции не закрыта скобка: разбор оборвётся, не назвав ни одного ключа.
|
|
path := writeConfig(t, "[telegram\nenabled = true\nbot_token = \""+secret+"\"\n")
|
|
|
|
_, err := LoadConfig(path)
|
|
if err == nil {
|
|
t.Fatal("поломанный файл настроек принят")
|
|
}
|
|
|
|
message := err.Error()
|
|
for _, part := range []string{secret, "AAHsecret", "secret-token-value"} {
|
|
if strings.Contains(message, part) {
|
|
t.Fatalf("значение ключа доступа уехало в отказ: %v", err)
|
|
}
|
|
}
|
|
if !strings.Contains(message, "строке") {
|
|
t.Fatalf("место отказа не названо, чинить нечего: %v", err)
|
|
}
|
|
}
|
|
|
|
// Числа конвейера приезжают из файла, а не выдумываются кодом.
|
|
func TestLoadConfigReadsPipelineSettings(t *testing.T) {
|
|
path := writeConfig(t, `
|
|
[pipeline]
|
|
workers = 7
|
|
own_work_limit_minutes = 90
|
|
foreign_work_limit_minutes = 720
|
|
|
|
[telegram]
|
|
enabled = false
|
|
`)
|
|
|
|
cfg, err := LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("конфиг не прочитан: %v", err)
|
|
}
|
|
|
|
if cfg.Pipeline.Workers != 7 {
|
|
t.Errorf("число воркеров не прочитано: %d", cfg.Pipeline.Workers)
|
|
}
|
|
|
|
limits := cfg.Pipeline.StuckLimits()
|
|
if limits.Own != 90*time.Minute {
|
|
t.Errorf("предел своей работы не прочитан: %v", limits.Own)
|
|
}
|
|
if limits.Foreign != 720*time.Minute {
|
|
t.Errorf("предел чужой работы не прочитан: %v", limits.Foreign)
|
|
}
|
|
}
|
|
|
|
// Умолчания есть у всех трёх чисел: файл без секции конвейера годен, и сервис
|
|
// поднимается с рабочими значениями.
|
|
func TestPipelineSettingsHaveDefaults(t *testing.T) {
|
|
path := writeConfig(t, "[telegram]\nenabled = false\n")
|
|
|
|
cfg, err := LoadConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("конфиг не прочитан: %v", err)
|
|
}
|
|
|
|
if cfg.Pipeline.Workers <= 0 {
|
|
t.Errorf("умолчание числа воркеров негодно: %d", cfg.Pipeline.Workers)
|
|
}
|
|
if err := cfg.Pipeline.Validate(); err != nil {
|
|
t.Errorf("умолчания не проходят собственную проверку: %v", err)
|
|
}
|
|
}
|
|
|
|
// Ноль воркеров — объявленный режим, а отрицательное число и нулевой предел —
|
|
// опечатка: подниматься с ней значит остановить всякую запись первым же
|
|
// захватом.
|
|
func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) {
|
|
valid := PipelineConfig{Workers: 0, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440}
|
|
if err := valid.Validate(); err != nil {
|
|
t.Errorf("ноль воркеров объявлен законным значением: %v", err)
|
|
}
|
|
|
|
for name, cfg := range map[string]PipelineConfig{
|
|
"отрицательное число воркеров": {Workers: -1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 1440},
|
|
"нулевой предел своей работы": {Workers: 1, OwnWorkLimitMinutes: 0, ForeignWorkLimitMinutes: 1440},
|
|
"нулевой предел чужой работы": {Workers: 1, OwnWorkLimitMinutes: 60, ForeignWorkLimitMinutes: 0},
|
|
} {
|
|
if err := cfg.Validate(); err == nil {
|
|
t.Errorf("%s принято за режим", name)
|
|
}
|
|
}
|
|
}
|