diff --git a/internal/docrevision/pg_store.go b/internal/docrevision/pg_store.go index 8c8cf96e..a710c122 100644 --- a/internal/docrevision/pg_store.go +++ b/internal/docrevision/pg_store.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "time" "chat-agent-system/internal/app/uow" @@ -65,8 +66,12 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &sn.SourceKind, &sn.ActorUserID, &sn.ActorRef, &actorRaw, &sn.SourceRunID, &sn.CreatedAt); err != nil { return err } - _ = json.Unmarshal(blockRaw, &sn.BlockMap) - _ = json.Unmarshal(actorRaw, &sn.ActorSnapshot) + if err := json.Unmarshal(blockRaw, &sn.BlockMap); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal block_map for doc %s snapshot %d: %w", docID, sn.ID, err) + } + if err := json.Unmarshal(actorRaw, &sn.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s snapshot %d: %w", docID, sn.ID, err) + } snaps = append(snaps, snapRow{snap: sn}) shas[sn.ContentSHA256] = struct{}{} } @@ -141,8 +146,12 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &ev.EditSessionID, &ev.RequestID, &ev.IdempotencyKey, &ev.CreatedAt); err != nil { return err } - _ = json.Unmarshal(actorRaw, &ev.ActorSnapshot) - _ = json.Unmarshal(changeRaw, &ev.ChangeSet) + if err := json.Unmarshal(actorRaw, &ev.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s event %d: %w", docID, ev.ID, err) + } + if err := json.Unmarshal(changeRaw, &ev.ChangeSet); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal change_set for doc %s event %d: %w", docID, ev.ID, err) + } cp := ev s.events[cp.ID] = &cp if cp.IdempotencyKey != "" { @@ -173,12 +182,24 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &rep.ModelSnapshot, &rep.PromptVersion, &factRaw, &rep.CorrectsReportID, &rep.RawJSON, &rep.CreatedAt); err != nil { return err } - _ = json.Unmarshal(sumRaw, &rep.Summary) - _ = json.Unmarshal(riskRaw, &rep.RiskItems) - _ = json.Unmarshal(projRaw, &rep.ProjectImpacts) - _ = json.Unmarshal(docRaw, &rep.DocumentImpacts) - _ = json.Unmarshal(qRaw, &rep.OpenQuestions) - _ = json.Unmarshal(factRaw, &rep.DeterministicFacts) + if err := json.Unmarshal(sumRaw, &rep.Summary); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal summary for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(riskRaw, &rep.RiskItems); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal risk_items for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(projRaw, &rep.ProjectImpacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal project_impacts for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(docRaw, &rep.DocumentImpacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal document_impacts for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(qRaw, &rep.OpenQuestions); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal open_questions for doc %s report %d: %w", docID, rep.ID, err) + } + if err := json.Unmarshal(factRaw, &rep.DeterministicFacts); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal deterministic_facts for doc %s report %d: %w", docID, rep.ID, err) + } cp := rep s.reports[cp.ID] = &cp if cp.ID > s.nextRepID { @@ -205,7 +226,9 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error { &c.CreatedAt, &c.DecidedAt); err != nil { return err } - _ = json.Unmarshal(actorRaw, &c.ActorSnapshot) + if err := json.Unmarshal(actorRaw, &c.ActorSnapshot); err != nil { + return fmt.Errorf("hydrateDocLocked: unmarshal actor_snapshot for doc %s candidate %d: %w", docID, c.ID, err) + } cp := c s.candidates[cp.ID] = &cp if cp.ID > s.nextCandID { diff --git a/internal/docrevision/pg_store_hydrate_corrupt_test.go b/internal/docrevision/pg_store_hydrate_corrupt_test.go new file mode 100644 index 00000000..22506950 --- /dev/null +++ b/internal/docrevision/pg_store_hydrate_corrupt_test.go @@ -0,0 +1,68 @@ +package docrevision_test + +import ( + "context" + "strings" + "testing" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/docrevision" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func TestHydrateDocLocked_CorruptJSONBFailsExplicitly_PG(t *testing.T) { + dsn := db.SkipIfNoTestDatabaseURL(t) + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + + var n int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_name='document_snapshots'`).Scan(&n); err != nil || n == 0 { + t.Skip("document_snapshots table missing; migrate required") + } + + docID := "doc_hydrate_corrupt_jsonb" + _, _ = pool.Exec(ctx, `DELETE FROM document_change_events WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_agent_candidates WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_change_reports WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_versions WHERE doc_id=$1`, docID) + _, _ = pool.Exec(ctx, `DELETE FROM document_snapshots WHERE doc_id=$1`, docID) + + _, err = pool.Exec(ctx, ` + INSERT INTO document_snapshots ( + doc_id, content_sha256, parent_snapshot_id, block_map, source_kind, + actor_user_id, actor_ref, actor_snapshot, source_run_id, created_at + ) VALUES ( + $1, 'sha_corrupt_test', NULL, '{not-valid-json'::bytea, 'human', + 1, 'tester', '{}'::jsonb, NULL, now() + ) + `, docID) + if err != nil { + // Some schemas store block_map as jsonb (rejects invalid at insert). Fall back to casting via text. + _, err = pool.Exec(ctx, ` + INSERT INTO document_snapshots ( + doc_id, content_sha256, parent_snapshot_id, block_map, source_kind, + actor_user_id, actor_ref, actor_snapshot, source_run_id, created_at + ) VALUES ( + $1, 'sha_corrupt_test', NULL, convert_to('{not-valid-json', 'UTF8'), 'human', + 1, 'tester', '{}'::jsonb, NULL, now() + ) + `, docID) + if err != nil { + t.Skipf("cannot insert corrupt block_map for schema: %v", err) + } + } + + svc := docrevision.NewService(pool) + _, err = svc.GetRevisionState(ctx, docID) + if err == nil { + t.Fatal("expected hydrate error for corrupt block_map JSONB") + } + if !strings.Contains(err.Error(), "hydrateDocLocked") || !strings.Contains(err.Error(), "block_map") { + t.Fatalf("expected explicit hydrate/block_map error, got: %v", err) + } +}