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) } }