package main import ( "bufio" "encoding/json" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" "testing" ) // Тесты запускают саму команду как подпроцесс: тестовый бинарник, увидев // HOTPCALC_TEST_EXEC, работает как hotpcalc. Это даёт настоящую проверку // разбора аргументов, вывода, кодов возврата и межпроцессной блокировки. func TestMain(m *testing.M) { if os.Getenv("HOTPCALC_TEST_EXEC") == "1" { os.Args = append([]string{"hotpcalc"}, os.Args[1:]...) main() return } os.Exit(m.Run()) } type runResult struct { stdout, stderr string code int } func runCLI(t *testing.T, args ...string) runResult { t.Helper() cmd := exec.Command(os.Args[0], args...) cmd.Env = append(os.Environ(), "HOTPCALC_TEST_EXEC=1", "HOTPCALC_STORE=") var out, errBuf strings.Builder cmd.Stdout = &out cmd.Stderr = &errBuf err := cmd.Run() res := runResult{stdout: out.String(), stderr: errBuf.String()} if err != nil { var ee *exec.ExitError if !asExitError(err, &ee) { t.Fatalf("запуск %v: %v", args, err) } res.code = ee.ExitCode() } return res } func asExitError(err error, target **exec.ExitError) bool { e, ok := err.(*exec.ExitError) if ok { *target = e } return ok } func mustRun(t *testing.T, args ...string) runResult { t.Helper() r := runCLI(t, args...) if r.code != 0 { t.Fatalf("%v завершилась с кодом %d\nstdout: %s\nstderr: %s", args, r.code, r.stdout, r.stderr) } return r } func storePath(t *testing.T) string { t.Helper() return filepath.Join(t.TempDir(), "tokens.ini") } // newToken выпускает токен и возвращает его серийник и seed. func newToken(t *testing.T, store string, extra ...string) (serial, seed string) { t.Helper() args := append([]string{"new", "-store", store, "-json"}, extra...) r := mustRun(t, args...) var tokens []struct { Serial string `json:"serial"` Seed string `json:"seed"` } if err := json.Unmarshal([]byte(r.stdout), &tokens); err != nil { t.Fatalf("разбор JSON: %v\n%s", err, r.stdout) } if len(tokens) != 1 { t.Fatalf("ожидался один токен, получено %d", len(tokens)) } return tokens[0].Serial, tokens[0].Seed } func TestNewNextMatchesCalculator(t *testing.T) { st := storePath(t) serial, seed := newToken(t, st) if len(seed) != 40 { t.Errorf("seed = %q, ожидалось 40 hex-символов", seed) } // Три «нажатия кнопки». var pressed []string for i := 0; i < 3; i++ { r := mustRun(t, "next", serial, "-store", st) pressed = append(pressed, strings.TrimSpace(r.stdout)) } // Независимая сверка: калькулятор от того же seed со счётчика 0. r := mustRun(t, "-seed", seed, "-counter", "0", "-n", "3", "-json") var calc struct { Positions []struct { Codes []struct { Code string `json:"code"` } `json:"codes"` } `json:"positions"` } if err := json.Unmarshal([]byte(r.stdout), &calc); err != nil { t.Fatalf("разбор JSON калькулятора: %v\n%s", err, r.stdout) } for i, c := range calc.Positions[0].Codes { if pressed[i] != c.Code { t.Errorf("нажатие %d: токен выдал %s, калькулятор — %s", i+1, pressed[i], c.Code) } } // Счётчик доехал до 3 и служебная строка ушла в stderr, а не в stdout. r = mustRun(t, "show", serial, "-store", st, "-json") var shown struct { Counter uint64 `json:"counter"` } json.Unmarshal([]byte(r.stdout), &shown) if shown.Counter != 3 { t.Errorf("счётчик = %d, ожидалось 3", shown.Counter) } } func TestShowDoesNotAdvance(t *testing.T) { st := storePath(t) serial, _ := newToken(t, st) mustRun(t, "next", serial, "-store", st) before := counterOf(t, st, serial) mustRun(t, "show", serial, "-store", st, "-peek", "5") if after := counterOf(t, st, serial); after != before { t.Errorf("show сдвинул счётчик: %d → %d", before, after) } } func counterOf(t *testing.T, store, serial string) uint64 { t.Helper() r := mustRun(t, "show", serial, "-store", store, "-json") var v struct { Counter uint64 `json:"counter"` } if err := json.Unmarshal([]byte(r.stdout), &v); err != nil { t.Fatalf("разбор JSON: %v\n%s", err, r.stdout) } return v.Counter } func TestNextOutputIsScriptable(t *testing.T) { st := storePath(t) serial, _ := newToken(t, st) r := mustRun(t, "next", serial, "-store", st) code := strings.TrimSpace(r.stdout) if len(code) != 6 { t.Errorf("stdout должен содержать только код, получено %q", r.stdout) } if _, err := strconv.Atoi(code); err != nil { t.Errorf("код %q не число", code) } if !strings.Contains(r.stderr, "счётчик 0 → 1") { t.Errorf("служебная строка не попала в stderr: %q", r.stderr) } } func TestSyncSetsCounter(t *testing.T) { st := storePath(t) serial, seed := newToken(t, st) // Коды счётчиков 10 и 11, полученные независимо через калькулятор. r := mustRun(t, "-seed", seed, "-counter", "10", "-n", "2", "-json") var calc struct { Positions []struct { Codes []struct { Code string `json:"code"` } `json:"codes"` } `json:"positions"` } json.Unmarshal([]byte(r.stdout), &calc) c1 := calc.Positions[0].Codes[0].Code c2 := calc.Positions[0].Codes[1].Code mustRun(t, "sync", serial, c1, c2, "-store", st) if got := counterOf(t, st, serial); got != 12 { t.Errorf("после sync счётчик = %d, ожидалось 12", got) } // Следующее значение продолжает ту же последовательность. r = mustRun(t, "next", serial, "-store", st) expect := mustRun(t, "-seed", seed, "-counter", "12", "-n", "1", "-json") var one struct { Positions []struct { Codes []struct { Code string `json:"code"` } `json:"codes"` } `json:"positions"` } json.Unmarshal([]byte(expect.stdout), &one) if strings.TrimSpace(r.stdout) != one.Positions[0].Codes[0].Code { t.Errorf("после sync выдан %q, ожидалось %q", strings.TrimSpace(r.stdout), one.Positions[0].Codes[0].Code) } } func TestExportImportRoundTrip(t *testing.T) { st := storePath(t) xml := filepath.Join(filepath.Dir(st), "init.xml") serial, seed := newToken(t, st, "-xml", xml) data, err := os.ReadFile(xml) if err != nil { t.Fatal(err) } if !strings.Contains(string(data), ""+seed+"") { t.Errorf("seed не найден в XML:\n%s", data) } other := filepath.Join(filepath.Dir(st), "other.ini") mustRun(t, "import", xml, "-store", other) r := mustRun(t, "list", "-store", other, "-json", "-show-seed") var tokens []struct { Serial string `json:"serial"` Seed string `json:"seed"` } json.Unmarshal([]byte(r.stdout), &tokens) if len(tokens) != 1 || tokens[0].Serial != serial || tokens[0].Seed != seed { t.Errorf("импорт вернул %+v, ожидался %s / %s", tokens, serial, seed) } // Повторный импорт без -force ничего не меняет. r = mustRun(t, "import", xml, "-store", other) if !strings.Contains(r.stderr, "пропущены") { t.Errorf("повторный импорт должен предупреждать о пропуске: %q", r.stderr) } } func TestExportWarnsAboutUnsupportedParameters(t *testing.T) { st := storePath(t) serial, _ := newToken(t, st, "-digits", "8") r := mustRun(t, "export", serial, "-store", st) if !strings.Contains(r.stderr, "разрядность") { t.Errorf("ожидалось предупреждение о разрядности, получено: %q", r.stderr) } if !strings.Contains(r.stdout, "") { t.Errorf("XML не выведен в stdout:\n%s", r.stdout) } } func TestTOTPTokenLifecycle(t *testing.T) { st := storePath(t) serial, seed := newToken(t, st, "-type", "totp", "-period", "60s") r := mustRun(t, "next", serial, "-store", st) code := strings.TrimSpace(r.stdout) if !strings.Contains(r.stderr, "шаг ") { t.Errorf("для TOTP ожидалась строка про шаг: %q", r.stderr) } // Токен записал выданный временной шаг. var shown struct { LastStep uint64 `json:"last_step"` Period int `json:"period_seconds"` } json.Unmarshal([]byte(mustRun(t, "show", serial, "-store", st, "-json").stdout), &shown) if shown.LastStep == 0 || shown.Period != 60 { t.Fatalf("состояние TOTP записано неверно: %+v", shown) } // Тот же шаг, посчитанный калькулятором от того же seed, даёт тот же код. r = mustRun(t, "-mode", "totp", "-step", "60s", "-seed", seed, "-counter", strconv.FormatUint(shown.LastStep, 10), "-n", "1", "-json") var calc struct { Positions []struct { Codes []struct { Code string `json:"code"` } `json:"codes"` } `json:"positions"` } json.Unmarshal([]byte(r.stdout), &calc) if got := calc.Positions[0].Codes[0].Code; got != code { t.Errorf("токен выдал %s, калькулятор на шаге %d — %s", code, shown.LastStep, got) } } func TestConcurrentNextGivesDistinctCounters(t *testing.T) { st := storePath(t) serial, _ := newToken(t, st) const n = 8 codes := make([]string, n) var wg sync.WaitGroup for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() r := runCLI(t, "next", serial, "-store", st) if r.code == 0 { codes[i] = strings.TrimSpace(r.stdout) } }(i) } wg.Wait() seen := map[string]bool{} got := 0 for _, c := range codes { if c == "" { continue } if seen[c] { t.Errorf("код %s выдан дважды — блокировка не сработала", c) } seen[c] = true got++ } if got != n { t.Fatalf("успешных нажатий %d из %d — блокировка не пропустила все", got, n) } if c := counterOf(t, st, serial); c != n { t.Errorf("счётчик = %d, ожидалось %d", c, n) } } func TestUnknownTokenAndBadArgs(t *testing.T) { st := storePath(t) newToken(t, st) if r := runCLI(t, "next", "НЕТ-ТАКОГО", "-store", st); r.code == 0 { t.Error("несуществующий токен должен давать ненулевой код возврата") } if r := runCLI(t, "next", "-store", st); r.code == 0 { t.Error("вызов без серийника должен давать ошибку") } if r := runCLI(t, "set", "AAA", "-store", st); r.code == 0 { t.Error("set без изменений должен давать ошибку") } } func TestCalculatorStillWorks(t *testing.T) { // Регрессия: прежние вызовы не должны сломаться подкомандами. const seed = "765D7268D012090C28172A729BC9CC1AD82B382D" r := mustRun(t, seed, "941595", "057938", "-n", "2") if !strings.Contains(r.stdout, "счётчик 41") { t.Errorf("калькулятор сломан:\n%s", r.stdout) } r = mustRun(t, "calc", "-seed", seed, "-counter", "41", "-n", "1") if !strings.Contains(r.stdout, "941595") { t.Errorf("явный вызов calc сломан:\n%s", r.stdout) } } func TestListEmptyStore(t *testing.T) { st := storePath(t) r := mustRun(t, "list", "-store", st) if !strings.Contains(r.stdout, "пусто") { t.Errorf("ожидалось сообщение о пустом хранилище:\n%s", r.stdout) } r = mustRun(t, "list", "-store", st, "-json") if strings.TrimSpace(r.stdout) != "[]" { t.Errorf("для пустого хранилища JSON должен быть [], получено %q", r.stdout) } } func TestTokenInteractive(t *testing.T) { st := storePath(t) serial, _ := newToken(t, st) cmd := exec.Command(os.Args[0], "token", serial, "-store", st) cmd.Env = append(os.Environ(), "HOTPCALC_TEST_EXEC=1") cmd.Stdin = strings.NewReader("\n\nq\n") out, err := cmd.Output() if err != nil { t.Fatalf("token: %v", err) } var codes int sc := bufio.NewScanner(strings.NewReader(string(out))) for sc.Scan() { if strings.Contains(sc.Text(), "(счётчик ") { codes++ } } if codes != 2 { t.Errorf("выдано %d значений, ожидалось 2:\n%s", codes, out) } if c := counterOf(t, st, serial); c != 2 { t.Errorf("счётчик = %d, ожидалось 2", c) } }