diff --git a/backend/db/migrations/000008_operation_uuid.down.sql b/backend/db/migrations/000008_operation_uuid.down.sql new file mode 100644 index 0000000..317964f --- /dev/null +++ b/backend/db/migrations/000008_operation_uuid.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE operations DROP CONSTRAINT operations_operation_id_hex_check; + +ALTER TABLE operations + ALTER COLUMN operation_id TYPE BIGINT USING operation_id::bigint; \ No newline at end of file diff --git a/backend/db/migrations/000008_operation_uuid.up.sql b/backend/db/migrations/000008_operation_uuid.up.sql new file mode 100644 index 0000000..e27d140 --- /dev/null +++ b/backend/db/migrations/000008_operation_uuid.up.sql @@ -0,0 +1,12 @@ +-- operation_id outbox : BIGINT → TEXT 32-hex (UUID client-generated) +-- cf. docs/api-v1.md §6.1 — idempotence UNIQUE(device_id, operation_id) +-- survit car le nom de colonne ne change pas. +-- Les traces héritées (ancien protocole, operation_id INTEGER) sont converties +-- en 32-hex paddé (LPAD injectif → l'unicité (device_id, operation_id) tient, +-- et le CHECK 32-hex est satisfait ; valeurs jamais rejouées, traces inertes). +ALTER TABLE operations + ALTER COLUMN operation_id TYPE TEXT USING LPAD(operation_id::text, 32, '0'); + +ALTER TABLE operations + ADD CONSTRAINT operations_operation_id_hex_check + CHECK (operation_id ~ '^[0-9a-f]{32}$'); \ No newline at end of file diff --git a/backend/db/migrations_test.go b/backend/db/migrations_test.go index c4f8640..85ab2aa 100644 --- a/backend/db/migrations_test.go +++ b/backend/db/migrations_test.go @@ -179,7 +179,7 @@ func TestMigrationsUpDown(t *testing.T) { t.Error("FK resources.user_id → users(id) manquante après 000007") } - // operation_id outbox = id client (INTEGER) — cf. docs/api-v1.md §6.1 + // operation_id outbox = UUID 32-hex client-generated — cf. docs/api-v1.md §6.1 var opType string err = conn.QueryRow(` SELECT data_type FROM information_schema.columns @@ -187,9 +187,10 @@ func TestMigrationsUpDown(t *testing.T) { if err != nil { t.Fatalf("operation_id type: %v", err) } - if opType != "bigint" { - t.Errorf("operation_id attendu bigint, got %s", opType) + if opType != "text" { + t.Errorf("operation_id attendu text, got %s", opType) } + assertHexCheck(t, conn, "operations", "operation_id") var resourceTypeCheck int err = conn.QueryRow(` diff --git a/backend/handlers/sync_test.go b/backend/handlers/sync_test.go index c92a6ee..4244f71 100644 --- a/backend/handlers/sync_test.go +++ b/backend/handlers/sync_test.go @@ -14,7 +14,7 @@ func syncOpsBody(ops []map[string]any) []byte { return body } -func op(operationID int64, resourceID, operation, resourceType string, payload map[string]any) map[string]any { +func op(operationID string, resourceID, operation, resourceType string, payload map[string]any) map[string]any { return map[string]any{ "operation_id": operationID, "ref_type": "resource", @@ -33,9 +33,9 @@ func TestSyncOpsApplySequential(t *testing.T) { folderID := repository.NewID() fileID := repository.NewID() ops := []map[string]any{ - op(1, folderID, "create_resource", "folder", map[string]any{"name": "Docs"}), - op(2, fileID, "create_resource", "file", map[string]any{"name": "note.txt"}), - op(3, fileID, "move_resource", "file", map[string]any{"toFolderResourceId": folderID}), + op(repository.NewID(), folderID, "create_resource", "folder", map[string]any{"name": "Docs"}), + op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "note.txt"}), + op(repository.NewID(), fileID, "move_resource", "file", map[string]any{"toFolderResourceId": folderID}), } rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json") @@ -77,6 +77,80 @@ func TestSyncOpsApplySequential(t *testing.T) { } } +func TestSyncOpsCreateResourceWithParent(t *testing.T) { + r, _, repo := setup(t) + device := repository.NewID() + token, user := registerAndLogin(t, r, repo, testUserUsername(device, "scp"), "sync-test-password", device) + + parentID := repository.NewID() + childFolderID := repository.NewID() + fileID := repository.NewID() + ops := []map[string]any{ + op(repository.NewID(), parentID, "create_resource", "folder", map[string]any{"name": "Docs"}), + op(repository.NewID(), childFolderID, "create_resource", "folder", map[string]any{"name": "Sub", "parentResourceId": parentID}), + op(repository.NewID(), fileID, "create_resource", "file", map[string]any{"name": "note.txt", "parentResourceId": parentID}), + } + + rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json") + env := expectOK(t, rec, "sync-ops-parent") + var result struct { + Applied int `json:"applied"` + Failed any `json:"failed"` + } + if err := json.Unmarshal(env.Data, &result); err != nil { + t.Fatalf("unmarshal: %v body=%s", err, rec.Body.String()) + } + if result.Applied != 3 || result.Failed != nil { + t.Errorf("attendu applied=3 failed=null, got %+v", result) + } + + // Le fichier est classé sous parentID (pas à la racine) + rec, _ = doRequest(t, r, http.MethodGet, "/api/v1/files/"+fileID, token, nil, "") + env = expectOK(t, rec, "get-after-sync-parent") + var got fileDTO + if err := json.Unmarshal(env.Data, &got); err != nil { + t.Fatalf("get-after-sync-parent: unmarshal: %v", err) + } + if got.FolderID != parentID { + t.Errorf("fichier attendu sous parentID=%s, got FolderID=%q", parentID, got.FolderID) + } + + // Parent inexistant → NOT_FOUND, aucune ressource créée + missingParent := repository.NewID() + orphanID := repository.NewID() + orphanOpID := repository.NewID() + rec, _ = doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, + syncOpsBody([]map[string]any{ + op(orphanOpID, orphanID, "create_resource", "file", map[string]any{"name": "ghost.txt", "parentResourceId": missingParent}), + }), "application/json") + env = expectOK(t, rec, "sync-ops-missing-parent") + var failed struct { + Applied int `json:"applied"` + Failed *struct { + OperationID string `json:"operation_id"` + Code string `json:"code"` + } `json:"failed"` + } + if err := json.Unmarshal(env.Data, &failed); err != nil { + t.Fatalf("unmarshal: %v body=%s", err, rec.Body.String()) + } + if failed.Applied != 0 || failed.Failed == nil || failed.Failed.OperationID != orphanOpID || failed.Failed.Code != "NOT_FOUND" { + t.Errorf("attendu applied=0 NOT_FOUND, got %+v", failed) + } + + // La ressource orpheline n'existe pas ; le dossier enfant est parenté à parentID. + if _, err := repo.Resources.GetFile(user, orphanID); err != repository.ErrNotFound { + t.Errorf("la ressource orpheline ne doit pas exister, err=%v", err) + } + child, err := repo.Resources.GetFolder(user, childFolderID) + if err != nil { + t.Fatalf("GetFolder enfant: %v", err) + } + if child.ParentID != parentID { + t.Errorf("dossier enfant attendu parentID=%s, got %q", parentID, child.ParentID) + } +} + func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) { r, _, repo := setup(t) device := repository.NewID() @@ -84,11 +158,12 @@ func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) { folderID := repository.NewID() dupeID := repository.NewID() + dupeOpID := repository.NewID() ops := []map[string]any{ - op(10, folderID, "create_resource", "folder", map[string]any{"name": "Docs"}), + op(repository.NewID(), folderID, "create_resource", "folder", map[string]any{"name": "Docs"}), // Conflicte avec Docs (même parent racine, même nom) - op(11, dupeID, "create_resource", "folder", map[string]any{"name": "Docs"}), - op(12, repository.NewID(), "create_resource", "file", map[string]any{"name": "after.txt"}), + op(dupeOpID, dupeID, "create_resource", "folder", map[string]any{"name": "Docs"}), + op(repository.NewID(), repository.NewID(), "create_resource", "file", map[string]any{"name": "after.txt"}), } rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json") @@ -96,7 +171,7 @@ func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) { var result struct { Applied int `json:"applied"` Failed *struct { - OperationID int64 `json:"operation_id"` + OperationID string `json:"operation_id"` Code string `json:"code"` Message string `json:"message"` } `json:"failed"` @@ -107,8 +182,8 @@ func TestSyncOpsStopsAtFirstNonIdempotentFailure(t *testing.T) { if result.Applied != 1 { t.Errorf("attendu applied=1 (arrêt à la 2e op), got %d", result.Applied) } - if result.Failed == nil || result.Failed.OperationID != 11 || result.Failed.Code != "NAME_CONFLICT" { - t.Errorf("failed attendu op 11 NAME_CONFLICT, got %+v", result.Failed) + if result.Failed == nil || result.Failed.OperationID != dupeOpID || result.Failed.Code != "NAME_CONFLICT" { + t.Errorf("failed attendu op %s NAME_CONFLICT, got %+v", dupeOpID, result.Failed) } // L'op 12 n'a PAS été appliquée @@ -136,8 +211,8 @@ func TestSyncOpsDeleteIdempotent(t *testing.T) { // Supprimer une ressource absente → no-op réussi (pas de dead-letter) absent := repository.NewID() ops := []map[string]any{ - op(20, fileID, "delete_resource", "file", map[string]any{}), - op(21, absent, "delete_resource", "file", map[string]any{}), + op(repository.NewID(), fileID, "delete_resource", "file", map[string]any{}), + op(repository.NewID(), absent, "delete_resource", "file", map[string]any{}), } rec, _ := doRequest(t, r, http.MethodPost, "/api/v1/sync/ops", token, syncOpsBody(ops), "application/json") env := expectOK(t, rec, "sync-delete") diff --git a/backend/repository/operations.go b/backend/repository/operations.go index 61b62d1..cfe9a36 100644 --- a/backend/repository/operations.go +++ b/backend/repository/operations.go @@ -3,16 +3,24 @@ package repository import ( "database/sql" "encoding/json" + "errors" + "regexp" ) +// operationIDPattern — operation_id outbox = TEXT 32-hex (cf. docs/api-v1.md §6.1). +var operationIDPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) + // Operations persists the per-device outbox trace enforcing idempotence // UNIQUE(device_id, operation_id) — cf. docs/api-v1.md §6.1. type Operations struct { DB *sql.DB } +// ErrInvalidOperationID is returned when operation_id is not 32 lowercase hex. +var ErrInvalidOperationID = errors.New("operation_id must be 32 lowercase hex chars") + // Applied reports whether the operation was already processed for this device. -func (o *Operations) Applied(deviceID string, operationID int64) (bool, error) { +func (o *Operations) Applied(deviceID, operationID string) (bool, error) { var exists int err := o.DB.QueryRow( `SELECT 1 FROM operations WHERE device_id = $1 AND operation_id = $2`, @@ -25,7 +33,10 @@ func (o *Operations) Applied(deviceID string, operationID int64) (bool, error) { } // Record stores an applied operation trace (idempotent on replay). -func (o *Operations) Record(deviceID string, operationID int64, opType, refType string, refID *int64, resourceID string, payload []byte) error { +func (o *Operations) Record(deviceID, operationID string, opType, refType string, refID *int64, resourceID string, payload []byte) error { + if !operationIDPattern.MatchString(operationID) { + return ErrInvalidOperationID + } var refTypeValue any if refType != "" { refTypeValue = refType diff --git a/backend/service/sync.go b/backend/service/sync.go index 0e5b15f..f8b84b7 100644 --- a/backend/service/sync.go +++ b/backend/service/sync.go @@ -32,7 +32,7 @@ var ackOnlyOps = map[string]bool{ // SyncOperation is one outbox entry (shadow of mobile PendingOperationRow). type SyncOperation struct { - OperationID int64 `json:"operation_id"` + OperationID string `json:"operation_id"` RefType string `json:"ref_type"` RefID int64 `json:"ref_id"` ResourceID string `json:"resource_id"` @@ -43,7 +43,7 @@ type SyncOperation struct { // FailedOperation reports the first non-idempotent failure. type FailedOperation struct { - OperationID int64 `json:"operation_id"` + OperationID string `json:"operation_id"` Code string `json:"code"` Message string `json:"message"` } @@ -55,9 +55,10 @@ type SyncResult struct { } type createResourcePayload struct { - Name string `json:"name"` - MimeType string `json:"mimeType"` - Extension string `json:"extension"` + Name string `json:"name"` + ParentResourceID string `json:"parentResourceId"` // dossier parent (racine si vide), cf. docs/api-v1.md §6.1 + MimeType string `json:"mimeType"` + Extension string `json:"extension"` } type renamePayload struct { @@ -113,8 +114,8 @@ func (s *Resources) ApplyBatch(userID, deviceID string, ops []SyncOperation) (Sy } func validateSyncOp(op *SyncOperation) error { - if op.OperationID <= 0 { - return errors.New("operation_id must be > 0") + if !resourceIDPattern.MatchString(op.OperationID) { + return errors.New("operation_id must be 32 lowercase hex chars") } if op.Operation == "" { return errors.New("missing operation type") @@ -153,11 +154,18 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { if p.Name == "" { return errors.New("payload.name required") } + // Le parent doit exister et appartenir à l'utilisateur (NOT_FOUND sinon, + // cohérent avec move_resource). Ordre du SAF walk : parent avant enfant. + if p.ParentResourceID != "" { + if _, err := s.Repo.GetFolder(ownerID, p.ParentResourceID); err != nil { + return err + } + } if op.ResourceType == "folder" { - return s.Repo.InsertFolder(ownerID, op.ResourceID, p.Name, "") + return s.Repo.InsertFolder(ownerID, op.ResourceID, p.Name, p.ParentResourceID) } mime := p.MimeType - return s.Repo.InsertFile(ownerID, op.ResourceID, p.Name, "", 0, &mime, nullableString(p.Extension)) + return s.Repo.InsertFile(ownerID, op.ResourceID, p.Name, p.ParentResourceID, 0, &mime, nullableString(p.Extension)) case OpUpdateMetadata: exists, err := s.Repo.ExistsOwner(ownerID, op.ResourceID) @@ -201,8 +209,8 @@ func (s *Resources) applySyncOp(ownerID string, op *SyncOperation) error { } } -func (s *Resources) recordApplied(ownerID string, op *SyncOperation) error { - return s.Repository.Operations.Record(ownerID, op.OperationID, op.Operation, op.RefType, nullableInt64(op.RefID), op.ResourceID, op.Payload) +func (s *Resources) recordApplied(deviceID string, op *SyncOperation) error { + return s.Repository.Operations.Record(deviceID, op.OperationID, op.Operation, op.RefType, nullableInt64(op.RefID), op.ResourceID, op.Payload) } func nullableString(value string) *string {