Files
CyberStrikeAI/internal/config/config_test.go
T
Sycun 0d931569be feat: carry the release version across an update, so the header matches the code
The repository's config.example.yaml ships the release version this code was cut
from (upstream bumps it in the release commit: v1.7.21's config.example says
v1.7.21), while the live config.yaml is operator data that no merge ever touches.
So after an update the header badge - and the ?v= on every static asset - kept
saying the version the installation was born with while the tree already ran
newer code.

An update, an adoption, or a rollback now keeps the two together: after the merge
the update reads the version the new code carries (config.example.yaml) and writes
it into the live config's version field - that one line, temp file plus atomic
rename, everything else byte-for-byte preserved, and only when the value actually
differs. The job log records the change (the page shows the step), and the state
file records both numbers so a rollback puts the old version back with the old
code; a number hand-edited after the update is the operator's and is left alone.
The side effect worth naming: the version bump also changes the ?v= query on
static assets, so a browser stops serving the pre-update JavaScript.

Trees without a live config or without a shipped version are left untouched -
a source checkout has nothing to sync, and nothing is ever guessed.

internal/config gains the two focused helpers (FileVersion / WriteVersion, line
level like the tarball upgrade path always did); tests pin the replace, the
insert-when-absent, byte preservation, the mode, the malformed-value refusal, and
the update/adopt/rollback/leave-alone behaviours end to end.
2026-10-08 13:57:11 +08:00

436 lines
14 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureLocalConfigCreatesFromExample(t *testing.T) {
dir := t.TempDir()
examplePath := filepath.Join(dir, "config.example.yaml")
configPath := filepath.Join(dir, "config.yaml")
example := []byte(`auth:
session_duration_hours: 12
server:
host: 127.0.0.1
port: 8080
`)
if err := os.WriteFile(examplePath, example, 0644); err != nil {
t.Fatalf("write example: %v", err)
}
result, err := EnsureLocalConfig(configPath)
if err != nil {
t.Fatalf("EnsureLocalConfig: %v", err)
}
if !result.Created {
t.Fatal("Created = false, want true")
}
if result.ExamplePath != examplePath {
t.Fatalf("ExamplePath = %q, want %q", result.ExamplePath, examplePath)
}
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load generated config: %v", err)
}
if cfg.Auth.SessionDurationHours != 12 {
t.Fatalf("SessionDurationHours = %d, want 12", cfg.Auth.SessionDurationHours)
}
second, err := EnsureLocalConfig(configPath)
if err != nil {
t.Fatalf("EnsureLocalConfig existing: %v", err)
}
if second.Created {
t.Fatal("Created = true for existing config, want false")
}
}
func TestLoadIgnoresLegacyAuthPasswordField(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
initial := strings.Join([]string{
"auth:",
` password: "legacy-password"`,
" session_duration_hours: 12",
"server:",
" host: 127.0.0.1",
" port: 8080",
"",
}, "\n")
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Auth.SessionDurationHours != 12 {
t.Fatalf("SessionDurationHours = %d, want 12", cfg.Auth.SessionDurationHours)
}
}
func TestHitlEffectiveAuditBackend(t *testing.T) {
if got := (HitlConfig{}).EffectiveAuditBackend(); got != HitlAuditBackendOpenAI {
t.Fatalf("empty backend = %q, want openai", got)
}
if got := (HitlConfig{AuditBackend: "Jev"}).EffectiveAuditBackend(); got != HitlAuditBackendTypeSafe {
t.Fatalf("jev alias = %q, want typesafe", got)
}
if got := (HitlConfig{AuditBackend: "claude"}).EffectiveAuditBackend(); got != HitlAuditBackendOpenAI {
t.Fatalf("unknown backend = %q, want openai", got)
}
}
func TestHitlTypeSafeConfigEffectiveDoesNotInheritMainKey(t *testing.T) {
gotURL, gotKey, gotModel := (HitlConfig{
AuditBackend: "typesafe",
AuditModel: OpenAIConfig{APIKey: "ts-key"},
}).TypeSafeConfigEffective()
if gotURL != TypeSafeDefaultBaseURL {
t.Fatalf("base url = %q, want default", gotURL)
}
if gotKey != "ts-key" {
t.Fatalf("api key = %q, want ts-key", gotKey)
}
if gotModel != TypeSafeDefaultModel {
t.Fatalf("model = %q, want default", gotModel)
}
}
func TestHitlAuditModelEffectiveFallsBackToMainConfig(t *testing.T) {
main := OpenAIConfig{
Provider: "openai",
BaseURL: "https://api.example.com/v1",
APIKey: "main-key",
Model: "large-model",
}
got := (HitlConfig{
AuditModel: OpenAIConfig{Model: "small-reviewer"},
}).AuditModelEffective(main)
if got.Provider != main.Provider || got.BaseURL != main.BaseURL || got.APIKey != main.APIKey {
t.Fatalf("expected provider/base_url/api_key to inherit main config, got %+v", got)
}
if got.Model != "small-reviewer" {
t.Fatalf("expected audit model override, got %q", got.Model)
}
}
func TestHitlDefaultConfigEffectiveValues(t *testing.T) {
if got := (HitlConfig{}).EffectiveDefaultMode(); got != "off" {
t.Fatalf("empty default mode = %q, want off", got)
}
if got := (HitlConfig{DefaultMode: "review-edit"}).EffectiveDefaultMode(); got != "off" {
t.Fatalf("unknown default mode = %q, want off", got)
}
if got := (HitlConfig{DefaultMode: "review_edit"}).EffectiveDefaultMode(); got != "review_edit" {
t.Fatalf("review_edit default mode = %q, want review_edit", got)
}
if got := (HitlConfig{}).EffectiveDefaultTimeoutSeconds(); got != 300 {
t.Fatalf("empty default timeout = %d, want 300", got)
}
zero := 0
if got := (HitlConfig{DefaultTimeoutSeconds: &zero}).EffectiveDefaultTimeoutSeconds(); got != 0 {
t.Fatalf("zero default timeout = %d, want 0", got)
}
neg := -1
if got := (HitlConfig{DefaultTimeoutSeconds: &neg}).EffectiveDefaultTimeoutSeconds(); got != 0 {
t.Fatalf("negative default timeout = %d, want 0", got)
}
}
func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
initial := strings.Join([]string{
"ai:",
" default_channel: deepseek",
" channels:",
" qwen:",
" name: Qwen",
" provider: openai_compatible",
" base_url: https://dashscope.example/v1",
" api_key: qwen-key",
" model: qwen-max",
" deepseek:",
" name: DeepSeek",
" provider: openai_compatible",
" base_url: https://deepseek.example/v1",
" api_key: deepseek-key",
" model: deepseek-chat",
" max_total_tokens: 64000",
"server:",
" host: 127.0.0.1",
" port: 8080",
"",
}, "\n")
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OpenAI.Model != "deepseek-chat" || cfg.OpenAI.APIKey != "deepseek-key" || cfg.OpenAI.MaxTotalTokens != 64000 {
t.Fatalf("runtime OpenAI config did not follow ai.default_channel: %+v", cfg.OpenAI)
}
oa, id, ok := cfg.ResolveAIChannel("qwen")
if !ok || id != "qwen" || oa.Model != "qwen-max" || oa.APIKey != "qwen-key" {
t.Fatalf("ResolveAIChannel(qwen) = (%+v, %q, %v)", oa, id, ok)
}
}
func TestNormalizeAIProviderProfilesForOfficialDeepSeekEndpoint(t *testing.T) {
cfg := &Config{
OpenAI: OpenAIConfig{
BaseURL: "https://api.deepseek.com/v1",
Model: "deepseek-chat",
Reasoning: OpenAIReasoningConfig{
Profile: "openai_compat",
},
},
AI: AIConfig{
Channels: map[string]AIChannelConfig{
"official": {
BaseURL: "api.deepseek.com/v1",
Model: "deepseek-chat",
Reasoning: OpenAIReasoningConfig{
Profile: "auto",
},
},
"gateway": {
BaseURL: "https://compatible.example.com/v1",
Model: "deepseek-chat",
Reasoning: OpenAIReasoningConfig{
Profile: "openai_compat",
},
},
},
},
}
cfg.NormalizeAIProviderProfiles()
if cfg.OpenAI.Reasoning.Profile != "deepseek" {
t.Fatalf("openai profile = %q, want deepseek", cfg.OpenAI.Reasoning.Profile)
}
if got := cfg.AI.Channels["official"].Reasoning.Profile; got != "deepseek" {
t.Fatalf("official channel profile = %q, want deepseek", got)
}
if got := cfg.AI.Channels["gateway"].Reasoning.Profile; got != "openai_compat" {
t.Fatalf("gateway profile should be preserved, got %q", got)
}
}
func TestLoadNormalizesDefaultChannelForOfficialDeepSeekEndpoint(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
initial := strings.Join([]string{
"ai:",
" default_channel: deepseek",
" channels:",
" deepseek:",
" name: DeepSeek",
" provider: openai_compatible",
" base_url: https://api.deepseek.com/v1",
" api_key: deepseek-key",
" model: deepseek-chat",
" reasoning:",
" profile: openai_compat",
"server:",
" host: 127.0.0.1",
" port: 8080",
"",
}, "\n")
if err := os.WriteFile(path, []byte(initial), 0644); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OpenAI.Reasoning.Profile != "deepseek" {
t.Fatalf("runtime OpenAI profile = %q, want deepseek", cfg.OpenAI.Reasoning.Profile)
}
if got := cfg.AI.Channels["deepseek"].Reasoning.Profile; got != "deepseek" {
t.Fatalf("channel profile = %q, want deepseek", got)
}
}
func TestSummarizationUserIntentLedgerRunesEffective(t *testing.T) {
var zero MultiAgentEinoMiddlewareConfig
if got := zero.SummarizationUserIntentLedgerMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerMaxRunes {
t.Fatalf("default ledger max runes = %d, want %d", got, DefaultSummarizationUserIntentLedgerMaxRunes)
}
if got := zero.SummarizationUserIntentLedgerEntryMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerEntryMaxRunes {
t.Fatalf("default ledger entry max runes = %d, want %d", got, DefaultSummarizationUserIntentLedgerEntryMaxRunes)
}
custom := MultiAgentEinoMiddlewareConfig{
SummarizationUserIntentLedgerMaxRunes: 12345,
SummarizationUserIntentLedgerEntryMaxRunes: 2345,
}
if got := custom.SummarizationUserIntentLedgerMaxRunesEffective(); got != 12345 {
t.Fatalf("custom ledger max runes = %d", got)
}
if got := custom.SummarizationUserIntentLedgerEntryMaxRunesEffective(); got != 2345 {
t.Fatalf("custom ledger entry max runes = %d", got)
}
}
func TestSummarizationOutputReserveTokensEffective(t *testing.T) {
var zero MultiAgentEinoMiddlewareConfig
if got := zero.SummarizationOutputReserveTokensEffective(); got != DefaultSummarizationOutputReserveTokens {
t.Fatalf("default output reserve = %d, want %d", got, DefaultSummarizationOutputReserveTokens)
}
custom := MultiAgentEinoMiddlewareConfig{SummarizationOutputReserveTokens: 4096}
if got := custom.SummarizationOutputReserveTokensEffective(); got != 4096 {
t.Fatalf("custom output reserve = %d", got)
}
}
func TestOpenAIOutputLimitValidation(t *testing.T) {
if got := (OpenAIConfig{}).MaxCompletionTokensEffective(); got != DefaultMaxCompletionTokens {
t.Fatalf("max completion default=%d", got)
}
if err := validateOpenAIOutputLimits(OpenAIConfig{MaxCompletionTokens: -1}); err == nil {
t.Fatal("negative completion limit must fail")
}
}
func TestLatestUserMessageRunesEffective(t *testing.T) {
var zero MultiAgentEinoMiddlewareConfig
if got := zero.LatestUserMessageMaxRunesEffective(); got != DefaultLatestUserMessageMaxRunes {
t.Fatalf("default latest user max runes = %d, want %d", got, DefaultLatestUserMessageMaxRunes)
}
if got := zero.LatestUserMessageHeadRunesEffective(); got != DefaultLatestUserMessageHeadRunes {
t.Fatalf("default latest user head runes = %d, want %d", got, DefaultLatestUserMessageHeadRunes)
}
if got := zero.LatestUserMessageTailRunesEffective(); got != DefaultLatestUserMessageTailRunes {
t.Fatalf("default latest user tail runes = %d, want %d", got, DefaultLatestUserMessageTailRunes)
}
custom := MultiAgentEinoMiddlewareConfig{
LatestUserMessageMaxRunes: 100,
LatestUserMessageHeadRunes: 40,
LatestUserMessageTailRunes: 60,
}
if got := custom.LatestUserMessageMaxRunesEffective(); got != 100 {
t.Fatalf("custom latest user max runes = %d", got)
}
if got := custom.LatestUserMessageHeadRunesEffective(); got != 40 {
t.Fatalf("custom latest user head runes = %d", got)
}
if got := custom.LatestUserMessageTailRunesEffective(); got != 60 {
t.Fatalf("custom latest user tail runes = %d", got)
}
}
func TestLoadUpdateRepoReadsOnlyTheSourceField(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
// The rest of the file may be broken or unvalidatable; the CLI only asks for the
// update source, and a full config load must not be a precondition for updating.
if err := os.WriteFile(path, []byte("update:\n repo: https://github.com/Sycun/CyberStrikeAI.git\nserver:\n port: 8088\n"), 0o644); err != nil {
t.Fatal(err)
}
repo, err := LoadUpdateRepo(path)
if err != nil {
t.Fatal(err)
}
if repo != "https://github.com/Sycun/CyberStrikeAI.git" {
t.Fatalf("repo = %q, want the configured address", repo)
}
// Unset and missing files both mean "the official repository is the default".
if err := os.WriteFile(path, []byte("server:\n port: 8088\n"), 0o644); err != nil {
t.Fatal(err)
}
if repo, err := LoadUpdateRepo(path); err != nil || repo != "" {
t.Fatalf("repo = %q err = %v, want empty with no error", repo, err)
}
if repo, err := LoadUpdateRepo(filepath.Join(dir, "missing.yaml")); err != nil || repo != "" {
t.Fatalf("a missing config is not an error: repo = %q err = %v", repo, err)
}
// A file that is not YAML at all must fail loudly rather than silently fall back to
// the official repository (that would be an update from the wrong place).
if err := os.WriteFile(path, []byte("update: [not: a: map\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadUpdateRepo(path); err == nil {
t.Fatal("a malformed config must be an error, not a silent default")
}
}
func TestFileVersionAndWriteVersion(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
// Missing file reads as "unknown", not an error.
if v, err := FileVersion(filepath.Join(dir, "missing.yaml")); err != nil || v != "" {
t.Fatalf("missing file: %q %v", v, err)
}
original := "version: \"v1.0.0\"\nserver:\n port: 8088\n # a nested word: version: should stay put\nnotes:\n version: nested-untouched\n"
if err := os.WriteFile(path, []byte(original), 0o640); err != nil {
t.Fatal(err)
}
if v, err := FileVersion(path); err != nil || v != "v1.0.0" {
t.Fatalf("version = %q err = %v", v, err)
}
changed, err := WriteVersion(path, "v1.1.0")
if err != nil || !changed {
t.Fatalf("write: changed=%v err=%v", changed, err)
}
got := readTestFile(t, path)
want := "version: \"v1.1.0\"\nserver:\n port: 8088\n # a nested word: version: should stay put\nnotes:\n version: nested-untouched\n"
if got != want {
t.Fatalf("file after write:\n%q\nwant:\n%q", got, want)
}
if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o640 {
t.Fatalf("mode = %v err = %v, want 0640 preserved", info.Mode(), err)
}
// Idempotent: the same value is not rewritten.
if changed, err := WriteVersion(path, "v1.1.0"); err != nil || changed {
t.Fatalf("rewriting the same value: changed=%v err=%v", changed, err)
}
// No top-level field: insert at the top, nested keys stay untouched.
if err := os.WriteFile(path, []byte("server:\n port: 8088\nnotes:\n version: nested\n"), 0o644); err != nil {
t.Fatal(err)
}
if changed, err := WriteVersion(path, "v1.2.0"); err != nil || !changed {
t.Fatalf("insert: changed=%v err=%v", changed, err)
}
got = readTestFile(t, path)
if !strings.HasPrefix(got, "version: \"v1.2.0\"\n") || !strings.Contains(got, " version: nested") {
t.Fatalf("file after insert:\n%q", got)
}
// A value that would write malformed YAML is refused rather than written.
if _, err := WriteVersion(path, "v1.0.0\"\nserver: hijacked"); err == nil {
t.Fatal("a version containing a quote/newline must be refused")
}
}
func readTestFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}