add tests

This commit is contained in:
m
2026-09-13 19:24:58 +02:00
parent c054b90c92
commit 5578e4a354
19 changed files with 1599 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
package service
import (
"errors"
"testing"
"github.com/vaultdrop/backend/pkg/passwd"
"github.com/vaultdrop/backend/repository"
)
func TestEnsureAdminCreatesFirstAdmin(t *testing.T) {
s := newServiceStore(t)
repo := s.Repository
if err := EnsureAdmin(repo, " Admin ", "admin-secret-123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
user, err := repo.Users.GetByUsernameNormalized("admin")
if err != nil {
t.Fatalf("admin non trouvé: %v", err)
}
if !user.IsAdmin {
t.Error("le compte créé doit être admin")
}
if user.UsernameNormalized != "admin" || user.Username != "admin" {
t.Errorf("identité anormale: %+v", user)
}
if err := passwd.Verify("admin-secret-123", user.PasswordHash); err != nil {
t.Errorf("le mot de passe doit vérifier: %v", err)
}
}
func TestNormalizeUsername(t *testing.T) {
cases := map[string]string{
" admin ": "admin",
" Alice": "alice",
"A B C": "a b c",
"": "",
" ": "",
}
for in, want := range cases {
if got := NormalizeUsername(in); got != want {
t.Errorf("NormalizeUsername(%q) = %q, attendu %q", in, got, want)
}
}
}
func TestEnsureAdminRefusesOnEmptyDBWithoutEnv(t *testing.T) {
s := newServiceStore(t)
err := EnsureAdmin(s.Repository, "", "")
if !errors.Is(err, ErrAdminRequired) {
t.Errorf("env absent : attendu ErrAdminRequired, got %v", err)
}
err = EnsureAdmin(s.Repository, " ", "some-password")
if !errors.Is(err, ErrAdminRequired) {
t.Errorf("username blanc : attendu ErrAdminRequired, got %v", err)
}
}
func TestEnsureAdminNeverOverwritesExistingAccounts(t *testing.T) {
s := newServiceStore(t)
repo := s.Repository
// La base n'est PAS vide → l'env ne doit rien créer, même avec des valeurs.
if _, err := repo.Users.Create("alice", "alice", "existing-hash", false); err != nil {
t.Fatalf("seed user: %v", err)
}
if err := EnsureAdmin(repo, "ADMIN", "would-be-admin-secret"); err != nil {
t.Fatalf("EnsureAdmin sur base non vide : %v", err)
}
// Aucun admin n'a été ajouté, l'existant est intact.
if _, err := repo.Users.GetByUsernameNormalized("admin"); !errors.Is(err, repository.ErrNotFound) {
t.Errorf("admin ne doit pas exister sur base non vide (err=%v)", err)
}
alice, err := repo.Users.GetByUsernameNormalized("alice")
if err != nil {
t.Fatalf("alice doit être intacte: %v", err)
}
if alice.PasswordHash != "existing-hash" || alice.IsAdmin {
t.Errorf("l'existant doit être préservé: %+v", alice)
}
}
+130
View File
@@ -0,0 +1,130 @@
package service
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/vaultdrop/backend/ocr"
"github.com/vaultdrop/backend/repository"
)
// stubEngine again (package service) — même contrat que handlers/ocr_test.
type stubEngine struct {
text string
err error
}
func (s stubEngine) ExtractText(_ context.Context, _ string, _ string) (string, error) {
if s.err != nil {
return "", s.err
}
return s.text, nil
}
// newTestOcr builds an Ocr over a fresh DB and returns it with the ids.
func newTestOcr(t *testing.T, uploadDir string, engine ocr.Engine) (*Ocr, string, string, string) {
t.Helper()
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "ocr-failed")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
return NewOcr(s.Repository, uploadDir, "fra+eng", engine), userID, deviceID, uploadDir
}
// waitTillTerminal poll jusqu'à un statut terminal (done/failed).
func waitTillTerminal(t *testing.T, o *Ocr, deviceID, jobID string) repository.OcrJobRow {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
row, err := o.Repository.OcrJobs.Get(deviceID, jobID)
if err != nil {
t.Fatalf("get job: %v", err)
}
if row.Status == "done" || row.Status == "failed" {
return row
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("job jamais terminal")
return repository.OcrJobRow{}
}
func insertOcrFile(t *testing.T, o *Ocr, userID, fileID string) {
t.Helper()
if err := o.Repository.Resources.InsertFile(userID, fileID, "scan.png", "", 128, nil, nil); err != nil {
t.Fatalf("insert file: %v", err)
}
}
func TestOcrPhysicalPath(t *testing.T) {
uploadDir := t.TempDir()
userID := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fileID := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
if err := os.MkdirAll(filepath.Join(uploadDir, userID), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
// Vérifie que l'extension du fichier trouvé est acceptée (glob = ordre
// alphabétique, on ne présume pas du nom choisi).
if err := os.WriteFile(filepath.Join(uploadDir, userID, fileID+".png"), []byte("x"), 0o644); err != nil {
t.Fatalf("write png: %v", err)
}
o := NewOcr(nil, uploadDir, "fra+eng", nil)
found, err := o.physicalPath(userID, fileID)
if err != nil {
t.Fatalf("physicalPath: %v", err)
}
if !strings.HasPrefix(filepath.Base(found), fileID+".") {
t.Errorf("path = %q, attendu préfixe %q", found, fileID+".")
}
if _, err := o.physicalPath(userID, "cccccccccccccccccccccccccccccccc"); !errors.Is(err, repository.ErrNotFound) {
t.Errorf("fichier absent : attendu ErrNotFound, got %v", err)
}
}
func TestOcrJobFailsWhenPhysicalFileMissing(t *testing.T) {
o, userID, deviceID, _ := newTestOcr(t, t.TempDir(), stubEngine{text: "x"})
fileID := repository.NewID()
insertOcrFile(t, o, userID, fileID)
job, err := o.Create(userID, deviceID, fileID)
if err != nil {
t.Fatalf("create: %v", err)
}
row := waitTillTerminal(t, o, deviceID, job.ID)
if row.Status != "failed" || row.Error == nil {
t.Errorf("fichier physique absent : attendu failed avec erreur, got %+v", row)
}
}
func TestOcrJobFailsOnEngineError(t *testing.T) {
uploadDir := t.TempDir()
o, userID, deviceID, _ := newTestOcr(t, uploadDir, stubEngine{err: errors.New("tesseract boom")})
fileID := repository.NewID()
insertOcrFile(t, o, userID, fileID)
// Le fichier physique doit exister pour atteindre l'engine.
if err := os.MkdirAll(filepath.Join(uploadDir, userID), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(uploadDir, userID, fileID+".png"), []byte("img"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
job, err := o.Create(userID, deviceID, fileID)
if err != nil {
t.Fatalf("create: %v", err)
}
row := waitTillTerminal(t, o, deviceID, job.ID)
if row.Status != "failed" || row.Error == nil || !strings.Contains(*row.Error, "tesseract boom") {
t.Errorf("erreur engine : attendu failed avec message engine, got %+v", row)
}
}
+93
View File
@@ -0,0 +1,93 @@
package service
import (
"bytes"
"errors"
"mime/multipart"
"os"
"path/filepath"
"testing"
"github.com/vaultdrop/backend/repository"
)
func multipartFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("create form: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close: %v", err)
}
reader := multipart.NewReader(&body, writer.Boundary())
form, err := reader.ReadForm(1 << 20)
if err != nil {
t.Fatalf("read form: %v", err)
}
files := form.File["file"]
if len(files) == 0 {
t.Fatal("aucun fichier dans la forme")
}
return files[0]
}
func TestUploadPersistsPhysicalFile(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "upload-happy")
dto, err := s.Upload(userID, multipartFileHeader(t, "docs.txt", []byte("hello")), "")
if err != nil {
t.Fatalf("upload: %v", err)
}
if dto.ID == "" || dto.Name != "docs.txt" {
t.Errorf("dto inattendu: %+v", dto)
}
// Le fichier physique existe sous UploadDir/<user>/<id>.<ext>.
path := filepath.Join(s.UploadDir, userID, dto.ID+".txt")
info, err := os.Stat(path)
if err != nil {
t.Fatalf("fichier physique absent: %v", err)
}
if info.Size() != 5 {
t.Errorf("taille physique = %d, attendu 5", info.Size())
}
}
func TestUploadRemovesPhysicalFileOnNameConflict(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "upload-conflict")
folderID := repository.NewID()
if err := s.Repository.Resources.InsertFolder(userID, folderID, "Docs", ""); err != nil {
t.Fatalf("insert folder: %v", err)
}
target := multipartFileHeader(t, "dupe.txt", []byte("hello"))
first, err := s.Upload(userID, target, folderID)
if err != nil {
t.Fatalf("premier upload: %v", err)
}
if first.ID == "" {
t.Fatal("aucun id au premier upload")
}
// Même nom, même dossier → NAME_CONFLICT et pas de second fichier physique.
if _, err := s.Upload(userID, target, folderID); !errors.Is(err, repository.ErrNameConflict) {
t.Fatalf("second upload : attendu ErrNameConflict, got %v", err)
}
entries, err := os.ReadDir(filepath.Join(s.UploadDir, userID))
if err != nil {
t.Fatalf("readdir: %v", err)
}
if len(entries) != 1 {
t.Errorf("fichier orphelin laissé après NAME_CONFLICT : %d fichiers", len(entries))
}
}
+281
View File
@@ -0,0 +1,281 @@
package service
import (
"encoding/json"
"errors"
"testing"
"github.com/vaultdrop/backend/dbtest"
"github.com/vaultdrop/backend/repository"
)
const serviceTestURL = "postgres://vaultdrop:vaultdrop@localhost:5432/vaultdrop_service_test?sslmode=disable"
// newServiceStore returns a Resources service over a fresh migrated test DB.
func newServiceStore(t *testing.T) *Resources {
t.Helper()
conn := dbtest.OpenTestDatabase(t, serviceTestURL)
repo := repository.NewRepository(conn)
return NewResources(repo, t.TempDir(), 100)
}
// mustCreateUser creates a plain (non-admin) account and returns its id.
func mustCreateUser(t *testing.T, repo *repository.Repository, username string) string {
t.Helper()
id, err := repo.Users.Create(username, username, "test-hash", false)
if err != nil {
t.Fatalf("create user %q: %v", username, err)
}
return id
}
// mustRegisterDevice registers a device (idempotent) and returns its id.
func mustRegisterDevice(t *testing.T, repo *repository.Repository, deviceID string) string {
t.Helper()
if err := repo.Devices.Upsert(deviceID); err != nil {
t.Fatalf("register device: %v", err)
}
return deviceID
}
func strp(v string) *string { return &v }
// syncOp builds a SyncOperation with a payload serialized as JSON.
func syncOp(operationID, resourceID, operation, resourceType string, payload any) SyncOperation {
raw, _ := json.Marshal(payload)
return SyncOperation{
OperationID: operationID,
ResourceID: strp(resourceID),
ResourceType: strp(resourceType),
Operation: operation,
Payload: raw,
}
}
func TestValidateSyncOp(t *testing.T) {
hexID := repository.NewID()
valid := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x.txt"})
if err := validateSyncOp(&valid); err != nil {
t.Errorf("op valide rejetée: %v", err)
}
cases := []struct {
name string
op SyncOperation
}{
{"operation_id non 32-hex", syncOp("UPPERCASE", hexID, OpCreateResource, "file", map[string]any{"name": "x"})},
{"operation_id trop court", syncOp("abc", hexID, OpCreateResource, "file", map[string]any{"name": "x"})},
{"operation vide", func() SyncOperation {
o := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x"})
o.Operation = ""
return o
}()},
{"operation inconnue", syncOp(hexID, hexID, "explode", "file", map[string]any{"name": "x"})},
{"resource_id non 32-hex", syncOp(hexID, "not-hex", OpCreateResource, "file", map[string]any{"name": "x"})},
{"resource_type invalide", syncOp(hexID, hexID, OpCreateResource, "document", map[string]any{"name": "x"})},
{"resource_type manquant", func() SyncOperation {
o := syncOp(hexID, hexID, OpCreateResource, "file", map[string]any{"name": "x"})
o.ResourceType = nil
return o
}()},
}
for _, tc := range cases {
if err := validateSyncOp(&tc.op); err == nil {
t.Errorf("%s: validation attendue à échouer", tc.name)
}
}
}
func TestValidateSyncOpAckOnlyOpsSkipResourceValidation(t *testing.T) {
// Les ops partage/lien sont accusées réception SANS état serveur : ni
// resource_id ni resource_type ne sont exigés.
for _, opType := range []string{OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink} {
op := SyncOperation{
OperationID: repository.NewID(),
Operation: opType,
Payload: json.RawMessage(`{}`),
}
if err := validateSyncOp(&op); err != nil {
t.Errorf("%s: ack-only op rejetée: %v", opType, err)
}
}
}
func TestApplyBatchRejectsInvalidOperationID(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-invalid")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp("NOT-32-HEX", repository.NewID(), OpCreateResource, "file", map[string]any{"name": "x.txt"}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil {
t.Fatalf("attendu applied=0 + échec, got %+v", result)
}
if result.Failed.OperationID != "NOT-32-HEX" || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("failed inattendu: %+v", result.Failed)
}
}
func TestApplyBatchAckOnlyOpsRecordedWithoutState(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-ack")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
ops := []SyncOperation{
syncOp(repository.NewID(), repository.NewID(), OpShare, "file", map[string]any{}),
syncOp(repository.NewID(), repository.NewID(), OpCreateLink, "file", map[string]any{}),
}
result, err := s.ApplyBatch(userID, deviceID, ops)
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 2 || result.Failed != nil {
t.Errorf("attendu applied=2, got %+v", result)
}
// Aucune ressource ne doit avoir été créée.
files, total, _ := s.Repo.ListFiles(userID, "", 10, 0, "created_at", "desc")
if len(files) != 0 || total != 0 {
t.Errorf("aucune ressource attendue, got %d", total)
}
// Rejeu → toujours accusé, jamais visible.
result, err = s.ApplyBatch(userID, deviceID, ops)
if err != nil || result.Applied != 2 {
t.Errorf("rejeu: applied=%d err=%v", result.Applied, err)
}
}
func TestApplyBatchUpdateMetadata(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-rename")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
fileID := repository.NewID()
ops := []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "before.txt"}),
syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "after.txt"}),
}
result, err := s.ApplyBatch(userID, deviceID, ops)
if err != nil || result.Applied != 2 || result.Failed != nil {
t.Fatalf("ApplyBatch: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
}
row, err := s.Repo.GetFile(userID, fileID)
if err != nil {
t.Fatalf("GetFile: %v", err)
}
if row.Name != "after.txt" {
t.Errorf("nom après update_metadata = %q, attendu after.txt", row.Name)
}
}
func TestApplyBatchMoveIntoItselfRejected(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-selfmove")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
fileID := repository.NewID()
if _, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("apply create: %v", err)
}
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpMoveResource, "file", map[string]any{"toFolderResourceId": fileID}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("move dans soi-même: attendu applied=0 INVALID_REQUEST, got %+v", result)
}
}
func TestApplyBatchMoveToMissingFolder(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-move-missing")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
fileID := repository.NewID()
if _, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("apply create: %v", err)
}
missing := repository.NewID()
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpMoveResource, "file", map[string]any{"toFolderResourceId": missing}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "NOT_FOUND" {
t.Errorf("move vers dossier absent: attendu applied=0 NOT_FOUND, got %+v", result)
}
}
func TestApplyBatchRejectsInvalidPayloadJSON(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-badpayload")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
op := syncOp(repository.NewID(), repository.NewID(), OpCreateResource, "file", map[string]any{})
op.Payload = json.RawMessage(`{"name":`)
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{op})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("payload JSON invalide: attendu applied=0 INVALID_REQUEST, got %+v", result)
}
}
func TestApplyBatchRejectsMissingName(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-noname")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
result, err := s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), repository.NewID(), OpCreateResource, "file", map[string]any{}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("create sans name: attendu applied=0 INVALID_REQUEST, got %+v", result)
}
}
func TestApplyBatchDeleteIsIdempotent(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-del")
deviceID := mustRegisterDevice(t, s.Repository, repository.NewID())
fileID := repository.NewID()
ops := []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
syncOp(repository.NewID(), fileID, OpDeleteResource, "file", map[string]any{}),
}
result, err := s.ApplyBatch(userID, deviceID, ops)
if err != nil || result.Applied != 2 || result.Failed != nil {
t.Fatalf("ApplyBatch: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
}
if _, err := s.Repo.GetFile(userID, fileID); !errors.Is(err, repository.ErrNotFound) {
t.Errorf("fichier supprimé attendu NOT_FOUND, got %v", err)
}
// Suppression d'une ressource déjà absente → no-op réussi.
result, err = s.ApplyBatch(userID, deviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpDeleteResource, "file", map[string]any{}),
})
if err != nil || result.Applied != 1 || result.Failed != nil {
t.Errorf("delete absent: applied=%d failed=%+v err=%v", result.Applied, result.Failed, err)
}
}