add tests
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user