Initial commit: hotpcalc CLI
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user