package service import ( "path/filepath" "strings" "testing" "hy2xs-admin/dao" "hy2xs-admin/model/bo" "hy2xs-admin/model/entity" ) // Тесты транзакционности импорта работают на настоящей SQLite: UNIQUE-конфликт, // ради которого транзакция и нужна, воспроизводится только в базе. Схема // поднимается отдельно от InitSql — bootstrap-администратор и bootstrap-пир // здесь не нужны и только мешали бы читать ожидания. func newTestDB(t *testing.T) { t.Helper() dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db") if err := dao.InitSqliteDBAt(dbPath); err != nil { t.Fatalf("не удалось открыть тестовую базу: %v", err) } if err := dao.RunMigrations(); err != nil { t.Fatalf("не удалось применить миграции: %v", err) } t.Cleanup(func() { _ = dao.CloseSqliteDB() }) } func seedPeer(t *testing.T, name string, authID string) int64 { t.Helper() digest, err := PeerSecretDigest(name + "-secret") if err != nil { t.Fatalf("digest: %v", err) } cipher, err := EncryptPeerSecret(name + "-secret") if err != nil { t.Fatalf("encrypt: %v", err) } remark := "" quota := int64(-1) zero := int64(0) maxDevices := int64(3) id, err := dao.SavePeer(entity.Peer{ Name: &name, Remark: &remark, AuthId: &authID, SecretDigest: &digest, SecretEncrypted: &cipher, QuotaBytes: "a, DownloadBytes: &zero, UploadBytes: &zero, ExpiresAt: &zero, MaxDevices: &maxDevices, Disabled: &zero, BannedUntil: &zero, LastConnectionAt: &zero, }) if err != nil { t.Fatalf("не удалось создать пира %s: %v", name, err) } return id } func snapshotPeers(t *testing.T) map[string]entity.Peer { t.Helper() peers, err := dao.ListPeer("1=1") if err != nil { t.Fatalf("не удалось прочитать пиров: %v", err) } out := make(map[string]entity.Peer, len(peers)) for _, peer := range peers { out[*peer.Name] = peer } return out } func importItem(name string, authID string) bo.PeerExport { return bo.PeerExport{ Name: name, AuthId: authID, QuotaBytes: -1, MaxDevices: 3, } } func TestUpsertPeerExportAppliesWholeBatch(t *testing.T) { newTestDB(t) items := []bo.PeerExport{ importItem("alpha1", ""), importItem("bravo2", ""), importItem("charl3", ""), } if err := UpsertPeerExport(items); err != nil { t.Fatalf("валидный импорт отклонён: %v", err) } peers := snapshotPeers(t) if len(peers) != 3 { t.Fatalf("ожидалось 3 пира, получено %d", len(peers)) } for _, name := range []string{"alpha1", "bravo2", "charl3"} { if _, ok := peers[name]; !ok { t.Errorf("пир %s не создан", name) } } } // Главная регрессия: cross-conflict, описанный в комментарии к UpsertPeerExport. // // В базе A(auth_id=aaa, name=alice1) и B(auth_id=bbb, name=bob123). // Импорт несёт запись (auth_id=aaa, name=bob123): поиск найдёт A по auth_id и // попытается переименовать её в bob123 — прямо в UNIQUE(name). // // До транзакции всё, что шло в файле ДО конфликтной записи, оставалось // применённым. func TestUpsertPeerExportRollsBackCrossConflict(t *testing.T) { newTestDB(t) seedPeer(t, "alice1", "aaa") seedPeer(t, "bob123", "bbb") before := snapshotPeers(t) items := []bo.PeerExport{ importItem("first1", ""), // применилась бы importItem("second", ""), // применилась бы importItem("bob123", "aaa"), // UNIQUE(name) конфликт importItem("fourth", ""), // до неё дело не дойдёт } err := UpsertPeerExport(items) if err == nil { t.Fatal("импорт с конфликтом UNIQUE должен быть отклонён") } // Отказ обязан прийти из базы, а не из валидации: иначе тест доказывал бы // только то, что файл не прошёл проверку, и про откат не говорил бы ничего. if strings.HasPrefix(err.Error(), "peer import:") { t.Fatalf("ожидался отказ базы, получен отказ валидации: %v", err) } after := snapshotPeers(t) if len(after) != len(before) { t.Fatalf("состояние базы изменилось: было %d пиров, стало %d", len(before), len(after)) } for name, peer := range before { got, ok := after[name] if !ok { t.Errorf("пир %s пропал после отката", name) continue } if *got.AuthId != *peer.AuthId { t.Errorf("у пира %s изменился auth_id: %s -> %s", name, *peer.AuthId, *got.AuthId) } } for _, name := range []string{"first1", "second", "fourth"} { if _, ok := after[name]; ok { t.Errorf("частично применённый импорт: пир %s остался в базе", name) } } } // Тот же инвариант на дубликате auth_id: конфликт возникает на вставке, а не // на обновлении. func TestUpsertPeerExportRollsBackDuplicateAuthId(t *testing.T) { newTestDB(t) seedPeer(t, "exist1", "shared-auth-id") before := snapshotPeers(t) items := []bo.PeerExport{ importItem("newone", ""), // Имя новое, поэтому запись будет вставляться, а auth_id уже занят. importItem("newtwo", "shared-auth-id"), } err := UpsertPeerExport(items) if err == nil { // Совпадение по auth_id приводит к обновлению существующего пира — // это допустимая семантика upsert. Тогда проверяем, что первая запись // применилась вместе со второй, а не вместо неё. after := snapshotPeers(t) if _, ok := after["newone"]; !ok { t.Fatal("успешный импорт не применил первую запись") } return } after := snapshotPeers(t) if len(after) != len(before) { t.Fatalf("частичный импорт: было %d пиров, стало %d", len(before), len(after)) } if _, ok := after["newone"]; ok { t.Error("частично применённый импорт: пир newone остался в базе") } } // Отказ подготовки (валидация партии) не должен доходить до базы вовсе. func TestUpsertPeerExportRejectsInvalidBatchBeforeAnyWrite(t *testing.T) { newTestDB(t) items := []bo.PeerExport{ importItem("valid1", ""), importItem("bad", ""), // короче 6 символов } if err := UpsertPeerExport(items); err == nil { t.Fatal("партия с невалидной записью должна быть отклонена") } if peers := snapshotPeers(t); len(peers) != 0 { t.Fatalf("отклонённая партия оставила записи в базе: %d", len(peers)) } } // Пир установщика защищён и внутри транзакции: отказ обязан откатить всё, что // успело примениться до него. func TestUpsertPeerExportRollsBackOnBootstrapPeer(t *testing.T) { newTestDB(t) seedPeer(t, ReservedBootstrapPeerName, ReservedBootstrapPeerName) items := []bo.PeerExport{ importItem("first1", ""), // Имя зарезервировано, поэтому валидация не пропустит его напрямую; // совпадение достигается через auth_id установщика. importItem("second", ReservedBootstrapPeerName), } if err := UpsertPeerExport(items); err == nil { t.Fatal("импорт, задевающий пира установщика, должен быть отклонён") } after := snapshotPeers(t) if _, ok := after["first1"]; ok { t.Error("частично применённый импорт: пир first1 остался в базе") } } // Обновление существующего пира без секрета в файле не должно трогать его // секрет: иначе перенос настроек ломал бы работающие клиентские ссылки. func TestUpsertPeerExportKeepsSecretWhenFileHasNone(t *testing.T) { newTestDB(t) seedPeer(t, "keeper", "keeper-auth") before := snapshotPeers(t)["keeper"] item := importItem("keeper", "keeper-auth") item.Remark = "updated remark" if err := UpsertPeerExport([]bo.PeerExport{item}); err != nil { t.Fatalf("импорт отклонён: %v", err) } after := snapshotPeers(t)["keeper"] if *after.SecretDigest != *before.SecretDigest { t.Error("секрет пира перезаписан, хотя в файле его не было") } if *after.Remark != "updated remark" { t.Errorf("remark не обновился: %q", *after.Remark) } }