Files
Kazier/backend/service/sync_test.go
T
2026-09-17 07:59:21 +02:00

1214 lines
42 KiB
Go

package service
import (
"encoding/json"
"errors"
"testing"
"time"
"github.com/kazier/backend/dbtest"
"github.com/kazier/backend/repository"
)
const serviceTestURL = "postgres://kazier:kazier@localhost:5432/kazier_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
}
// shareTestDeviceID is a fixed 32-hex device registered by each share test.
const shareTestDeviceID = "a0000000000000000000000000000001"
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 TestValidateSyncOpShareOpsRequireResourceID(t *testing.T) {
hexID := repository.NewID()
// Share ops are no longer ack-only — they require resource_id and resource_type.
shareOps := []string{OpShare, OpRevokeShare, OpUpdateShare, OpCreateLink, OpRevokeLink}
for _, opType := range shareOps {
// Valid op with resource_id
op := SyncOperation{
OperationID: repository.NewID(),
ResourceID: strp(hexID),
ResourceType: strp("file"),
Operation: opType,
Payload: json.RawMessage(`{}`),
}
if err := validateSyncOp(&op); err != nil {
t.Errorf("%s: op valide rejetée: %v", opType, err)
}
// Missing resource_id → rejected
opNoRes := SyncOperation{
OperationID: repository.NewID(),
Operation: opType,
Payload: json.RawMessage(`{}`),
}
if err := validateSyncOp(&opNoRes); err == nil {
t.Errorf("%s: resource_id manquant attendu rejeté", opType)
}
}
}
func TestApplyBatchRejectsInvalidOperationID(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-invalid")
deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID)
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 TestApplyBatchUpdateMetadata(t *testing.T) {
s := newServiceStore(t)
userID := mustCreateUser(t, s.Repository, "sync-rename")
deviceID := mustRegisterDevice(t, s.Repository, shareTestDeviceID)
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, shareTestDeviceID)
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, shareTestDeviceID)
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, shareTestDeviceID)
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, shareTestDeviceID)
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, shareTestDeviceID)
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)
}
}
// --- Share & snapshot tests ---
func TestApplyShareAndSnapshot(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "share-owner")
granteeID := mustCreateUser(t, s.Repository, "share-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
// Owner creates a file
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "shared.txt"}),
}); err != nil {
t.Fatalf("create: %v", err)
}
// Owner shares with grantee
result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "viewer",
}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 1 || result.Failed != nil {
t.Fatalf("share: applied=%d failed=%+v", result.Applied, result.Failed)
}
// Grantee's snapshot includes the shared file
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if len(perms) != 1 {
t.Fatalf("attendu 1 permission, got %d", len(perms))
}
if perms[0].ResourceID != fileID {
t.Errorf("resource_id = %s, attendu %s", perms[0].ResourceID, fileID)
}
if perms[0].EffectiveAccess != "viewer" {
t.Errorf("effectiveAccess = %s, attendu viewer", perms[0].EffectiveAccess)
}
if perms[0].OwnerID != ownerID {
t.Errorf("ownerId = %s, attendu %s", perms[0].OwnerID, ownerID)
}
if perms[0].SharedByID == nil || *perms[0].SharedByID != ownerID {
t.Errorf("sharedById = %v, attendu %s", perms[0].SharedByID, ownerID)
}
if perms[0].Name != "shared.txt" {
t.Errorf("name = %s, attendu shared.txt", perms[0].Name)
}
}
func TestApplyShareGranteeNotFound(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "share-nograntee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("create: %v", err)
}
result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": repository.NewID(),
"access": "viewer",
}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "GRANTEE_NOT_FOUND" {
t.Errorf("attendu GRANTEE_NOT_FOUND, got %+v", result)
}
}
func TestApplyShareNotOwner(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "share-owner2")
outsiderID := mustCreateUser(t, s.Repository, "share-outsider")
granteeID := mustCreateUser(t, s.Repository, "share-grantee2")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("create: %v", err)
}
// Non-owner tries to share → NOT_FOUND (not enumeration)
result, err := s.ApplyBatch(outsiderID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "NOT_FOUND" {
t.Errorf("non-owner share: attendu NOT_FOUND, got %+v", result)
}
}
func TestApplyRevokeShareStopsPropagation(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "revoke-owner")
granteeID := mustCreateUser(t, s.Repository, "revoke-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
}),
}); err != nil {
t.Fatalf("create+share: %v", err)
}
// Grantee sees it
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if len(perms) != 1 {
t.Fatalf("avant revoke: attendu 1, got %d", len(perms))
}
// Owner revokes
result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpRevokeShare, "file", map[string]any{
"granteeUserId": granteeID,
}),
})
if err != nil {
t.Fatalf("revoke: %v", err)
}
if result.Applied != 1 || result.Failed != nil {
t.Fatalf("revoke result: %+v", result)
}
// Grantee no longer sees it
perms, err = s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot après revoke: %v", err)
}
if len(perms) != 0 {
t.Errorf("après revoke: attendu 0, got %d", len(perms))
}
}
func TestApplyShareUpdatesLevel(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "upd-owner")
granteeID := mustCreateUser(t, s.Repository, "upd-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "viewer",
}),
}); err != nil {
t.Fatalf("create+share: %v", err)
}
// Upgrade to editor
result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpUpdateShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
}),
})
if err != nil {
t.Fatalf("update: %v", err)
}
if result.Applied != 1 || result.Failed != nil {
t.Fatalf("update result: %+v", result)
}
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if len(perms) != 1 || perms[0].EffectiveAccess != "editor" {
t.Errorf("après update: attendu editor, got %v", perms)
}
}
func TestInheritedGrantDownTree(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "inherit-owner")
granteeID := mustCreateUser(t, s.Repository, "inherit-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
// Create folder → subfolder → file
parentID := repository.NewID()
childID := repository.NewID()
fileID := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}),
syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}),
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "file.txt", "parentResourceId": childID}),
})
if err != nil {
t.Fatalf("create tree: %v", err)
}
// Share parent with inherit=true → grantee should see parent + child + file
_, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
"inherit": true,
}),
})
if err != nil {
t.Fatalf("share parent: %v", err)
}
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
ids := make(map[string]string)
for _, p := range perms {
ids[p.ResourceID] = p.EffectiveAccess
}
if _, ok := ids[parentID]; !ok {
t.Error("parent absent du snapshot")
}
if _, ok := ids[childID]; !ok {
t.Error("child absent du snapshot (inherit)")
}
if _, ok := ids[fileID]; !ok {
t.Error("file absent du snapshot (inherit)")
}
}
func TestInheritFalseBlocksPropagation(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "noinherit-owner")
granteeID := mustCreateUser(t, s.Repository, "noinherit-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
parentID := repository.NewID()
childID := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}),
syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}),
})
if err != nil {
t.Fatalf("create tree: %v", err)
}
// Share parent with inherit=false → only parent visible
inherit := false
_, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
"inherit": inherit,
}),
})
if err != nil {
t.Fatalf("share parent: %v", err)
}
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if len(perms) != 1 || perms[0].ResourceID != parentID {
t.Errorf("inherit=false: attendu juste parent, got %d permissions", len(perms))
}
}
func TestDirectGrantOnChildOverrides(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "direct-owner")
granteeID := mustCreateUser(t, s.Repository, "direct-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
parentID := repository.NewID()
childID := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}),
syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}),
})
if err != nil {
t.Fatalf("create tree: %v", err)
}
// Share parent with viewer + inherit=true → child gets viewer via inherit
_, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{
"granteeUserId": granteeID,
"access": "viewer",
"inherit": true,
}),
})
if err != nil {
t.Fatalf("share parent: %v", err)
}
// Directly grant child with editor (higher) → child should show editor
_, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), childID, OpShare, "folder", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
"inherit": false,
}),
})
if err != nil {
t.Fatalf("share child: %v", err)
}
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
byID := make(map[string]string)
for _, p := range perms {
byID[p.ResourceID] = p.EffectiveAccess
}
if byID[parentID] != "viewer" {
t.Errorf("parent: attendu viewer, got %s", byID[parentID])
}
if byID[childID] != "editor" {
t.Errorf("child: attendu editor (direct > inherited), got %s", byID[childID])
}
}
func TestExpiredGrantIgnored(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "expired-owner")
granteeID := mustCreateUser(t, s.Repository, "expired-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("create: %v", err)
}
// Share with an expiry in the past
pastMs := time.Now().Add(-1 * time.Hour).UnixMilli()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "viewer",
"expiresAt": pastMs,
}),
})
if err != nil {
t.Fatalf("share: %v", err)
}
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if len(perms) != 0 {
t.Errorf("grant expiré: attendu 0 permissions, got %d", len(perms))
}
}
func TestExpiredGrantDoesNotPropagate(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "expire-prop-owner")
granteeID := mustCreateUser(t, s.Repository, "expire-prop-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
parentID := repository.NewID()
childID := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}),
syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}),
})
if err != nil {
t.Fatalf("create tree: %v", err)
}
pastMs := time.Now().Add(-1 * time.Hour).UnixMilli()
_, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
"inherit": true,
"expiresAt": pastMs,
}),
})
if err != nil {
t.Fatalf("share: %v", err)
}
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
// Neither parent nor child should appear (expired grant blocks propagation)
if len(perms) != 0 {
t.Errorf("grant expiré + inherit: attendu 0, got %d", len(perms))
}
}
func TestSnapshotDelta(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "delta-owner")
granteeID := mustCreateUser(t, s.Repository, "delta-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
// Full snapshot (afterMs=0) → empty
perms, err := s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if len(perms) != 0 {
t.Fatalf("attendu 0, got %d", len(perms))
}
// Share a file
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "delta.txt"}),
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "viewer",
}),
}); err != nil {
t.Fatalf("create+share: %v", err)
}
// Delta with afterMs in the far future → empty (resource not that new)
futureMs := time.Now().Add(1 * time.Hour).UnixMilli()
perms, err = s.Snapshot(granteeID, futureMs)
if err != nil {
t.Fatalf("Snapshot future: %v", err)
}
if len(perms) != 0 {
t.Errorf("delta future: attendu 0, got %d", len(perms))
}
// Delta with afterMs=0 → includes the file
perms, err = s.Snapshot(granteeID, 0)
if err != nil {
t.Fatalf("Snapshot full: %v", err)
}
if len(perms) != 1 {
t.Errorf("delta full: attendu 1, got %d", len(perms))
}
}
func TestShareLink(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "link-owner")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "linked.txt"}),
}); err != nil {
t.Fatalf("create: %v", err)
}
token := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateLink, "file", map[string]any{
"token": token,
"access": "viewer",
}),
})
if err != nil {
t.Fatalf("create link: %v", err)
}
// Resolve the link
link, err := s.Repository.Shares.GetPublicLink(token)
if err != nil {
t.Fatalf("GetPublicLink: %v", err)
}
if link.ResourceID != fileID || link.Access != "viewer" || link.Name != "linked.txt" {
t.Errorf("link: %+v", link)
}
}
func TestRevokeLink(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "revoke-link-owner")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("create: %v", err)
}
token := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateLink, "file", map[string]any{
"token": token,
"access": "viewer",
}),
})
if err != nil {
t.Fatalf("create link: %v", err)
}
// Revoke
_, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpRevokeLink, "file", map[string]any{
"token": token,
}),
})
if err != nil {
t.Fatalf("revoke link: %v", err)
}
// No longer resolvable
_, err = s.Repository.Shares.GetPublicLink(token)
if !errors.Is(err, repository.ErrNotFound) {
t.Errorf("après revoke: attendu ErrNotFound, got %v", err)
}
}
func TestShareInvalidAccessLevel(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "share-invalid-access")
granteeID := mustCreateUser(t, s.Repository, "share-invalid-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
}); err != nil {
t.Fatalf("create: %v", err)
}
result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "owner",
}),
})
if err != nil {
t.Fatalf("ApplyBatch: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "INVALID_REQUEST" {
t.Errorf("access=owner: attendu INVALID_REQUEST, got %+v", result)
}
}
func TestRevokeShareIsIdempotent(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "revoke-idempotent")
granteeID := mustCreateUser(t, s.Repository, "revoke-idempotent-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "x.txt"}),
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "viewer",
}),
}); err != nil {
t.Fatalf("create+share: %v", err)
}
// First revoke
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpRevokeShare, "file", map[string]any{
"granteeUserId": granteeID,
}),
})
if err != nil {
t.Fatalf("revoke 1: %v", err)
}
// Second revoke (different operation_id) — idempotent, should succeed
result, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpRevokeShare, "file", map[string]any{
"granteeUserId": granteeID,
}),
})
if err != nil {
t.Fatalf("revoke 2: %v", err)
}
if result.Applied != 1 || result.Failed != nil {
t.Errorf("revoke idempotent: applied=%d failed=%+v", result.Applied, result.Failed)
}
}
func TestOwnerDoesNotSeeSharesInSnapshot(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "own-snap-owner")
granteeID := mustCreateUser(t, s.Repository, "own-snap-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
if _, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "own.txt"}),
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{
"granteeUserId": granteeID,
"access": "viewer",
}),
}); err != nil {
t.Fatalf("create+share: %v", err)
}
// Owner snapshot: only 1 (their own file, not duplicated by the share)
perms, err := s.Snapshot(ownerID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if len(perms) != 1 {
t.Fatalf("owner: attendu 1, got %d", len(perms))
}
if perms[0].EffectiveAccess != "owner" {
t.Errorf("owner access = %s, attendu owner", perms[0].EffectiveAccess)
}
}
// --- Phase 2 : enforcement lecture partagée + rename editor+ ---
func snapshotAccess(t *testing.T, s *Resources, userID string) map[string]string {
t.Helper()
perms, err := s.Snapshot(userID, 0)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
byID := make(map[string]string, len(perms))
for _, p := range perms {
byID[p.ResourceID] = p.EffectiveAccess
}
return byID
}
func TestVisibleReadsIncludeSharedContent(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "vis-owner")
granteeID := mustCreateUser(t, s.Repository, "vis-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
sharedRoot := repository.NewID()
sharedSub := repository.NewID()
sharedFile := repository.NewID()
privateRoot := repository.NewID()
privateFile := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), sharedRoot, OpCreateResource, "folder", map[string]any{"name": "shared-root"}),
syncOp(repository.NewID(), sharedSub, OpCreateResource, "folder", map[string]any{"name": "sub", "parentResourceId": sharedRoot}),
syncOp(repository.NewID(), sharedFile, OpCreateResource, "file", map[string]any{"name": "shared-file.txt", "parentResourceId": sharedSub}),
syncOp(repository.NewID(), privateRoot, OpCreateResource, "folder", map[string]any{"name": "private-root"}),
syncOp(repository.NewID(), privateFile, OpCreateResource, "file", map[string]any{"name": "secret.txt", "parentResourceId": privateRoot}),
syncOp(repository.NewID(), sharedRoot, OpShare, "folder", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
"inherit": true,
}),
})
if err != nil {
t.Fatalf("create tree + share: %v", err)
}
// Grantee sees the shared root folder, not the private one.
roots, err := s.Repo.ListRootFoldersVisible(granteeID)
if err != nil {
t.Fatalf("ListRootFoldersVisible: %v", err)
}
rootIDs := make(map[string]bool)
for _, f := range roots {
rootIDs[f.ID] = true
}
if !rootIDs[sharedRoot] || rootIDs[privateRoot] {
t.Errorf("dossiers racines visibles: %v, attendu {shared-root} seulement", rootIDs)
}
// Grantee lists the shared file inside the shared subfolder.
files, total, err := s.Repo.ListFilesVisible(granteeID, sharedSub, 50, 0, "created_at", "desc")
if err != nil {
t.Fatalf("ListFilesVisible: %v", err)
}
if total != 1 || len(files) != 1 || files[0].ID != sharedFile {
t.Errorf("ListFilesVisible(sharedSub): total=%d files=%+v", total, files)
}
// Grantee does NOT see the private file inside the private folder.
_, total, err = s.Repo.ListFilesVisible(granteeID, privateRoot, 50, 0, "created_at", "desc")
if err != nil {
t.Fatalf("ListFilesVisible(private): %v", err)
}
if total != 0 {
t.Errorf("dossier privé: total=%d, attendu 0", total)
}
// GetFileVisible: shared ok, private → ErrNotFound.
if f, err := s.Repo.GetFileVisible(granteeID, sharedFile); err != nil || f.Name != "shared-file.txt" {
t.Errorf("GetFileVisible(shared): %+v err=%v", f, err)
}
if _, err := s.Repo.GetFileVisible(granteeID, privateFile); !errors.Is(err, repository.ErrNotFound) {
t.Errorf("GetFileVisible(private): attendu ErrNotFound, got %v", err)
}
// SearchFilesVisible: shared matches, private does not.
search, total, err := s.Repo.SearchFilesVisible(granteeID, "shared-file", 50, 0)
if err != nil {
t.Fatalf("SearchFilesVisible: %v", err)
}
if total != 1 || len(search) != 1 || search[0].ID != sharedFile {
t.Errorf("SearchFilesVisible('shared-file'): total=%d files=%+v", total, search)
}
_, total, err = s.Repo.SearchFilesVisible(granteeID, "secret", 50, 0)
if err != nil {
t.Fatalf("SearchFilesVisible secret: %v", err)
}
if total != 0 {
t.Errorf("recherche 'secret': total=%d, attendu 0", total)
}
}
func TestEffectiveAccessInheritedFromParent(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "eff-owner")
granteeID := mustCreateUser(t, s.Repository, "eff-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
parentID := repository.NewID()
childID := repository.NewID()
privateID := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), parentID, OpCreateResource, "folder", map[string]any{"name": "parent"}),
syncOp(repository.NewID(), childID, OpCreateResource, "folder", map[string]any{"name": "child", "parentResourceId": parentID}),
syncOp(repository.NewID(), privateID, OpCreateResource, "file", map[string]any{"name": "secret.txt"}),
syncOp(repository.NewID(), parentID, OpShare, "folder", map[string]any{
"granteeUserId": granteeID,
"access": "editor",
"inherit": true,
}),
})
if err != nil {
t.Fatalf("create + share: %v", err)
}
// Child (no direct grant) inherits editor from the parent grant.
ra, err := s.Repository.Shares.EffectiveAccess(granteeID, childID)
if err != nil {
t.Fatalf("EffectiveAccess child: %v", err)
}
if !ra.Accessible || ra.EffectiveRank != repository.AccessEditor {
t.Errorf("child: attendu editor accessible, got %+v", ra)
}
// Private file: not accessible.
ra, err = s.Repository.Shares.EffectiveAccess(granteeID, privateID)
if err != nil {
t.Fatalf("EffectiveAccess private: %v", err)
}
if ra.Accessible || ra.EffectiveRank != 0 {
t.Errorf("private: attendu inaccessible, got %+v", ra)
}
// Direct viewer grant on the child is authoritative over the inherited editor.
_, err = s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), childID, OpShare, "folder", map[string]any{
"granteeUserId": granteeID,
"access": "viewer",
}),
})
if err != nil {
t.Fatalf("share child viewer: %v", err)
}
ra, err = s.Repository.Shares.EffectiveAccess(granteeID, childID)
if err != nil {
t.Fatalf("EffectiveAccess child 2: %v", err)
}
if !ra.Accessible || ra.EffectiveRank != repository.AccessViewer {
t.Errorf("child après grant direct viewer: attendu viewer, got %+v", ra)
}
}
func TestEffectiveAccessMaxInheritedChain(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "chain-owner")
granteeID := mustCreateUser(t, s.Repository, "chain-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
g := repository.NewID()
a := repository.NewID()
b := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), g, OpCreateResource, "folder", map[string]any{"name": "G"}),
syncOp(repository.NewID(), a, OpCreateResource, "folder", map[string]any{"name": "A", "parentResourceId": g}),
syncOp(repository.NewID(), b, OpCreateResource, "folder", map[string]any{"name": "B", "parentResourceId": a}),
// G: editor, inherit. A: viewer, inherit (exact-node authoritative).
syncOp(repository.NewID(), g, OpShare, "folder", map[string]any{"granteeUserId": granteeID, "access": "editor", "inherit": true}),
syncOp(repository.NewID(), a, OpShare, "folder", map[string]any{"granteeUserId": granteeID, "access": "viewer", "inherit": true}),
})
if err != nil {
t.Fatalf("create + shares: %v", err)
}
// A keeps its own viewer (rule 2); B, with no direct grant, keeps the
// highest inherited rank (rule 5): editor from G.
for id, want := range map[string]int{g: repository.AccessEditor, a: repository.AccessViewer, b: repository.AccessEditor} {
ra, err := s.Repository.Shares.EffectiveAccess(granteeID, id)
if err != nil {
t.Fatalf("EffectiveAccess %s: %v", id, err)
}
if !ra.Accessible || ra.EffectiveRank != want {
t.Errorf("EffectiveAccess(%s) = %+v, attendu rank %d", id, ra, want)
}
}
// The snapshot matches the per-resource check.
byID := snapshotAccess(t, s, granteeID)
if byID[g] != "editor" || byID[a] != "viewer" || byID[b] != "editor" {
t.Errorf("snapshot chain = %v, attendu G=editor A=viewer B=editor", byID)
}
}
func TestUpdateMetadataRequiresEditor(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "ren-owner")
editorID := mustCreateUser(t, s.Repository, "ren-editor")
viewerID := mustCreateUser(t, s.Repository, "ren-viewer")
strangerID := mustCreateUser(t, s.Repository, "ren-stranger")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "orig.txt"}),
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{"granteeUserId": editorID, "access": "editor"}),
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{"granteeUserId": viewerID, "access": "viewer"}),
})
if err != nil {
t.Fatalf("create + shares: %v", err)
}
// Editor renames successfully.
result, err := s.ApplyBatch(editorID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "renamed.txt"}),
})
if err != nil {
t.Fatalf("editor update: %v", err)
}
if result.Applied != 1 || result.Failed != nil {
t.Fatalf("editor update: applied=%d failed=%+v", result.Applied, result.Failed)
}
row, err := s.Repo.GetFile(ownerID, fileID)
if err != nil || row.Name != "renamed.txt" {
t.Errorf("après rename editor: %+v err=%v", row, err)
}
// Viewer rename is rejected (NOT_FOUND) and stops the batch.
result, err = s.ApplyBatch(viewerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "hijack.txt"}),
})
if err != nil {
t.Fatalf("viewer update: %v", err)
}
if result.Applied != 0 || result.Failed == nil || result.Failed.Code != "NOT_FOUND" {
t.Errorf("viewer update: attendu applied=0 NOT_FOUND, got %+v", result)
}
// Stranger (no access) → no-op terminal (absent semantics), no failure.
result, err = s.ApplyBatch(strangerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpUpdateMetadata, "file", map[string]any{"name": "sneak.txt"}),
})
if err != nil {
t.Fatalf("stranger update: %v", err)
}
if result.Applied != 1 || result.Failed != nil {
t.Errorf("stranger update: attendu applied=1 aucun échec, got %+v", result)
}
row, err = s.Repo.GetFile(ownerID, fileID)
if err != nil || row.Name != "renamed.txt" {
t.Errorf("nom altéré par un tiers: %+v err=%v", row, err)
}
}
func TestMoveAndDeleteOwnerOnly(t *testing.T) {
s := newServiceStore(t)
ownerID := mustCreateUser(t, s.Repository, "ownmove-owner")
granteeID := mustCreateUser(t, s.Repository, "ownmove-grantee")
mustRegisterDevice(t, s.Repository, shareTestDeviceID)
fileID := repository.NewID()
targetID := repository.NewID()
_, err := s.ApplyBatch(ownerID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpCreateResource, "file", map[string]any{"name": "protected.txt"}),
syncOp(repository.NewID(), targetID, OpCreateResource, "folder", map[string]any{"name": "target"}),
syncOp(repository.NewID(), fileID, OpShare, "file", map[string]any{"granteeUserId": granteeID, "access": "editor"}),
})
if err != nil {
t.Fatalf("create + share: %v", err)
}
// Editor tries to move the shared file → no-op (owner-only), nothing moves.
result, err := s.ApplyBatch(granteeID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpMoveResource, "file", map[string]any{"toFolderResourceId": targetID}),
})
if err != nil {
t.Fatalf("grantee move: %v", err)
}
if result.Applied != 1 || result.Failed != nil {
t.Fatalf("grantee move: applied=%d failed=%+v", result.Applied, result.Failed)
}
row, err := s.Repo.GetFile(ownerID, fileID)
if err != nil {
t.Fatalf("GetFile: %v", err)
}
if row.FolderID != "" {
t.Errorf("fichier déplacé par un non-owner: folderId=%q, attendu racine", row.FolderID)
}
// Editor tries to delete the shared file → no-op, still present for owner.
result, err = s.ApplyBatch(granteeID, shareTestDeviceID, []SyncOperation{
syncOp(repository.NewID(), fileID, OpDeleteResource, "file", map[string]any{}),
})
if err != nil {
t.Fatalf("grantee delete: %v", err)
}
if result.Applied != 1 || result.Failed != nil {
t.Fatalf("grantee delete: applied=%d failed=%+v", result.Applied, result.Failed)
}
if _, err := s.Repo.GetFile(ownerID, fileID); err != nil {
t.Errorf("fichier supprimé par un non-owner: %v", err)
}
}