diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 6468814..880b002 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -2356,6 +2356,59 @@ paths: "426": $ref: "#/components/responses/UpgradeRequired" + /sync/conflicts: + parameters: + - $ref: "#/components/parameters/ClientVersion" + get: + operationId: listSyncConflicts + tags: [sync] + summary: Page the caller's group's conflict log, newest first (FR-093, FR-124, D4.6, A182). Member-readable. + security: + - SessionCookie: [] + - DeviceToken: [] + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + - name: after + in: query + required: false + schema: + type: string + responses: + "200": + description: "One page of the caller's group's conflict log, newest first. An empty log is `conflicts: []`." + content: + application/json: + schema: + $ref: "#/components/schemas/SyncConflictLogResponse" + example: + conflicts: + - id: 0a6f1c2e-5d0b-4b1e-9a55-3c0f6b7e2d11 + mutation_id: 5f0d3b5e-7a4c-4e0e-8a53-1c2b9d6e4f70 + entity_type: item + entity_id: 9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f + field_name: name + server_value: "\"Cordless drill\"" + losing_client_value: "\"Drill (18V)\"" + detected_at: 1788000000000 + next_cursor: null + "400": + description: '' + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "401": + $ref: "#/components/responses/Unauthorized" + "426": + $ref: "#/components/responses/UpgradeRequired" + /backup: parameters: - $ref: "#/components/parameters/ClientVersion" @@ -4601,6 +4654,47 @@ components: type: integer format: int64 + SyncConflictLogEntry: + type: object + required: [id, mutation_id, entity_type, entity_id, field_name, server_value, losing_client_value, detected_at] + properties: + id: + type: string + description: The conflict log entry's own opaque id; unique, stable, and safe as a client-side upsert key. + mutation_id: + type: [string, "null"] + entity_type: + type: string + description: The closed-sync-entity-set member (A7) the conflict concerns, as `SyncMutation.entity_type`. + entity_id: + type: string + field_name: + type: string + description: The conflicting field, or the sentinel `"_entity"` for a whole-entity conflict (A151). + server_value: + type: [string, "null"] + losing_client_value: + type: [string, "null"] + description: JSON text of the value the losing mutation tried to write; `null` when no snapshot was recorded. + detected_at: + type: integer + format: int64 + description: Unix milliseconds (A51) at which the conflict was detected. + + SyncConflictLogResponse: + type: object + description: One page of `GET /sync/conflicts`. + required: [conflicts, next_cursor] + properties: + conflicts: + type: array + description: Newest first. `[]` when the page is empty, never `null`. + items: + $ref: "#/components/schemas/SyncConflictLogEntry" + next_cursor: + type: [string, "null"] + description: Opaque cursor for the next (older) page — pass as `after` verbatim. `null` when no further page remains. + ImportUploadResponse: type: object description: "`POST /import/native/upload`'s `201` body (T148c, FR-051)." diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go index 0530d4d..d1e5b4e 100644 --- a/server/internal/httpapi/router.go +++ b/server/internal/httpapi/router.go @@ -277,6 +277,7 @@ func mountV1(r chi.Router, cfg Config) { r.Route("/sync", func(r chi.Router) { r.Post("/pull", middleware.Scoped(syncPullHandler(cfg))) r.Post("/push", middleware.Scoped(syncPushHandler(cfg))) + r.Get("/conflicts", middleware.Scoped(syncConflictsHandler(cfg))) }) }) diff --git a/server/internal/httpapi/sync_conflicts.go b/server/internal/httpapi/sync_conflicts.go new file mode 100644 index 0000000..5a5f94e --- /dev/null +++ b/server/internal/httpapi/sync_conflicts.go @@ -0,0 +1,89 @@ +package httpapi + +import ( + "errors" + "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/middleware" + "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/problem" + "github.com/ankek/Household-Objects-Dev/server/internal/httpapi/requestid" + "github.com/ankek/Household-Objects-Dev/server/internal/storage" + "log/slog" + "net/http" + "strconv" +) + +const defaultSyncConflictsLimit = 50 + +type syncConflictLogEntryBody struct { + ID string `json:"id"` + MutationID *string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + FieldName string `json:"field_name"` + ServerValue *string `json:"server_value"` + LosingClientValue *string `json:"losing_client_value"` + DetectedAt int64 `json:"detected_at"` +} + +type syncConflictLogResponseBody struct { + Conflicts []syncConflictLogEntryBody `json:"conflicts"` + NextCursor *string `json:"next_cursor"` +} + +var syncConflictRepositoryFor = func(cfg Config, scope storage.Scope) (storage.PushRepository, error) { + if cfg.Store == nil { + return nil, errors.New("httpapi: sync: no storage configured") + } + return cfg.Store.ForGroupPush(scope.GroupID()) +} + +func syncConflictsHandler(cfg Config) middleware.ScopedHandler { + return func(w http.ResponseWriter, r *http.Request, scope storage.Scope) { + q := r.URL.Query() + + limit := defaultSyncConflictsLimit + if v := q.Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 || n > storage.MaxConflictPageLimit { + problem.Write(w, r, problem.BadRequest("limit must be an integer between 1 and "+ + strconv.Itoa(storage.MaxConflictPageLimit))) + return + } + limit = n + } + + var after *storage.ConflictCursor + if v := q.Get("after"); v != "" { + c, err := storage.DecodeConflictCursor(v) + if err != nil { + problem.Write(w, r, problem.BadRequest("after is not a valid cursor")) + return + } + after = &c + } + + repo, err := syncConflictRepositoryFor(cfg, scope) + if err == nil { + var page storage.ConflictPage + page, err = repo.ListConflicts(r.Context(), after, limit) + if err == nil { + body := syncConflictLogResponseBody{ + Conflicts: make([]syncConflictLogEntryBody, 0, len(page.Entries)), + } + for _, e := range page.Entries { + body.Conflicts = append(body.Conflicts, syncConflictLogEntryBody(e)) + } + if page.Next != nil { + s := storage.EncodeConflictCursor(*page.Next) + body.NextCursor = &s + } + writeJSON(w, r, http.StatusOK, body) + return + } + } + cfg.Logger.LogAttrs(r.Context(), slog.LevelError, "sync: list conflicts failed", + slog.String("request_id", requestid.FromContext(r.Context())), + slog.String("error", err.Error()), + ) + problem.Write(w, r, problem.Internal()) + } +} diff --git a/server/internal/httpapi/sync_conflicts_test.go b/server/internal/httpapi/sync_conflicts_test.go new file mode 100644 index 0000000..09e1760 --- /dev/null +++ b/server/internal/httpapi/sync_conflicts_test.go @@ -0,0 +1,402 @@ +package httpapi + +import ( + "context" + "encoding/json" + "fmt" + "github.com/ankek/Household-Objects-Dev/server/api" + "github.com/ankek/Household-Objects-Dev/server/internal/storage" + "gopkg.in/yaml.v3" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +const syncConflictsPath = "/api/v1/sync/conflicts" + +func TestSyncConflictsIsReadableByAMember(t *testing.T) { + cfg := testConfig() + cfg.Authenticator = identityByHeaderAuth(sessionsTestIdentities) + stubConflictSource(t, &fakeConflictRepo{}) + h, err := NewRouter(cfg) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, asTestUser(httptest.NewRequest(http.MethodGet, syncConflictsPath, nil), "member")) + + if rec.Code == http.StatusForbidden { + t.Fatalf("GET %s as a member: status = 403, want 200 -- D4.6/A182 make the conflict log "+ + "member-readable; a 403 means the route was mounted inside the RequireOwner sub-group", + syncConflictsPath) + } + if rec.Code != http.StatusOK { + t.Fatalf("GET %s as a member: status = %d, want %d; body = %s", + syncConflictsPath, rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestSyncConflictsRequiresCredentials(t *testing.T) { + cfg := testConfig() + cfg.Authenticator = identityByHeaderAuth(sessionsTestIdentities) + h, err := NewRouter(cfg) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, syncConflictsPath, nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated GET %s: status = %d, want 401", syncConflictsPath, rec.Code) + } +} + +type fakeConflictRepo struct { + storage.PushRepository + entries []storage.ConflictLogEntry + err error + + gotLimit int + gotAfter *storage.ConflictCursor +} + +func (f *fakeConflictRepo) ListConflicts(_ context.Context, after *storage.ConflictCursor, limit int) (storage.ConflictPage, error) { + f.gotLimit, f.gotAfter = limit, after + if f.err != nil { + return storage.ConflictPage{}, f.err + } + start := 0 + if after != nil { + for i, e := range f.entries { + if e.ID == after.ID { + start = i + 1 + } + } + } + rest := f.entries[start:] + page := storage.ConflictPage{Entries: []storage.ConflictLogEntry{}} + for i, e := range rest { + if i == limit { + last := page.Entries[limit-1] + page.Next = &storage.ConflictCursor{DetectedAt: last.DetectedAt, ID: last.ID} + break + } + page.Entries = append(page.Entries, e) + } + return page, nil +} + +func stubConflictSource(t *testing.T, f *fakeConflictRepo) { + t.Helper() + orig := syncConflictRepositoryFor + syncConflictRepositoryFor = func(Config, storage.Scope) (storage.PushRepository, error) { return f, nil } + t.Cleanup(func() { syncConflictRepositoryFor = orig }) +} + +func syncConflictsRouter(t *testing.T) http.Handler { + t.Helper() + cfg := testConfig() + cfg.Authenticator = authAsGroup("grp-test-a") + h, err := NewRouter(cfg) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return h +} + +func strp(s string) *string { return &s } + +func TestSyncConflictsLimitValidation(t *testing.T) { + tests := []struct { + name string + query string + wantCode int + wantLimit int + }{ + {"default", "", 200, 50}, + {"one", "?limit=1", 200, 1}, + {"max", "?limit=200", 200, 200}, + {"zero", "?limit=0", 400, 0}, + {"over max", "?limit=201", 400, 0}, + {"negative", "?limit=-1", 400, 0}, + {"non integer", "?limit=abc", 400, 0}, + {"fraction", "?limit=1.5", 400, 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + f := &fakeConflictRepo{} + stubConflictSource(t, f) + rec := do(t, syncConflictsRouter(t), http.MethodGet, syncConflictsPath+tc.query) + if rec.Code != tc.wantCode { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, tc.wantCode, rec.Body.String()) + } + if tc.wantCode == 200 && f.gotLimit != tc.wantLimit { + t.Errorf("limit passed to storage = %d, want %d", f.gotLimit, tc.wantLimit) + } + if tc.wantCode == 400 { + if p := decodeProblem(t, rec); p.Detail == "" { + t.Errorf("400 must carry a detail: %+v", p) + } + if f.gotLimit != 0 { + t.Errorf("storage was called on an invalid limit") + } + } + }) + } +} + +func TestSyncConflictsBadCursorIsGeneric400(t *testing.T) { + for _, after := range []string{"!!!not-base64!!!", "aGVsbG8", "Og", "MTo"} { + t.Run(after, func(t *testing.T) { + f := &fakeConflictRepo{} + stubConflictSource(t, f) + rec := do(t, syncConflictsRouter(t), http.MethodGet, syncConflictsPath+"?after="+after) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", rec.Code, rec.Body.String()) + } + p := decodeProblem(t, rec) + if p.Detail != "after is not a valid cursor" { + t.Errorf("detail = %q, want the fixed generic sentence", p.Detail) + } + for _, leak := range []string{"storage", "base64", "illegal", "strconv", "parsing"} { + if strings.Contains(rec.Body.String(), leak) { + t.Errorf("400 body leaks internal text %q: %s", leak, rec.Body.String()) + } + } + }) + } +} + +func TestSyncConflictsStorageErrorIs500WithoutInternalText(t *testing.T) { + f := &fakeConflictRepo{err: fmt.Errorf("storage: list conflicts: SQLITE_BUSY secret-table")} + stubConflictSource(t, f) + rec := do(t, syncConflictsRouter(t), http.MethodGet, syncConflictsPath) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if strings.Contains(rec.Body.String(), "SQLITE") || strings.Contains(rec.Body.String(), "secret-table") { + t.Errorf("500 body leaks internal text: %s", rec.Body.String()) + } +} + +func syncConflictsFixture(n int) []storage.ConflictLogEntry { + out := make([]storage.ConflictLogEntry, n) + for i := range out { + out[i] = storage.ConflictLogEntry{ + ID: "c" + strconv.Itoa(1000-i), EntityType: "item", EntityID: "it-1", FieldName: "name", + DetectedAt: int64(5000 - i), + } + } + return out +} + +func TestSyncConflictsPaginationRoundTrip(t *testing.T) { + f := &fakeConflictRepo{entries: syncConflictsFixture(5)} + stubConflictSource(t, f) + h := syncConflictsRouter(t) + + var seen []string + path := syncConflictsPath + "?limit=2" + for pages := 0; ; pages++ { + if pages > 5 { + t.Fatal("pagination did not terminate") + } + rec := do(t, h, http.MethodGet, path) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body = %s", rec.Code, rec.Body.String()) + } + var body struct { + Conflicts []struct{ ID string } `json:"conflicts"` + NextCursor *string `json:"next_cursor"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + for _, c := range body.Conflicts { + seen = append(seen, c.ID) + } + if body.NextCursor == nil { + break + } + if _, err := storage.DecodeConflictCursor(*body.NextCursor); err != nil { + t.Fatalf("next_cursor is not decodable: %v", err) + } + path = syncConflictsPath + "?limit=2&after=" + *body.NextCursor + } + want := []string{"c1000", "c999", "c998", "c997", "c996"} + if strings.Join(seen, ",") != strings.Join(want, ",") { + t.Fatalf("walked %v, want %v", seen, want) + } +} + +func TestSyncConflictsEmitsExplicitNulls(t *testing.T) { + f := &fakeConflictRepo{entries: []storage.ConflictLogEntry{ + {ID: "c1", EntityType: "item", EntityID: "it-1", FieldName: "_entity", DetectedAt: 7}, + {ID: "c2", MutationID: strp("m-2"), EntityType: "item", EntityID: "it-1", FieldName: "name", + ServerValue: strp(`"a"`), LosingClientValue: strp(`"b"`), DetectedAt: 6}, + }} + stubConflictSource(t, f) + rec := do(t, syncConflictsRouter(t), http.MethodGet, syncConflictsPath) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body = %s", rec.Code, rec.Body.String()) + } + var raw struct { + Conflicts []map[string]json.RawMessage `json:"conflicts"` + NextCursor json.RawMessage `json:"next_cursor"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatal(err) + } + if string(raw.NextCursor) != "null" { + t.Errorf("next_cursor = %q, want the explicit null", raw.NextCursor) + } + if len(raw.Conflicts) != 2 { + t.Fatalf("got %d entries, want 2", len(raw.Conflicts)) + } + for _, key := range []string{"mutation_id", "server_value", "losing_client_value"} { + if got, ok := raw.Conflicts[0][key]; !ok || string(got) != "null" { + t.Errorf("entry 0 key %q = %q (present=%v), want explicit null", key, got, ok) + } + } + if got := string(raw.Conflicts[1]["server_value"]); got != `"\"a\""` { + t.Errorf("entry 1 server_value = %s, want the JSON text as a string", got) + } + + rec = do(t, syncConflictsRouter(t), http.MethodGet, syncConflictsPath+"?limit=1&after="+ + storage.EncodeConflictCursor(storage.ConflictCursor{DetectedAt: 1, ID: "zzz"})) + if !strings.Contains(rec.Body.String(), `"conflicts":[`) { + t.Errorf("body = %s", rec.Body.String()) + } + f.entries = nil + rec = do(t, syncConflictsRouter(t), http.MethodGet, syncConflictsPath) + if got := strings.TrimSpace(rec.Body.String()); got != `{"conflicts":[],"next_cursor":null}` { + t.Errorf("empty log body = %s, want {\"conflicts\":[],\"next_cursor\":null}", got) + } +} + +func TestSyncConflictsResponseMatchesOpenAPISchema(t *testing.T) { + var doc map[string]any + if err := yaml.Unmarshal(api.OpenAPIYAML, &doc); err != nil { + t.Fatal(err) + } + schemas := doc["components"].(map[string]any)["schemas"].(map[string]any) + + f := &fakeConflictRepo{entries: []storage.ConflictLogEntry{ + {ID: "c1", EntityType: "item", EntityID: "it-1", FieldName: "_entity", DetectedAt: 7}, + {ID: "c2", MutationID: strp("m-2"), EntityType: "item", EntityID: "it-1", FieldName: "name", + ServerValue: strp(`"a"`), LosingClientValue: strp(`"b"`), DetectedAt: 6}, + }} + stubConflictSource(t, f) + for _, q := range []string{"?limit=1", ""} { + rec := do(t, syncConflictsRouter(t), http.MethodGet, syncConflictsPath+q) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + dec := json.NewDecoder(strings.NewReader(rec.Body.String())) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + t.Fatal(err) + } + if err := validateAgainstSchema(schemas, map[string]any{"$ref": "#/components/schemas/SyncConflictLogResponse"}, v, "$"); err != nil { + t.Errorf("query %q: %v\nbody: %s", q, err, rec.Body.String()) + } + } + + rec := do(t, syncConflictsRouter(t), http.MethodGet, syncConflictsPath+"?limit=0") + dec := json.NewDecoder(strings.NewReader(rec.Body.String())) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + t.Fatal(err) + } + if err := validateAgainstSchema(schemas, map[string]any{"$ref": "#/components/schemas/Problem"}, v, "$"); err != nil { + t.Errorf("400 body vs Problem: %v", err) + } +} + +func validateAgainstSchema(schemas map[string]any, schema map[string]any, v any, at string) error { + if ref, ok := schema["$ref"].(string); ok { + name := strings.TrimPrefix(ref, "#/components/schemas/") + target, ok := schemas[name].(map[string]any) + if !ok { + return fmt.Errorf("%s: unresolved $ref %s", at, ref) + } + return validateAgainstSchema(schemas, target, v, at) + } + var types []string + switch t := schema["type"].(type) { + case string: + types = []string{t} + case []any: + for _, x := range t { + types = append(types, x.(string)) + } + } + matches := func(typ string) bool { + switch typ { + case "null": + return v == nil + case "string": + _, ok := v.(string) + return ok + case "integer": + n, ok := v.(json.Number) + if !ok { + return false + } + _, err := n.Int64() + return err == nil + case "boolean": + _, ok := v.(bool) + return ok + case "object": + _, ok := v.(map[string]any) + return ok + case "array": + _, ok := v.([]any) + return ok + } + return true + } + if len(types) > 0 { + ok := false + for _, typ := range types { + ok = ok || matches(typ) + } + if !ok { + return fmt.Errorf("%s: value %v does not match type %v", at, v, types) + } + } + switch x := v.(type) { + case map[string]any: + props, _ := schema["properties"].(map[string]any) + if req, ok := schema["required"].([]any); ok { + for _, k := range req { + if _, present := x[k.(string)]; !present { + return fmt.Errorf("%s: required key %q is absent", at, k) + } + } + } + for k, val := range x { + ps, ok := props[k].(map[string]any) + if !ok { + return fmt.Errorf("%s: key %q is not declared by the schema", at, k) + } + if err := validateAgainstSchema(schemas, ps, val, at+"."+k); err != nil { + return err + } + } + case []any: + if items, ok := schema["items"].(map[string]any); ok { + for i, el := range x { + if err := validateAgainstSchema(schemas, items, el, fmt.Sprintf("%s[%d]", at, i)); err != nil { + return err + } + } + } + } + return nil +} diff --git a/server/internal/httpapi/sync_push_detail_format_test.go b/server/internal/httpapi/sync_push_detail_format_test.go new file mode 100644 index 0000000..a50616f --- /dev/null +++ b/server/internal/httpapi/sync_push_detail_format_test.go @@ -0,0 +1,150 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "regexp" + "strconv" + "strings" + "testing" +) + +var androidStructuralDetailRE = regexp.MustCompile(`^mutations\[(\d+)] \(mutation_id="((?:[^"\\]|\\.)*)"\): .*`) + +const trickyMutationID = `mut-"quoted"\back` + +func validPushItem(t *testing.T, id string) syncPushMutationBody { + t.Helper() + return syncPushMutationBody{ + MutationID: id, EntityType: "item", EntityID: "itm-" + id, + Fields: map[string]json.RawMessage{ + "name": pushField(t, "Drill"), + "description": pushField(t, ""), + "location_id": pushField(t, ""), + "quantity": pushField(t, 1), + }, + } +} + +func assertStructuralDetail(t *testing.T, h http.Handler, req syncPushRequestBody, wantIndex int, wantID string) { + t.Helper() + rec := doSyncPush(t, h, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %q", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/problem+json") { + t.Errorf("Content-Type = %q, want application/problem+json", ct) + } + detail := decodeProblem(t, rec).Detail + m := androidStructuralDetailRE.FindStringSubmatch(detail) + if m == nil { + t.Fatalf("detail %q does not match Android's regex", detail) + } + if idx, _ := strconv.Atoi(m[1]); idx != wantIndex { + t.Errorf("captured index = %s, want %d (detail %q)", m[1], wantIndex, detail) + } + gotID, err := strconv.Unquote(`"` + m[2] + `"`) + if err != nil { + t.Fatalf("Unquote(%q): %v", m[2], err) + } + if gotID != wantID { + t.Errorf("unquoted mutation_id = %q, want %q", gotID, wantID) + } +} + +func TestSyncPushStructuralDetailFormat(t *testing.T) { + tests := []struct { + name string + mutations func(t *testing.T) []syncPushMutationBody + wantIndex int + wantID string + }{ + { + name: "handler pre-flight rejection at index 2 (missing entity_type)", + mutations: func(t *testing.T) []syncPushMutationBody { + bad := validPushItem(t, trickyMutationID) + bad.EntityType = "" + return []syncPushMutationBody{validPushItem(t, "ok-0"), validPushItem(t, "ok-1"), bad} + }, + wantIndex: 2, wantID: trickyMutationID, + }, + { + name: "pre-flight rejection with plain id at index 1 (missing entity_id)", + mutations: func(t *testing.T) []syncPushMutationBody { + bad := validPushItem(t, "plain-1") + bad.EntityID = "" + return []syncPushMutationBody{validPushItem(t, "ok-0"), bad} + }, + wantIndex: 1, wantID: "plain-1", + }, + { + name: "syncpkg.Push PushMutationError at index 1 (attachment rejected)", + mutations: func(t *testing.T) []syncPushMutationBody { + return []syncPushMutationBody{ + validPushItem(t, "ok-0"), + {MutationID: trickyMutationID, EntityType: "attachment", EntityID: "att-1", Fields: map[string]json.RawMessage{}}, + validPushItem(t, "ok-2"), + } + }, + wantIndex: 1, wantID: trickyMutationID, + }, + { + name: "syncpkg.Push PushMutationError at index 2 (unknown entity_type)", + mutations: func(t *testing.T) []syncPushMutationBody { + return []syncPushMutationBody{ + validPushItem(t, "ok-0"), validPushItem(t, "ok-1"), + {MutationID: "unk-2", EntityType: "no_such_entity", EntityID: "x-1", Fields: map[string]json.RawMessage{}}, + } + }, + wantIndex: 2, wantID: "unk-2", + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := syncPushTestRouter(t, "grp-push-detailfmt-"+strconv.Itoa(i)) + assertStructuralDetail(t, h, syncPushRequestBody{DeviceID: "dev-1", Mutations: tt.mutations(t)}, tt.wantIndex, tt.wantID) + }) + } +} + +func TestSyncPushRequestLevel400IsNotClassifiedStructural(t *testing.T) { + h := syncPushTestRouter(t, "grp-push-detailfmt-reqlevel") + + t.Run("missing device_id", func(t *testing.T) { + rec := doSyncPush(t, h, syncPushRequestBody{Mutations: []syncPushMutationBody{validPushItem(t, "m")}}) + assertNonStructural400(t, rec) + }) + t.Run("invalid JSON", func(t *testing.T) { + rec := httptestPost(h, "{not json") + assertNonStructural400(t, rec) + }) +} + +func assertNonStructural400(t *testing.T, rec interface { + Result() *http.Response +}) { + t.Helper() + res := rec.Result() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", res.StatusCode) + } + var p struct { + Detail string `json:"detail"` + } + if err := json.NewDecoder(res.Body).Decode(&p); err != nil { + t.Fatalf("decode problem: %v", err) + } + if strings.HasPrefix(p.Detail, "mutations[") || androidStructuralDetailRE.MatchString(p.Detail) { + t.Errorf("request-level detail %q must not look structural", p.Detail) + } + if p.Detail == "" { + t.Errorf("detail is empty") + } +} + +func httptestPost(h http.Handler, body string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/sync/push", strings.NewReader(body))) + return rec +} diff --git a/server/internal/httpapi/tenant_isolation_coverage.txt b/server/internal/httpapi/tenant_isolation_coverage.txt index e8c81c0..84e9eb0 100644 --- a/server/internal/httpapi/tenant_isolation_coverage.txt +++ b/server/internal/httpapi/tenant_isolation_coverage.txt @@ -14,6 +14,7 @@ PUT /api/v1/custom-field-defs/{fieldDefID} covered-by: TestTenantIsolation/(cook GET /api/v1/groups/detail-visibility covered-by: TestTenantIsolation/(cookie|bearer)/groups_visibility_get -- 200 with a real version (not the collection-route cross-group-404 shape: there is no {id} in this path to name wrong, per the FORMAT section's second assertion shape); the isolation claim proper is carried by group_a_resources_untouched below, which is the only check that would catch this route's sibling (PUT) leaking a write into another tenant's row PUT /api/v1/groups/detail-visibility covered-by: TestTenantIsolation/(cookie|bearer)/groups_visibility_update -- household B (an owner of its OWN group, so a 403 here would be the wrong failure, same as invites_list below) successfully toggles its own visibility state twice, once per credential kind; TestTenantIsolation/group_a_resources_untouched re-reads household A's visibility state afterwards, using A's own cookie, and requires it exactly as provisionTenant left it (all three flags false, version 1) -- the only check that would catch UpdateGroupVisibility's `id = ?` predicate breaking and B's own-group write landing on A's row as well as (or instead of) B's, since SQLite applies an UPDATE with no tenant predicate to every row a bare `version = ?` still matches GET /api/v1/groups/members covered-by: TestTenantIsolation/(cookie|bearer)/members_list -- household B's own roster (username, role, joined-at) contains EXACTLY household B's one registering user (household A's own username is absent, and an empty listing would satisfy that exclusion vacuously -- the same non-vacuity argument invites_list/labels_list make for their own collections); this is the FORMAT section's SECOND assertion shape (collection-absence), the identical shape groups_visibility_get's own line states for the identical reason -- there is no {id} in this path to name wrong, the resource is the caller's own group's membership. Member-readable (A115, not owner-gated like invites_list below): household B's registering user IS an owner of its own group throughout this suite, so this loop alone cannot observe the READ-side role claim either way (a 403 would be indistinguishable from a correctly-scoped 200 that merely happened to answer wrong); TestMemberListIsReadableByAMember (internal/httpapi) is what proves it, authenticating as a genuine non-owner member via identityByHeaderAuth, the identical seam TestGroupVisibilityGetIsReadableByAMember/TestCustomFieldDefListIsReadableByAMember use for their own inverse claim. Storage-layer half: storage/members_test.go's TestMemberListIsScopedToItsOwnGroup, the identical shape TestGroupVisibilityGetIsScopedToItsOwnGroup proves for its own group-singleton resource +GET /api/v1/sync/conflicts covered-by: server/tests/integration's own TestTenantIsolationSyncConflictsDoesNotLeakAcrossGroups (T169c) -- two households each produce a same-field conflict through REAL POST /sync/push calls; each household's GET (cookie and bearer, paged with limit=1) lists exactly its own entry (wire mutation_id, own server_value/losing_client_value) and the raw body never contains the other household's item id, label id, group id, mutation ids or values (non-vacuous: both logs are non-empty). FU-57: household A pushes a create whose entity_id is household B's real item id (pushRowExists is group-agnostic) -- A's log gets a whole-entity conflict whose server_value/losing_client_value are null, never B's item name or edited values, B's log gains nothing, and B's item is unchanged. The route is a middleware.Scoped read over a group-bound PushRepository (storage.ListConflicts binds group_id); member-readable, not owner-gated: internal/httpapi's TestSyncConflictsIsReadableByAMember pins a plain member gets 200, never 403 GET /api/v1/invites covered-by: TestTenantIsolation/(cookie|bearer)/invites_list -- group A's invite id is absent from group B's listing (caller is an owner, so a 403 would be the wrong failure) POST /api/v1/invites covered-by: TestUnscopedConstructionDoesNotCompile (T030, server/tests/tenant_isolation/compilefail_test.go) -- inviteCreateHandler passes GroupID: identity.Group, the authenticated scope, never a request field (a caller cannot name the group it writes to), so P-3's group-bound repositories (T026) enforce this route's tenant property structurally and the compile-fail suite proves an unscoped construction does not build; belt-and-braces runtime check: TestTenantIsolation/(cookie|bearer)/invites_create_readback -- create as group B, the new invite is present in B's own listing and absent from A's POST /api/v1/invites/redeem exempt: unauthenticated (public) -- no tenant to cross diff --git a/server/internal/storage/internal/gen/conflicts.sql.go b/server/internal/storage/internal/gen/conflicts.sql.go index 5b870e2..4defd85 100644 --- a/server/internal/storage/internal/gen/conflicts.sql.go +++ b/server/internal/storage/internal/gen/conflicts.sql.go @@ -49,3 +49,79 @@ func (q *Queries) CreateConflict(ctx context.Context, arg CreateConflictParams) ) return err } + +const listConflictsPage = `-- name: ListConflictsPage :many +SELECT + c.id, + m.mutation_id AS wire_mutation_id, + c.entity_type, + c.entity_id, + c.field_name, + c.server_value_snapshot, + c.losing_client_value_snapshot, + c.detected_at +FROM conflicts c +LEFT JOIN mutations m + ON m.id = c.mutation_id + AND m.group_id = c.group_id + AND m.group_id = ?1 +WHERE c.group_id = ?1 + AND (c.detected_at, c.id) < (CAST(?2 AS INTEGER), CAST(?3 AS TEXT)) +ORDER BY c.detected_at DESC, c.id DESC +LIMIT ?4 +` + +type ListConflictsPageParams struct { + GroupID string + AfterDetectedAt int64 + AfterID string + PageLimit int64 +} + +type ListConflictsPageRow struct { + ID string + WireMutationID sql.NullString + EntityType string + EntityID string + FieldName string + ServerValueSnapshot sql.NullString + LosingClientValueSnapshot sql.NullString + DetectedAt int64 +} + +func (q *Queries) ListConflictsPage(ctx context.Context, arg ListConflictsPageParams) ([]ListConflictsPageRow, error) { + rows, err := q.db.QueryContext(ctx, listConflictsPage, + arg.GroupID, + arg.AfterDetectedAt, + arg.AfterID, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ListConflictsPageRow{} + for rows.Next() { + var i ListConflictsPageRow + if err := rows.Scan( + &i.ID, + &i.WireMutationID, + &i.EntityType, + &i.EntityID, + &i.FieldName, + &i.ServerValueSnapshot, + &i.LosingClientValueSnapshot, + &i.DetectedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/server/internal/storage/push.go b/server/internal/storage/push.go index 6e1e758..fd998a5 100644 --- a/server/internal/storage/push.go +++ b/server/internal/storage/push.go @@ -3,9 +3,13 @@ package storage import ( "context" "database/sql" + "encoding/base64" "errors" "fmt" "github.com/ankek/Household-Objects-Dev/server/internal/storage/internal/gen" + "math" + "strconv" + "strings" ) type MutationLedgerEntry = gen.Mutation @@ -124,6 +128,55 @@ type PushRepository interface { UpsertFieldVersion(ctx context.Context, p UpsertFieldVersionParams) error InsertConflict(ctx context.Context, p InsertConflictParams) (ConflictRecord, error) + + ListConflicts(ctx context.Context, after *ConflictCursor, limit int) (ConflictPage, error) +} + +const MaxConflictPageLimit = 200 + +var ErrInvalidConflictPage = errors.New("storage: conflict page limit is out of range") + +var ErrInvalidConflictCursor = errors.New("storage: malformed conflict cursor") + +type ConflictLogEntry struct { + ID string + MutationID *string + EntityType string + EntityID string + FieldName string + ServerValue *string + LosingClientValue *string + DetectedAt int64 +} + +type ConflictCursor struct { + DetectedAt int64 + ID string +} + +type ConflictPage struct { + Entries []ConflictLogEntry + Next *ConflictCursor +} + +func EncodeConflictCursor(c ConflictCursor) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(c.DetectedAt, 10) + ":" + c.ID)) +} + +func DecodeConflictCursor(s string) (ConflictCursor, error) { + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return ConflictCursor{}, ErrInvalidConflictCursor + } + ts, id, ok := strings.Cut(string(raw), ":") + if !ok || id == "" { + return ConflictCursor{}, ErrInvalidConflictCursor + } + n, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return ConflictCursor{}, ErrInvalidConflictCursor + } + return ConflictCursor{DetectedAt: n, ID: id}, nil } type pushRepository struct { @@ -187,6 +240,49 @@ func (r pushRepository) InsertConflict(ctx context.Context, p InsertConflictPara return created, nil } +func (r pushRepository) ListConflicts(ctx context.Context, after *ConflictCursor, limit int) (ConflictPage, error) { + if limit < 1 || limit > MaxConflictPageLimit { + return ConflictPage{}, fmt.Errorf("%w: %d", ErrInvalidConflictPage, limit) + } + afterAt, afterID := int64(math.MaxInt64), "" + if after != nil { + afterAt, afterID = after.DetectedAt, after.ID + } + rows, err := r.queries().ListConflictsPage(ctx, gen.ListConflictsPageParams{ + GroupID: r.group(), AfterDetectedAt: afterAt, AfterID: afterID, PageLimit: int64(limit) + 1, + }) + if err != nil { + return ConflictPage{}, fmt.Errorf("storage: list conflicts: %w", err) + } + page := ConflictPage{Entries: make([]ConflictLogEntry, 0, min(len(rows), limit))} + for i, row := range rows { + if i == limit { + last := page.Entries[limit-1] + page.Next = &ConflictCursor{DetectedAt: last.DetectedAt, ID: last.ID} + break + } + page.Entries = append(page.Entries, ConflictLogEntry{ + ID: row.ID, + MutationID: stringPtrOrNil(row.WireMutationID), + EntityType: row.EntityType, + EntityID: row.EntityID, + FieldName: row.FieldName, + ServerValue: stringPtrOrNil(row.ServerValueSnapshot), + LosingClientValue: stringPtrOrNil(row.LosingClientValueSnapshot), + DetectedAt: row.DetectedAt, + }) + } + return page, nil +} + +func stringPtrOrNil(n sql.NullString) *string { + if !n.Valid { + return nil + } + v := n.String + return &v +} + func lookupMutationLedgerEntryTx(ctx context.Context, q *gen.Queries, group, mutationID string) (MutationLedgerEntry, error) { row, err := q.GetMutationByMutationID(ctx, gen.GetMutationByMutationIDParams{ GroupID: group, MutationID: mutationID, diff --git a/server/internal/storage/push_conflicts_test.go b/server/internal/storage/push_conflicts_test.go new file mode 100644 index 0000000..402dca7 --- /dev/null +++ b/server/internal/storage/push_conflicts_test.go @@ -0,0 +1,237 @@ +package storage + +import ( + "errors" + "fmt" + "testing" +) + +func seedConflict(t *testing.T, r PushRepository, id, entityID string, detectedAt int64, ledgerRowID string) { + t.Helper() + if _, err := r.InsertConflict(t.Context(), InsertConflictParams{ + ID: id, EntityType: "item", EntityID: entityID, FieldName: "name", + ServerValueSnapshot: `"srv-` + id + `"`, LosingClientValueSnapshot: `"cli-` + id + `"`, + DetectedAt: detectedAt, MutationID: ledgerRowID, Now: detectedAt, + }); err != nil { + t.Fatalf("InsertConflict %s: %v", id, err) + } +} + +func conflictIDs(es []ConflictLogEntry) []string { + out := make([]string, len(es)) + for i, e := range es { + out[i] = e.ID + } + return out +} + +func TestListConflictsIsGroupScoped(t *testing.T) { + repoA, repoB, _ := pushScope(t) + seedConflict(t, repoA, "a1", "item-a", 10, "") + seedConflict(t, repoA, "a2", "item-a", 20, "") + seedConflict(t, repoB, "b1", "item-b", 15, "") + + pa, err := repoA.ListConflicts(t.Context(), nil, 50) + if err != nil { + t.Fatal(err) + } + if got := conflictIDs(pa.Entries); fmt.Sprint(got) != "[a2 a1]" { + t.Errorf("groupA sees %v, want [a2 a1]", got) + } + pb, err := repoB.ListConflicts(t.Context(), nil, 50) + if err != nil { + t.Fatal(err) + } + if got := conflictIDs(pb.Entries); fmt.Sprint(got) != "[b1]" { + t.Errorf("groupB sees %v, want [b1]", got) + } + if pa.Next != nil || pb.Next != nil { + t.Errorf("Next must be nil when everything fits: %v %v", pa.Next, pb.Next) + } +} + +func TestListConflictsCrossGroupEntityCollision(t *testing.T) { + repoA, repoB, s := pushScope(t) + scopeB, err := s.ForGroup(MustGroupID("groupB")) + if err != nil { + t.Fatal(err) + } + if _, err := scopeB.Items().Create(t.Context(), CreateItemParams{ + ID: "shared-x", Name: "B-secret-name", ShortCode: "BSEC", Now: 1, + }); err != nil { + t.Fatalf("create B item: %v", err) + } + seedConflict(t, repoA, "a-collide", "shared-x", 100, "") + + pb, err := repoB.ListConflicts(t.Context(), nil, 50) + if err != nil { + t.Fatal(err) + } + if len(pb.Entries) != 0 { + t.Fatalf("groupB sees %v, want none: A's conflict on B's entity id must not leak", conflictIDs(pb.Entries)) + } + + pa, err := repoA.ListConflicts(t.Context(), nil, 50) + if err != nil { + t.Fatal(err) + } + if len(pa.Entries) != 1 { + t.Fatalf("groupA sees %d entries, want 1", len(pa.Entries)) + } + e := pa.Entries[0] + if e.ServerValue == nil || *e.ServerValue != `"srv-a-collide"` || *e.LosingClientValue != `"cli-a-collide"` { + t.Errorf("A's entry values = %v/%v, want its own snapshots", e.ServerValue, e.LosingClientValue) + } + if e.ServerValue != nil && (*e.ServerValue == "B-secret-name" || *e.ServerValue == `"B-secret-name"`) { + t.Errorf("server_value resolved from B's live item: %q", *e.ServerValue) + } +} + +func TestListConflictsJoinNeverResolvesAcrossGroups(t *testing.T) { + repoA, repoB, s := pushScope(t) + if _, err := repoB.Insert(t.Context(), InsertMutationLedgerEntryParams{ + ID: "ledger-b", MutationID: "wire-b", EntityType: "item", EntityID: "x", + Outcome: MutationOutcomeConflict, Now: 1, + }); err != nil { + t.Fatal(err) + } + seedConflict(t, repoA, "a-forged", "x", 5, "") + conn, err := s.store.Writer().Conn(t.Context()) + if err != nil { + t.Fatalf("conn: %v", err) + } + defer conn.Close() + if _, err := conn.ExecContext(t.Context(), `PRAGMA foreign_keys = OFF`); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(t.Context(), `UPDATE conflicts SET mutation_id = 'ledger-b' WHERE id = 'a-forged'`); err != nil { + t.Fatal(err) + } + if _, err := conn.ExecContext(t.Context(), `PRAGMA foreign_keys = ON`); err != nil { + t.Fatal(err) + } + + pa, err := repoA.ListConflicts(t.Context(), nil, 10) + if err != nil { + t.Fatal(err) + } + if len(pa.Entries) != 1 || pa.Entries[0].MutationID != nil { + t.Fatalf("A's forged entry = %+v, want one entry with nil mutation_id (B's ledger must not resolve)", pa.Entries) + } +} + +func TestListConflictsMutationIDIsWireIDAndNullWithoutLedgerRow(t *testing.T) { + repoA, _, _ := pushScope(t) + if _, err := repoA.Insert(t.Context(), InsertMutationLedgerEntryParams{ + ID: "ledger-row-1", MutationID: "wire-uuid-1", EntityType: "item", EntityID: "i1", + Outcome: MutationOutcomeConflict, Now: 1, + }); err != nil { + t.Fatal(err) + } + seedConflict(t, repoA, "with-ledger", "i1", 20, "ledger-row-1") + seedConflict(t, repoA, "no-ledger", "i1", 10, "") + + p, err := repoA.ListConflicts(t.Context(), nil, 10) + if err != nil { + t.Fatal(err) + } + if len(p.Entries) != 2 { + t.Fatalf("got %d entries", len(p.Entries)) + } + w, n := p.Entries[0], p.Entries[1] + if w.ID != "with-ledger" || w.MutationID == nil || *w.MutationID != "wire-uuid-1" { + t.Errorf("with-ledger mutation_id = %v, want wire-uuid-1 (not the ledger row id)", w.MutationID) + } + if n.ID != "no-ledger" || n.MutationID != nil { + t.Errorf("no-ledger mutation_id = %v, want nil", n.MutationID) + } +} + +func TestListConflictsNullSnapshotsAreNil(t *testing.T) { + repoA, _, _ := pushScope(t) + if _, err := repoA.InsertConflict(t.Context(), InsertConflictParams{ + ID: "c", EntityType: "item", EntityID: "i", FieldName: "_entity", DetectedAt: 1, Now: 1, + }); err != nil { + t.Fatal(err) + } + p, err := repoA.ListConflicts(t.Context(), nil, 10) + if err != nil || len(p.Entries) != 1 { + t.Fatalf("err=%v entries=%d", err, len(p.Entries)) + } + if p.Entries[0].ServerValue != nil || p.Entries[0].LosingClientValue != nil { + t.Errorf("snapshots = %v/%v, want nil/nil", p.Entries[0].ServerValue, p.Entries[0].LosingClientValue) + } +} + +func TestListConflictsKeysetPagination(t *testing.T) { + repoA, _, _ := pushScope(t) + seedConflict(t, repoA, "z0", "i", 10, "") + seedConflict(t, repoA, "b", "i", 50, "") + seedConflict(t, repoA, "d", "i", 50, "") + seedConflict(t, repoA, "c", "i", 50, "") + seedConflict(t, repoA, "a", "i", 40, "") + seedConflict(t, repoA, "e", "i", 60, "") + want := []string{"e", "d", "c", "b", "a", "z0"} + + var got []string + var cur *ConflictCursor + pages := 0 + for { + p, err := repoA.ListConflicts(t.Context(), cur, 2) + if err != nil { + t.Fatal(err) + } + pages++ + if len(p.Entries) > 2 { + t.Fatalf("limit not honoured: %d entries", len(p.Entries)) + } + got = append(got, conflictIDs(p.Entries)...) + if p.Next == nil { + break + } + if len(p.Entries) != 2 { + t.Fatalf("Next set on a short page") + } + dec, err := DecodeConflictCursor(EncodeConflictCursor(*p.Next)) + if err != nil || dec != *p.Next { + t.Fatalf("cursor round trip: %v %+v vs %+v", err, dec, *p.Next) + } + cur = &dec + if pages > 10 { + t.Fatal("pagination does not terminate") + } + if pages == 1 { + seedConflict(t, repoA, "newest", "i", 999, "") + } + } + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("walk = %v, want %v", got, want) + } + if pages != 3 { + t.Errorf("pages = %d, want 3 (exact-multiple final page has Next == nil)", pages) + } +} + +func TestListConflictsLimitBounds(t *testing.T) { + repoA, _, _ := pushScope(t) + for _, l := range []int{0, -1, MaxConflictPageLimit + 1} { + if _, err := repoA.ListConflicts(t.Context(), nil, l); !errors.Is(err, ErrInvalidConflictPage) { + t.Errorf("limit %d: err = %v, want ErrInvalidConflictPage", l, err) + } + } + if _, err := repoA.ListConflicts(t.Context(), nil, MaxConflictPageLimit); err != nil { + t.Errorf("limit %d: %v", MaxConflictPageLimit, err) + } +} + +func TestDecodeConflictCursorRejectsGarbage(t *testing.T) { + for _, s := range []string{"", "!!!", "bm9jb2xvbg", EncodeConflictCursor(ConflictCursor{})} { + if _, err := DecodeConflictCursor(s); !errors.Is(err, ErrInvalidConflictCursor) { + t.Errorf("Decode(%q) err = %v, want ErrInvalidConflictCursor", s, err) + } + } + c := ConflictCursor{DetectedAt: 7, ID: "a:b:c"} + if got, err := DecodeConflictCursor(EncodeConflictCursor(c)); err != nil || got != c { + t.Errorf("colon-bearing id round trip = %+v, %v", got, err) + } +} diff --git a/server/internal/storage/queries/conflicts.sql b/server/internal/storage/queries/conflicts.sql index 6d50639..50da0cd 100644 --- a/server/internal/storage/queries/conflicts.sql +++ b/server/internal/storage/queries/conflicts.sql @@ -9,3 +9,23 @@ VALUES ( sqlc.arg(server_value_snapshot), sqlc.arg(losing_client_value_snapshot), sqlc.arg(detected_at), sqlc.arg(mutation_id), sqlc.arg(now), sqlc.arg(now) ); + +-- name: ListConflictsPage :many +SELECT + c.id, + m.mutation_id AS wire_mutation_id, + c.entity_type, + c.entity_id, + c.field_name, + c.server_value_snapshot, + c.losing_client_value_snapshot, + c.detected_at +FROM conflicts c +LEFT JOIN mutations m + ON m.id = c.mutation_id + AND m.group_id = c.group_id + AND m.group_id = sqlc.arg(group_id) +WHERE c.group_id = sqlc.arg(group_id) + AND (c.detected_at, c.id) < (CAST(sqlc.arg(after_detected_at) AS INTEGER), CAST(sqlc.arg(after_id) AS TEXT)) +ORDER BY c.detected_at DESC, c.id DESC +LIMIT sqlc.arg(page_limit); diff --git a/server/internal/sync/push_self_conflict_test.go b/server/internal/sync/push_self_conflict_test.go new file mode 100644 index 0000000..214b290 --- /dev/null +++ b/server/internal/sync/push_self_conflict_test.go @@ -0,0 +1,209 @@ +package sync + +import ( + "database/sql" + "github.com/ankek/Household-Objects-Dev/server/internal/storage" + _ "modernc.org/sqlite" + "path/filepath" + "testing" +) + +type selfConflictEnv struct { + s *storage.Storage + commit storage.PushCommitRepository + scope storage.Scope + raw *sql.DB +} + +func newSelfConflictEnv(t *testing.T) selfConflictEnv { + t.Helper() + path := filepath.Join(t.TempDir(), "db", "hho.db") + s, err := storage.Open(t.Context(), storage.Config{Path: path, ReadConns: 2}) + if err != nil { + t.Fatalf("storage.Open: %v", err) + } + t.Cleanup(func() { + if err := s.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }) + scope := seedGroup(t, s, "groupA") + commit := pushCommitFor(t, s, "groupA") + + raw, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("open inspection handle: %v", err) + } + t.Cleanup(func() { _ = raw.Close() }) + + res, err := Push(t.Context(), commit, PushBatch{Now: 100, Mutations: []PushMutation{{ + MutationID: "mut-create", EntityType: "item", EntityID: "item-x", BaseVersion: 0, + Fields: pushFields(t, map[string]any{"name": "Orig"}), + }}}) + if err != nil || len(res.Applied) != 1 || res.Applied[0].Version != 1 { + t.Fatalf("seed create: res=%+v err=%v, want one applied at version 1", res, err) + } + return selfConflictEnv{s: s, commit: commit, scope: scope, raw: raw} +} + +func (e selfConflictEnv) push(t *testing.T, now int64, ms ...PushMutation) PushResult { + t.Helper() + res, err := Push(t.Context(), e.commit, PushBatch{Mutations: ms, Now: now}) + if err != nil { + t.Fatalf("Push: %v", err) + } + return res +} + +func (e selfConflictEnv) item(t *testing.T) storage.Item { + t.Helper() + it, err := e.scope.Items().Get(t.Context(), "item-x") + if err != nil { + t.Fatalf("Items().Get: %v", err) + } + return it +} + +type conflictRow struct{ field, losing, mutationID string } + +func (e selfConflictEnv) conflicts(t *testing.T) []conflictRow { + t.Helper() + rows, err := e.raw.QueryContext(t.Context(), `SELECT field_name, COALESCE(losing_client_value_snapshot, ''), COALESCE(mutation_id, '') + FROM conflicts WHERE group_id = 'groupA' AND entity_type = 'item' AND entity_id = 'item-x' ORDER BY created_at, id`) + if err != nil { + t.Fatalf("query conflicts: %v", err) + } + defer rows.Close() + var out []conflictRow + for rows.Next() { + var r conflictRow + if err := rows.Scan(&r.field, &r.losing, &r.mutationID); err != nil { + t.Fatalf("scan: %v", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + return out +} + +func (e selfConflictEnv) ledgerID(t *testing.T, mutationID string) string { + t.Helper() + var id string + if err := e.raw.QueryRowContext(t.Context(), `SELECT id FROM mutations WHERE group_id = 'groupA' AND mutation_id = ?`, mutationID).Scan(&id); err != nil { + t.Fatalf("ledger row for %s: %v", mutationID, err) + } + return id +} + +func mut(t *testing.T, id string, base int64, fields map[string]any) PushMutation { + t.Helper() + return PushMutation{MutationID: id, EntityType: "item", EntityID: "item-x", BaseVersion: base, Fields: pushFields(t, fields)} +} + +func TestPushSelfConflictRule(t *testing.T) { + t.Run("separate pushes: M2 conflicts with applied M1, row written, ledger replays M2 as skipped", func(t *testing.T) { + e := newSelfConflictEnv(t) + m1 := mut(t, "m1", 1, map[string]any{"name": "First"}) + m2 := mut(t, "m2", 1, map[string]any{"name": "Second"}) + + r1 := e.push(t, 200, m1) + if len(r1.Applied) != 1 || r1.Applied[0].MutationID != "m1" || r1.Applied[0].Version != 2 || len(r1.Conflicts) != 0 { + t.Fatalf("M1 result = %+v, want applied at version 2, no conflicts", r1) + } + + r2 := e.push(t, 300, m2) + if len(r2.Applied) != 0 || len(r2.Skipped) != 0 { + t.Fatalf("M2 result = %+v, want nothing applied or skipped", r2) + } + if len(r2.Conflicts) != 1 || r2.Conflicts[0].MutationID != "m2" || r2.Conflicts[0].FieldName != "name" || r2.Conflicts[0].EntityID != "item-x" { + t.Fatalf("M2 Conflicts = %+v, want one entry {m2 item item-x name}", r2.Conflicts) + } + + rows := e.conflicts(t) + if len(rows) != 1 || rows[0].field != "name" || rows[0].losing != `"Second"` || rows[0].mutationID != e.ledgerID(t, "m2") { + t.Fatalf("conflicts rows = %+v, want exactly one for name, losing value \"Second\", linked to M2's ledger row", rows) + } + if it := e.item(t); it.Name != "First" || it.Version != 2 { + t.Fatalf("item = name %q version %d, want \"First\" at version 2", it.Name, it.Version) + } + + replay := e.push(t, 400, m2) + if len(replay.Skipped) != 1 || replay.Skipped[0].MutationID != "m2" || len(replay.Applied) != 0 || len(replay.Conflicts) != 0 { + t.Fatalf("M2 replay = %+v, want only Skipped[m2]", replay) + } + if replay.NewWatermark != r2.NewWatermark { + t.Fatalf("replay watermark = %d, want unchanged %d", replay.NewWatermark, r2.NewWatermark) + } + if got := e.conflicts(t); len(got) != 1 { + t.Fatalf("conflicts rows after replay = %+v, want still 1", got) + } + if it := e.item(t); it.Name != "First" || it.Version != 2 { + t.Fatalf("item after replay = name %q version %d, want unchanged", it.Name, it.Version) + } + }) + + t.Run("same batch: M1 and M2 with the same stale base", func(t *testing.T) { + e := newSelfConflictEnv(t) + m1 := mut(t, "m1", 1, map[string]any{"name": "First"}) + m2 := mut(t, "m2", 1, map[string]any{"name": "Second"}) + + res := e.push(t, 200, m1, m2) + if len(res.Applied) != 1 || res.Applied[0].MutationID != "m1" || res.Applied[0].Version != 2 { + t.Fatalf("Applied = %+v, want only m1 at version 2", res.Applied) + } + if len(res.Conflicts) != 1 || res.Conflicts[0].MutationID != "m2" || res.Conflicts[0].FieldName != "name" { + t.Fatalf("Conflicts = %+v, want one entry for m2 name", res.Conflicts) + } + if rows := e.conflicts(t); len(rows) != 1 || rows[0].losing != `"Second"` { + t.Fatalf("conflicts rows = %+v, want one with losing value \"Second\"", rows) + } + if it := e.item(t); it.Name != "First" || it.Version != 2 { + t.Fatalf("item = name %q version %d, want \"First\" at 2", it.Name, it.Version) + } + + replay := e.push(t, 400, m1, m2) + if len(replay.Skipped) != 2 || len(replay.Applied) != 0 || len(replay.Conflicts) != 0 { + t.Fatalf("batch replay = %+v, want both skipped", replay) + } + if got := e.conflicts(t); len(got) != 1 { + t.Fatalf("conflicts rows after replay = %+v, want still 1", got) + } + }) + + t.Run("control: M2 rebased onto M1's applied version applies", func(t *testing.T) { + e := newSelfConflictEnv(t) + r1 := e.push(t, 200, mut(t, "m1", 1, map[string]any{"name": "First"})) + if len(r1.Applied) != 1 { + t.Fatalf("M1 result = %+v, want applied", r1) + } + r2 := e.push(t, 300, mut(t, "m2", r1.Applied[0].Version, map[string]any{"name": "Second"})) + if len(r2.Applied) != 1 || r2.Applied[0].MutationID != "m2" || r2.Applied[0].Version != r1.Applied[0].Version+1 || len(r2.Conflicts) != 0 { + t.Fatalf("M2 result = %+v, want applied at version %d, no conflicts", r2, r1.Applied[0].Version+1) + } + if rows := e.conflicts(t); len(rows) != 0 { + t.Fatalf("conflicts rows = %+v, want none", rows) + } + if it := e.item(t); it.Name != "Second" || it.Version != 3 { + t.Fatalf("item = name %q version %d, want \"Second\" at 3", it.Name, it.Version) + } + }) + + t.Run("different fields with the same stale base merge without conflict", func(t *testing.T) { + e := newSelfConflictEnv(t) + res := e.push(t, 200, + mut(t, "m1", 1, map[string]any{"name": "First"}), + mut(t, "m2", 1, map[string]any{"description": "Desc"}), + ) + if len(res.Applied) != 2 || len(res.Conflicts) != 0 { + t.Fatalf("result = %+v, want both applied, no conflicts", res) + } + if rows := e.conflicts(t); len(rows) != 0 { + t.Fatalf("conflicts rows = %+v, want none", rows) + } + if it := e.item(t); it.Name != "First" || it.Description != "Desc" || it.Version != 3 { + t.Fatalf("item = %q/%q v%d, want First/Desc v3", it.Name, it.Description, it.Version) + } + }) +} diff --git a/server/tests/integration/sync_push_android_stocktake_replay_test.go b/server/tests/integration/sync_push_android_stocktake_replay_test.go new file mode 100644 index 0000000..80116d5 --- /dev/null +++ b/server/tests/integration/sync_push_android_stocktake_replay_test.go @@ -0,0 +1,240 @@ +package integration + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "testing" +) + +const stocktakeGoldenDir = "../../../android-app/app/src/test/resources/golden" + +func stocktakeReplayLoadGolden(name string) ([]byte, error) { + b, err := os.ReadFile(filepath.Join(stocktakeGoldenDir, name)) + if err != nil { + return nil, fmt.Errorf("golden file %s: %w", name, err) + } + if len(b) == 0 { + return nil, fmt.Errorf("golden file %s is empty", name) + } + return b, nil +} + +func stocktakeReplayMustLoad(t *testing.T, name string) []byte { + t.Helper() + b, err := stocktakeReplayLoadGolden(name) + if err != nil { + t.Fatalf("the Android golden is required, not optional: %v", err) + } + return b +} + +type stocktakeReplayItem struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + LocationID string `json:"location_id"` + Quantity int64 `json:"quantity"` +} + +type stocktakeReplayEdge struct { + ItemID string `json:"item_id"` + LabelID string `json:"label_id"` +} + +type stocktakeReplayExpected struct { + Items []stocktakeReplayItem `json:"items"` + ItemLabels []stocktakeReplayEdge `json:"item_labels"` +} + +func stocktakeReplayLiveItems(t *testing.T, db *sql.DB) []stocktakeReplayItem { + t.Helper() + rows, err := db.Query("SELECT id, name, description, COALESCE(location_id, ''), quantity FROM items WHERE deleted_at IS NULL ORDER BY id") + if err != nil { + t.Fatalf("query items: %v", err) + } + defer func() { _ = rows.Close() }() + var out []stocktakeReplayItem + for rows.Next() { + var it stocktakeReplayItem + if err := rows.Scan(&it.ID, &it.Name, &it.Description, &it.LocationID, &it.Quantity); err != nil { + t.Fatalf("scan item: %v", err) + } + out = append(out, it) + } + if err := rows.Err(); err != nil { + t.Fatalf("items rows: %v", err) + } + return out +} + +func stocktakeReplayLiveEdges(t *testing.T, db *sql.DB) []stocktakeReplayEdge { + t.Helper() + rows, err := db.Query("SELECT item_id, label_id FROM item_labels WHERE deleted_at IS NULL ORDER BY item_id, label_id") + if err != nil { + t.Fatalf("query item_labels: %v", err) + } + defer func() { _ = rows.Close() }() + var out []stocktakeReplayEdge + for rows.Next() { + var e stocktakeReplayEdge + if err := rows.Scan(&e.ItemID, &e.LabelID); err != nil { + t.Fatalf("scan item_label: %v", err) + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + t.Fatalf("item_labels rows: %v", err) + } + return out +} + +func stocktakeReplayAssertNoConflicts(t *testing.T, srv *liveServer, cookie, when string) { + t.Helper() + rec := srv.do(t, http.MethodGet, "/api/v1/sync/conflicts?limit=200", cookie, nil) + if rec.Code != http.StatusOK { + t.Fatalf("%s: GET /sync/conflicts: status = %d: %s", when, rec.Code, rec.Body.String()) + } + var body struct { + Conflicts []json.RawMessage `json:"conflicts"` + NextCursor *string `json:"next_cursor"` + } + mustDecode(t, rec, &body) + if len(body.Conflicts) != 0 || body.NextCursor != nil { + t.Fatalf("%s: conflict log = %d entries (next_cursor %v), want empty: %s", when, len(body.Conflicts), body.NextCursor, rec.Body.String()) + } +} + +func stocktakeReplayParse(t *testing.T, raw []byte, wantMutations int, label string) pushReplayRequestWire { + t.Helper() + var req pushReplayRequestWire + if err := json.Unmarshal(raw, &req); err != nil { + t.Fatalf("%s: decode golden: %v", label, err) + } + if req.DeviceID == "" || len(req.Mutations) != wantMutations { + t.Fatalf("%s: golden has device_id=%q and %d mutations, want a device id and exactly %d", label, req.DeviceID, len(req.Mutations), wantMutations) + } + return req +} + +func TestSyncPushAndroidStocktakeGoldenReplay(t *testing.T) { + seedRaw := stocktakeReplayMustLoad(t, "stocktake-seed-push-request.json") + pushRaw := stocktakeReplayMustLoad(t, "stocktake-push-request.json") + expectedRaw := stocktakeReplayMustLoad(t, "stocktake-expected-state.json") + + seed := stocktakeReplayParse(t, seedRaw, 43, "seed") + golden := stocktakeReplayParse(t, pushRaw, 50, "push") + var expected stocktakeReplayExpected + if err := json.Unmarshal(expectedRaw, &expected); err != nil { + t.Fatalf("decode expected state: %v", err) + } + if len(expected.Items) != 30 || len(expected.ItemLabels) == 0 { + t.Fatalf("expected-state golden has %d items and %d edges, want 30 items and some edges", len(expected.Items), len(expected.ItemLabels)) + } + + srv := newLiveServer(t, false) + cookie, _ := registerAndLogin(t, srv, "stocktake-owner", "stocktake-owner-pw-1") + db := pushReplayOpenDB(t, srv.dbPath) + + recSeed, seeded := pushReplayPush(t, srv, cookie, seedRaw) + if recSeed.Code != http.StatusOK { + t.Fatalf("seed push: status = %d, want 200: %s", recSeed.Code, recSeed.Body.String()) + } + pushReplayAssertIDSet(t, pushReplayAppliedIDs(seeded.Applied), pushReplayMutationIDs(seed.Mutations), "seed applied set") + if len(seeded.Skipped) != 0 || len(seeded.Conflicts) != 0 { + t.Fatalf("seed push skipped=%+v conflicts=%+v, want none", seeded.Skipped, seeded.Conflicts) + } + stocktakeReplayAssertNoConflicts(t, srv, cookie, "after seed") + + rec1, first := pushReplayPush(t, srv, cookie, pushRaw) + if rec1.Code != http.StatusOK { + t.Fatalf("golden push: status = %d, want 200: %s", rec1.Code, rec1.Body.String()) + } + pushReplayAssertIDSet(t, pushReplayAppliedIDs(first.Applied), pushReplayMutationIDs(golden.Mutations), "golden applied set") + if len(first.Applied) != 50 || len(first.Skipped) != 0 || len(first.Conflicts) != 0 { + t.Fatalf("golden push applied=%d skipped=%d conflicts=%+v, want 50/0/none", len(first.Applied), len(first.Skipped), first.Conflicts) + } + stocktakeReplayAssertNoConflicts(t, srv, cookie, "after first push") + + tables := []string{"items", "locations", "labels", "item_labels", "stock_adjustments", "mutations"} + afterFirst := pushReplayCounts(t, db, tables) + want := map[string]int64{"items": 30, "locations": 4, "labels": 4, "stock_adjustments": 15, "mutations": 43 + 50} + for table, n := range want { + if afterFirst[table] != n { + t.Errorf("after first push: table %s rows = %d, want %d", table, afterFirst[table], n) + } + } + itemsAfterFirst := stocktakeReplayLiveItems(t, db) + edgesAfterFirst := stocktakeReplayLiveEdges(t, db) + pullAfterFirst := pushReplayPullBody(t, srv, cookie, golden.DeviceID, 0, 500) + + rec2, second := pushReplayPush(t, srv, cookie, pushRaw) + if rec2.Code != http.StatusOK { + t.Fatalf("replay push: status = %d, want 200: %s", rec2.Code, rec2.Body.String()) + } + if len(second.Applied) != 0 || len(second.Conflicts) != 0 { + t.Errorf("replay applied=%+v conflicts=%+v, want none", second.Applied, second.Conflicts) + } + pushReplayAssertIDSet(t, pushReplaySkippedIDs(second.Skipped), pushReplayMutationIDs(golden.Mutations), "replay skipped set") + if len(second.Skipped) != 50 { + t.Errorf("replay skipped = %d, want 50", len(second.Skipped)) + } + if second.NewWatermark != first.NewWatermark { + t.Errorf("replay new_watermark = %d, want %d", second.NewWatermark, first.NewWatermark) + } + pushReplayAssertCountsEqual(t, pushReplayCounts(t, db, tables), afterFirst, "golden replay") + + gotItems := stocktakeReplayLiveItems(t, db) + if fmt.Sprint(gotItems) != fmt.Sprint(itemsAfterFirst) { + t.Errorf("items changed by the replay:\nbefore=%v\nafter=%v", itemsAfterFirst, gotItems) + } + sort.Slice(expected.Items, func(i, j int) bool { return expected.Items[i].ID < expected.Items[j].ID }) + if len(gotItems) != len(expected.Items) { + t.Fatalf("live items = %d, want %d", len(gotItems), len(expected.Items)) + } + for i := range gotItems { + if gotItems[i] != expected.Items[i] { + t.Errorf("item %s: got %+v, want %+v", expected.Items[i].ID, gotItems[i], expected.Items[i]) + } + } + + gotEdges := stocktakeReplayLiveEdges(t, db) + if fmt.Sprint(gotEdges) != fmt.Sprint(edgesAfterFirst) { + t.Errorf("item_labels changed by the replay:\nbefore=%v\nafter=%v", edgesAfterFirst, gotEdges) + } + sort.Slice(expected.ItemLabels, func(i, j int) bool { + a, b := expected.ItemLabels[i], expected.ItemLabels[j] + return a.ItemID < b.ItemID || (a.ItemID == b.ItemID && a.LabelID < b.LabelID) + }) + if fmt.Sprint(gotEdges) != fmt.Sprint(expected.ItemLabels) { + t.Errorf("live item_labels:\n got=%v\nwant=%v", gotEdges, expected.ItemLabels) + } + seen := map[stocktakeReplayEdge]bool{} + for _, e := range gotEdges { + if seen[e] { + t.Errorf("duplicate live item_label edge %+v", e) + } + seen[e] = true + } + + if afterPull := pushReplayPullBody(t, srv, cookie, golden.DeviceID, 0, 500); afterPull != pullAfterFirst { + t.Errorf("sync pull payload changed after the replay") + } + + stocktakeReplayAssertNoConflicts(t, srv, cookie, "after replay") +} + +func TestSyncPushAndroidStocktakeGoldenMissingFailsLoudly(t *testing.T) { + if _, err := stocktakeReplayLoadGolden("stocktake-does-not-exist.json"); err == nil { + t.Fatal("loading a missing golden returned no error") + } + for _, name := range []string{"stocktake-seed-push-request.json", "stocktake-push-request.json", "stocktake-expected-state.json"} { + if _, err := stocktakeReplayLoadGolden(name); err != nil { + t.Errorf("committed golden not loadable: %v", err) + } + } +} diff --git a/server/tests/integration/tenant_isolation_sync_conflicts_test.go b/server/tests/integration/tenant_isolation_sync_conflicts_test.go new file mode 100644 index 0000000..015ed1c --- /dev/null +++ b/server/tests/integration/tenant_isolation_sync_conflicts_test.go @@ -0,0 +1,183 @@ +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestTenantIsolationSyncConflictsDoesNotLeakAcrossGroups(t *testing.T) { + srv := newLiveServer(t, true) + a := provisionPushIsoTenant(t, srv, "confl-a") + b := provisionPushIsoTenant(t, srv, "confl-b") + requirePushIsoDistinctTenantIDs(t, a, b) + + type marks struct{ winner, loser, winMut, loseMut string } + mk := func(tn pushIsoTenant) marks { + return marks{ + winner: tn.name + "-WINNER-value", loser: tn.name + "-LOSER-value", + winMut: tn.name + "-mut-win", loseMut: tn.name + "-mut-lose", + } + } + ma, mb := mk(a), mk(b) + + produce := func(tn pushIsoTenant, m marks) { + base := pushIsoGetItem(t, srv, false, tn.cookie, tn.itemID).Version + resp, _ := pushIsoPushOnce(t, srv, false, tn.cookie, tn.name+"-dev", []pushIsoMutationWire{ + pushIsoItemUpdateMutation(m.winMut, tn.itemID, base, m.winner)}) + if len(resp.Applied) != 1 || len(resp.Conflicts) != 0 { + t.Fatalf("%s winner push: %+v, want one applied", tn.name, resp) + } + resp, _ = pushIsoPushOnce(t, srv, false, tn.cookie, tn.name+"-dev", []pushIsoMutationWire{ + pushIsoItemUpdateMutation(m.loseMut, tn.itemID, base, m.loser)}) + if len(resp.Conflicts) != 1 { + t.Fatalf("%s stale push: %+v, want exactly one conflict", tn.name, resp) + } + } + produce(a, ma) + produce(b, mb) + + bItemName := pushIsoGetItem(t, srv, false, b.cookie, b.itemID).Name + if bItemName == "" { + t.Fatal("test bug: B's item has no name to search for") + } + + for _, bearer := range []bool{false, true} { + kind := map[bool]string{false: "cookie", true: "bearer"}[bearer] + t.Run("own-log-only/"+kind, func(t *testing.T) { + for _, c := range []struct { + own, other pushIsoTenant + m, om marks + }{{a, b, ma, mb}, {b, a, mb, ma}} { + page, rec := conflictsGetAll(t, srv, bearer, pushIsoCredValue(c.own, bearer), "") + if len(page) != 1 { + t.Fatalf("%s's log has %d entries, want exactly 1 (its own): %s", c.own.name, len(page), rec) + } + e := page[0] + if e.EntityID != c.own.itemID || e.EntityType != "item" || e.FieldName != "name" { + t.Errorf("%s's entry = %+v, want its own item/name conflict", c.own.name, e) + } + if e.MutationID == nil || *e.MutationID != c.m.loseMut { + t.Errorf("%s's entry mutation_id = %v, want the WIRE id %q", c.own.name, e.MutationID, c.m.loseMut) + } + if e.ServerValue == nil || *e.ServerValue != `"`+c.m.winner+`"` || + e.LosingClientValue == nil || *e.LosingClientValue != `"`+c.m.loser+`"` { + t.Errorf("%s's entry values = %v / %v, want its own winner/loser", c.own.name, e.ServerValue, e.LosingClientValue) + } + for _, needle := range []string{c.other.itemID, c.other.labelID, c.other.groupID, c.om.winner, c.om.loser, c.om.winMut, c.om.loseMut} { + if strings.Contains(rec, needle) { + t.Errorf("%s's conflict log leaked %q: %s", c.own.name, needle, rec) + } + } + } + }) + } + + collideMut := "confl-a-collide-b-item" + resp, _ := pushIsoPushOnce(t, srv, false, a.cookie, "confl-a-dev", []pushIsoMutationWire{ + pushIsoItemCreateMutation(collideMut, b.itemID, "A-COLLIDER-name")}) + if len(resp.Applied) != 0 || len(resp.Conflicts) != 1 || resp.Conflicts[0].EntityID != b.itemID || + resp.Conflicts[0].FieldName != entityConflictFieldWire { + t.Fatalf("A's create colliding with B's item id: %+v, want one whole-entity conflict and nothing applied", resp) + } + + for _, bearer := range []bool{false, true} { + kind := map[bool]string{false: "cookie", true: "bearer"}[bearer] + t.Run("fu57-collision-never-exposes-other-groups-value/"+kind, func(t *testing.T) { + page, raw := conflictsGetAll(t, srv, bearer, pushIsoCredValue(a, bearer), "") + var got *conflictLogEntryWire + for i := range page { + if page[i].MutationID != nil && *page[i].MutationID == collideMut { + got = &page[i] + } + } + if got == nil { + t.Fatalf("A's log has no entry for its colliding create: %s", raw) + } + if got.EntityID != b.itemID || got.FieldName != entityConflictFieldWire { + t.Errorf("collision entry = %+v, want whole-entity conflict on the colliding id", *got) + } + t.Logf("FU-57 evidence: A's collision entry server_value=%s losing_client_value=%s", + derefOrNull(got.ServerValue), derefOrNull(got.LosingClientValue)) + if got.ServerValue != nil { + for _, forbidden := range []string{bItemName, mb.winner, mb.loser} { + if strings.Contains(*got.ServerValue, forbidden) { + t.Errorf("collision entry's server_value %q exposes B's %q", *got.ServerValue, forbidden) + } + } + } + for _, forbidden := range []string{bItemName, mb.winner, mb.loser, b.labelID} { + if strings.Contains(raw, forbidden) { + t.Errorf("A's log body contains B's data %q: %s", forbidden, raw) + } + } + + bPage, bRaw := conflictsGetAll(t, srv, bearer, pushIsoCredValue(b, bearer), "") + if len(bPage) != 1 { + t.Errorf("B's log has %d entries after A's collision, want still exactly 1: %s", len(bPage), bRaw) + } + for _, needle := range []string{collideMut, "A-COLLIDER-name", got.ID} { + if strings.Contains(bRaw, needle) { + t.Errorf("B's log contains A's collision artifact %q: %s", needle, bRaw) + } + } + }) + } + + if it := pushIsoGetItem(t, srv, false, b.cookie, b.itemID); it.Name != bItemName { + t.Errorf("B's item name = %q after A's collision, want %q", it.Name, bItemName) + } +} + +type conflictLogEntryWire struct { + ID string `json:"id"` + MutationID *string `json:"mutation_id"` + EntityType string `json:"entity_type"` + EntityID string `json:"entity_id"` + FieldName string `json:"field_name"` + ServerValue *string `json:"server_value"` + LosingClientValue *string `json:"losing_client_value"` + DetectedAt int64 `json:"detected_at"` +} + +func derefOrNull(s *string) string { + if s == nil { + return "null" + } + return *s +} + +func conflictsGetAll(t *testing.T, srv *liveServer, bearer bool, cred, _ string) ([]conflictLogEntryWire, string) { + t.Helper() + var all []conflictLogEntryWire + var raw strings.Builder + path := "/api/v1/sync/conflicts?limit=1" + for i := 0; i < 50; i++ { + var rec *httptest.ResponseRecorder + if bearer { + rec = srv.doBearer(t, http.MethodGet, path, cred, nil) + } else { + rec = srv.do(t, http.MethodGet, path, cred, nil) + } + if rec.Code != http.StatusOK { + t.Fatalf("GET %s: status = %d: %s", path, rec.Code, rec.Body.String()) + } + raw.WriteString(rec.Body.String()) + var body struct { + Conflicts []conflictLogEntryWire `json:"conflicts"` + NextCursor *string `json:"next_cursor"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v: %s", err, rec.Body.String()) + } + all = append(all, body.Conflicts...) + if body.NextCursor == nil { + return all, raw.String() + } + path = "/api/v1/sync/conflicts?limit=1&after=" + *body.NextCursor + } + t.Fatal("conflict log paging did not terminate") + return nil, "" +}