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