Initial commit: hotpcalc CLI

This commit is contained in:
2026-09-20 20:04:18 +03:00
commit 8f42dc690e
24 changed files with 5182 additions and 0 deletions
+403
View File
@@ -0,0 +1,403 @@
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>"+seed+"</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, "<Tokens>") {
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)
}
}