404 lines
12 KiB
Go
404 lines
12 KiB
Go
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)
|
||
}
|
||
}
|