Files

404 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}