Initial commit: hotpcalc CLI
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hotpcalc/internal/otp"
|
||||
)
|
||||
|
||||
const testSeedHex = "765D7268D012090C28172A729BC9CC1AD82B382D"
|
||||
|
||||
func testSeed(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
b, err := hex.DecodeString(testSeedHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func openTemp(t *testing.T) (*Store, string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "tokens.ini")
|
||||
s, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s, path
|
||||
}
|
||||
|
||||
func sampleToken(t *testing.T) Token {
|
||||
return Token{
|
||||
Serial: "0004000ABCDEF1", Type: TypeHOTP, Secret: testSeed(t),
|
||||
Digits: 6, Alg: otp.SHA1, Counter: 42, Label: "Иванов И.И.",
|
||||
Created: time.Now().Truncate(time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutGetCommitReopen(t *testing.T) {
|
||||
s, path := openTemp(t)
|
||||
want := sampleToken(t)
|
||||
if err := s.Put(want); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
s.Close()
|
||||
|
||||
s2, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("повторное открытие: %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
|
||||
got, ok, err := s2.Get(want.Serial)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get: %v, найден=%v", err, ok)
|
||||
}
|
||||
if got.Type != want.Type || got.Digits != want.Digits || got.Counter != want.Counter ||
|
||||
got.Label != want.Label || got.Alg != want.Alg {
|
||||
t.Errorf("получено %+v, ожидалось %+v", got, want)
|
||||
}
|
||||
if hex.EncodeToString(got.Secret) != hex.EncodeToString(want.Secret) {
|
||||
t.Errorf("seed не совпал: %X", got.Secret)
|
||||
}
|
||||
if !got.Created.Equal(want.Created) {
|
||||
t.Errorf("created = %v, ожидалось %v", got.Created, want.Created)
|
||||
}
|
||||
|
||||
if _, ok, _ := s2.Get("НЕТ-ТАКОГО"); ok {
|
||||
t.Error("несуществующий токен не должен находиться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPFieldsRoundTrip(t *testing.T) {
|
||||
s, path := openTemp(t)
|
||||
want := Token{
|
||||
Serial: "TOTP01", Type: TypeTOTP, Secret: testSeed(t), Digits: 8,
|
||||
Alg: otp.SHA256, Period: 60 * time.Second, LastStep: 58907525,
|
||||
}
|
||||
if err := s.Put(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Close()
|
||||
|
||||
s2, _ := Open(path, nil)
|
||||
defer s2.Close()
|
||||
got, _, err := s2.Get("TOTP01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Period != 60*time.Second || got.LastStep != want.LastStep ||
|
||||
got.Digits != 8 || got.Alg != otp.SHA256 {
|
||||
t.Errorf("получено %+v", got)
|
||||
}
|
||||
|
||||
// Для TOTP counter не пишется, для HOTP не пишутся period/last_step.
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(data), "counter") {
|
||||
t.Errorf("в TOTP-секции не должно быть counter:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchTypeClearsForeignKeys(t *testing.T) {
|
||||
s, path := openTemp(t)
|
||||
tok := sampleToken(t)
|
||||
if err := s.Put(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok.Type = TypeTOTP
|
||||
tok.Period = 30 * time.Second
|
||||
if err := s.Put(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(data), "counter") {
|
||||
t.Errorf("counter должен исчезнуть при смене типа на totp:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndRemove(t *testing.T) {
|
||||
s, _ := openTemp(t)
|
||||
a := sampleToken(t)
|
||||
b := sampleToken(t)
|
||||
b.Serial = "0004000ABCDEF2"
|
||||
if err := s.Put(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Put(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
list, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 || list[0].Serial != a.Serial || list[1].Serial != b.Serial {
|
||||
t.Errorf("List вернул %+v", list)
|
||||
}
|
||||
if !s.Remove(a.Serial) || s.Remove(a.Serial) {
|
||||
t.Error("Remove работает неверно")
|
||||
}
|
||||
if got := s.Serials(); len(got) != 1 || got[0] != b.Serial {
|
||||
t.Errorf("после удаления Serials() = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("на Windows права доступа задаются иначе")
|
||||
}
|
||||
s, path := openTemp(t)
|
||||
if err := s.Put(sampleToken(t)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("права на хранилище = %04o, ожидалось 0600", perm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
s, _ := openTemp(t)
|
||||
bad := []struct {
|
||||
name string
|
||||
alter func(*Token)
|
||||
}{
|
||||
{"пустой серийник", func(t *Token) { t.Serial = "" }},
|
||||
{"пробел в серийнике", func(t *Token) { t.Serial = "AB CD" }},
|
||||
{"скобка в серийнике", func(t *Token) { t.Serial = "A]B" }},
|
||||
{"неизвестный тип", func(t *Token) { t.Type = "ocra" }},
|
||||
{"пустой seed", func(t *Token) { t.Secret = nil }},
|
||||
{"разрядность 4", func(t *Token) { t.Digits = 4 }},
|
||||
{"перевод строки в метке", func(t *Token) { t.Label = "две\nстроки" }},
|
||||
}
|
||||
for _, tc := range bad {
|
||||
tok := sampleToken(t)
|
||||
tc.alter(&tok)
|
||||
if err := s.Put(tok); err == nil {
|
||||
t.Errorf("%s: ожидалась ошибка", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenBrokenStore(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tokens.ini")
|
||||
os.WriteFile(path, []byte("это не ini\n"), 0o600)
|
||||
if _, err := Open(path, nil); err == nil {
|
||||
t.Error("битый файл должен давать ошибку")
|
||||
}
|
||||
// Замок при неудачном открытии не должен оставаться.
|
||||
if _, err := os.Stat(path + ".lock"); !os.IsNotExist(err) {
|
||||
t.Error("файл блокировки остался после неудачного Open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenTokenSection(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "tokens.ini")
|
||||
os.WriteFile(path, []byte("[AAA]\nseed = не-hex-и-не-base32!\n"), 0o600)
|
||||
s, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
if _, _, err := s.Get("AAA"); err == nil {
|
||||
t.Error("некорректный seed должен давать ошибку при чтении токена")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockIsExclusive(t *testing.T) {
|
||||
s, path := openTemp(t)
|
||||
_ = s
|
||||
start := time.Now()
|
||||
if _, err := Open(path, nil); err == nil {
|
||||
t.Fatal("второе открытие должно завершиться ошибкой")
|
||||
}
|
||||
if d := time.Since(start); d < lockTimeout {
|
||||
t.Errorf("ожидание длилось %s, ожидалось не меньше %s", d, lockTimeout)
|
||||
}
|
||||
// После Close хранилище снова открывается.
|
||||
s.Close()
|
||||
s2, err := Open(path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("после Close открыть не удалось: %v", err)
|
||||
}
|
||||
s2.Close()
|
||||
}
|
||||
|
||||
func TestStaleLockIsBroken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tokens.ini")
|
||||
lockPath := path + ".lock"
|
||||
os.WriteFile(lockPath, []byte("pid 1\n"), 0o600)
|
||||
old := time.Now().Add(-2 * lockStaleAfter)
|
||||
os.Chtimes(lockPath, old, old)
|
||||
|
||||
var warned string
|
||||
s, err := Open(path, func(m string) { warned = m })
|
||||
if err != nil {
|
||||
t.Fatalf("брошенный замок должен сниматься: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
if !strings.Contains(warned, "брошенный") {
|
||||
t.Errorf("предупреждение не выдано: %q", warned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSeedAndSerial(t *testing.T) {
|
||||
a, err := NewSeed(0)
|
||||
if err != nil || len(a) != SeedBytes {
|
||||
t.Fatalf("NewSeed: %v, %d байт", err, len(a))
|
||||
}
|
||||
b, _ := NewSeed(0)
|
||||
if hex.EncodeToString(a) == hex.EncodeToString(b) {
|
||||
t.Error("два вызова NewSeed дали одинаковый результат")
|
||||
}
|
||||
|
||||
serial, err := NewSerial(DefaultSerialPrefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(serial) != SerialLength || !strings.HasPrefix(serial, DefaultSerialPrefix) {
|
||||
t.Errorf("NewSerial = %q", serial)
|
||||
}
|
||||
if err := ValidateSerial(serial); err != nil {
|
||||
t.Errorf("сгенерированный серийник не проходит проверку: %v", err)
|
||||
}
|
||||
if _, err := NewSerial("плохой префикс"); err == nil {
|
||||
t.Error("некорректный префикс должен отвергаться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewUniqueSerial(t *testing.T) {
|
||||
s, _ := openTemp(t)
|
||||
seen := map[string]bool{}
|
||||
for i := 0; i < 20; i++ {
|
||||
serial, err := s.NewUniqueSerial(DefaultSerialPrefix)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[serial] {
|
||||
t.Fatalf("серийник %s выдан дважды", serial)
|
||||
}
|
||||
seen[serial] = true
|
||||
tok := sampleToken(t)
|
||||
tok.Serial = serial
|
||||
if err := s.Put(tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPathHonoursEnv(t *testing.T) {
|
||||
t.Setenv(EnvStore, "/tmp/особый/путь.ini")
|
||||
if got := DefaultPath(); got != "/tmp/особый/путь.ini" {
|
||||
t.Errorf("DefaultPath = %q", got)
|
||||
}
|
||||
t.Setenv(EnvStore, "")
|
||||
if got := DefaultPath(); filepath.Base(got) != FileName {
|
||||
t.Errorf("DefaultPath = %q, ожидалось имя %s", got, FileName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user