Initial commit: hotpcalc CLI

This commit is contained in:
2026-09-20 20:04:18 +03:00
commit 8f42dc690e
24 changed files with 5182 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
package ini
import (
"strings"
"testing"
)
const sample = `; хранилище токенов
; вторая строка шапки
[0004000ABCDEF1]
; заметка про этот токен
type = hotp
seed = 765D7268D012090C28172A729BC9CC1AD82B382D
counter = 42
label = Иванов И.И.
[0004000ABCDEF2]
type = totp
period = 30
`
func mustParse(t *testing.T, s string) *File {
t.Helper()
f, err := Parse(strings.NewReader(s))
if err != nil {
t.Fatalf("Parse: %v", err)
}
return f
}
func TestParseBasics(t *testing.T) {
f := mustParse(t, sample)
if got := f.Sections(); len(got) != 2 || got[0] != "0004000ABCDEF1" || got[1] != "0004000ABCDEF2" {
t.Fatalf("Sections() = %v", got)
}
s := f.Section("0004000ABCDEF1")
if s == nil {
t.Fatal("секция не найдена")
}
if got := s.Get("counter"); got != "42" {
t.Errorf("counter = %q", got)
}
if got := s.Get("label"); got != "Иванов И.И." {
t.Errorf("label = %q", got)
}
if got := s.Get("нет-такого"); got != "" {
t.Errorf("отсутствующий ключ = %q", got)
}
if !s.Has("seed") || s.Has("нет-такого") {
t.Error("Has работает неверно")
}
if got := s.Keys(); strings.Join(got, ",") != "type,seed,counter,label" {
t.Errorf("Keys() = %v", got)
}
if f.Section("нет-такой") != nil {
t.Error("несуществующая секция должна давать nil")
}
}
func TestRoundTripIsStable(t *testing.T) {
// Первая запись приводит выравнивание к каноническому виду, дальше файл
// не должен меняться — иначе каждая перезапись плодила бы отступы
// или пустые строки.
first, err := mustParse(t, sample).Bytes()
if err != nil {
t.Fatalf("Bytes: %v", err)
}
second, err := mustParse(t, string(first)).Bytes()
if err != nil {
t.Fatalf("Bytes: %v", err)
}
if string(first) != string(second) {
t.Errorf("повторная запись изменила файл:\n--- 1 ---\n%s\n--- 2 ---\n%s", first, second)
}
// Содержимое при этом сохраняется полностью.
got := string(first)
for _, want := range []string{
"; хранилище токенов", "; вторая строка шапки", "; заметка про этот токен",
"[0004000ABCDEF1]", "[0004000ABCDEF2]",
"seed = 765D7268D012090C28172A729BC9CC1AD82B382D",
"label = Иванов И.И.", "period = 30",
} {
if !strings.Contains(got, want) {
t.Errorf("после записи потеряно %q:\n%s", want, got)
}
}
if strings.Contains(got, "\n\n\n") {
t.Errorf("лишние пустые строки:\n%q", got)
}
}
func TestSetKeepsPositionAndComments(t *testing.T) {
f := mustParse(t, sample)
s := f.Section("0004000ABCDEF1")
s.Set("counter", "43") // существующий ключ — на своём месте
s.Set("last_used", "now") // новый — в конец секции
out, err := f.Bytes()
if err != nil {
t.Fatal(err)
}
got := string(out)
if !strings.Contains(got, "counter = 43") {
t.Errorf("counter не обновлён:\n%s", got)
}
if !strings.Contains(got, "; заметка про этот токен") {
t.Errorf("комментарий внутри секции потерян:\n%s", got)
}
if !strings.Contains(got, "; хранилище токенов") {
t.Errorf("шапка потеряна:\n%s", got)
}
if strings.Index(got, "counter") > strings.Index(got, "label") {
t.Errorf("порядок ключей нарушен:\n%s", got)
}
if !strings.Contains(got, "last_used = now") {
t.Errorf("новый ключ не добавлен:\n%s", got)
}
// Ширина выравнивания пересчитывается по самому длинному ключу секции.
if !strings.Contains(got, "type = hotp") {
t.Errorf("выравнивание нарушено:\n%s", got)
}
}
func TestAddRemoveSection(t *testing.T) {
f := New()
f.SetPreamble([]string{"; шапка"})
s := f.AddSection("AAA")
s.Set("k", "v")
if f.AddSection("AAA") != s {
t.Error("AddSection должен возвращать существующую секцию")
}
f.AddSection("BBB").Set("x", "y")
out, _ := f.Bytes()
want := "; шапка\n\n[AAA]\nk = v\n\n[BBB]\nx = y\n"
if string(out) != want {
t.Errorf("получено:\n%q\nожидалось:\n%q", out, want)
}
if !f.RemoveSection("AAA") || f.RemoveSection("AAA") {
t.Error("RemoveSection работает неверно")
}
if got := f.Sections(); len(got) != 1 || got[0] != "BBB" {
t.Errorf("после удаления Sections() = %v", got)
}
}
func TestUnset(t *testing.T) {
f := mustParse(t, sample)
s := f.Section("0004000ABCDEF1")
if !s.Unset("counter") || s.Unset("counter") {
t.Error("Unset работает неверно")
}
out, _ := f.Bytes()
if strings.Contains(string(out), "counter") {
t.Errorf("ключ не удалён:\n%s", out)
}
}
func TestValueEdgeCases(t *testing.T) {
f := mustParse(t, "[s]\nempty =\nurl = http://x/?a=1&b=2\npadded = с пробелами \n")
s := f.Section("s")
if got := s.Get("empty"); got != "" {
t.Errorf("пустое значение = %q", got)
}
if got := s.Get("url"); got != "http://x/?a=1&b=2" {
t.Errorf("значение со знаком = внутри: %q", got)
}
if got := s.Get("padded"); got != "с пробелами" {
t.Errorf("обрамляющие пробелы не срезаны: %q", got)
}
}
func TestWriteRejectsNewline(t *testing.T) {
f := New()
f.AddSection("s").Set("k", "две\nстроки")
if _, err := f.Bytes(); err == nil {
t.Error("значение с переводом строки должно отвергаться при записи")
}
}
func TestParseErrors(t *testing.T) {
cases := map[string]string{
"ключ вне секции": "k = v\n",
"незакрытая секция": "[abc\n",
"пустое имя секции": "[]\n",
"строка без знака =": "[s]\nмусор\n",
"пустое имя ключа": "[s]\n = v\n",
"повтор секции": "[s]\nk = 1\n[s]\nk = 2\n",
"повтор ключа в секции": "[s]\nk = 1\nk = 2\n",
}
for name, in := range cases {
if _, err := Parse(strings.NewReader(in)); err == nil {
t.Errorf("%s: ожидалась ошибка", name)
}
}
}
func TestParseEmptyAndCRLF(t *testing.T) {
if f, err := Parse(strings.NewReader("")); err != nil || len(f.Sections()) != 0 {
t.Errorf("пустой файл: %v, %v", f, err)
}
f := mustParse(t, "[s]\r\nk = v\r\n")
if got := f.Section("s").Get("k"); got != "v" {
t.Errorf("CRLF: k = %q", got)
}
}