chore: establish local platform baseline
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package memorylifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const deletedMemoryMarker = "[已按用户请求删除]"
|
||||
|
||||
type DeleteProcessor struct {
|
||||
pool *pgxpool.Pool
|
||||
audit *audit.Repository
|
||||
}
|
||||
|
||||
func NewDeleteProcessor(pool *pgxpool.Pool) (*DeleteProcessor, error) {
|
||||
if pool == nil {
|
||||
return nil, errors.New("database pool is required")
|
||||
}
|
||||
return &DeleteProcessor{pool: pool, audit: audit.NewRepository()}, nil
|
||||
}
|
||||
|
||||
func (p *DeleteProcessor) Handle(ctx context.Context, event outbox.Event) error {
|
||||
return database.Transact(ctx, p.pool, func(tx pgx.Tx) error {
|
||||
var tenantID, memoryPublicID, status string
|
||||
err := tx.QueryRow(ctx, `SELECT tenant_id::text,public_id::text,status FROM curated_memory WHERE id=$1::uuid FOR UPDATE`, event.AggregateID).Scan(&tenantID, &memoryPublicID, &status)
|
||||
if errors.Is(err, pgx.ErrNoRows) || status == "DELETED" {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("load memory deletion: %w", err)
|
||||
}
|
||||
if status != "PENDING_DELETE" {
|
||||
return nil
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE memory_index_entry SET embedding=NULL,status='DELETED',provider_ref=NULL,indexed_at=NULL,last_error=NULL,updated_at=now() WHERE memory_id=$1::uuid`, event.AggregateID); err != nil {
|
||||
return fmt.Errorf("delete memory indexes: %w", err)
|
||||
}
|
||||
digest := sha256.Sum256([]byte(deletedMemoryMarker))
|
||||
if _, err := tx.Exec(ctx, `UPDATE memory_version SET content=$2,content_digest=$3,change_reason=CASE WHEN COALESCE(change_reason,'')='' THEN 'user requested deletion' ELSE change_reason || '; user requested deletion' END WHERE memory_id=$1::uuid`, event.AggregateID, deletedMemoryMarker, hex.EncodeToString(digest[:])); err != nil {
|
||||
return fmt.Errorf("redact memory versions: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE curated_memory SET status='DELETED',deleted_at=now(),valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE id=$1::uuid`, event.AggregateID); err != nil {
|
||||
return fmt.Errorf("complete memory deletion: %w", err)
|
||||
}
|
||||
return p.audit.Record(ctx, tx, audit.Event{TenantID: &tenantID, ActorType: "system", Action: "memory.delete.complete", ResourceType: "memory", ResourceID: &memoryPublicID, Result: "success", Detail: map[string]any{"local_index": "deleted", "database_content": "redacted"}})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package memorylifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"data-terminal-cloud/internal/developmentembedding"
|
||||
"data-terminal-cloud/internal/outbox"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestDeleteProcessorRedactsMemoryAndIsIdempotent(t *testing.T) {
|
||||
databaseURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if _, err := NewDeleteProcessor(nil); err == nil {
|
||||
t.Fatal("nil database pool was accepted")
|
||||
}
|
||||
processor, err := NewDeleteProcessor(pool)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
memoryID, memoryPublicID := uuid.NewString(), uuid.NewString()
|
||||
versionID, versionPublicID := uuid.NewString(), uuid.NewString()
|
||||
defer func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, memoryPublicID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM memory_index_entry WHERE memory_id=$1::uuid`, memoryID)
|
||||
_, _ = pool.Exec(ctx, `UPDATE curated_memory SET current_version_id=NULL WHERE id=$1::uuid`, memoryID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM memory_version WHERE memory_id=$1::uuid`, memoryID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM curated_memory WHERE id=$1::uuid`, memoryID)
|
||||
}()
|
||||
content := "这段合成记忆必须被可靠删除。"
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO curated_memory(id,public_id,tenant_id,subject_id,status,sensitivity,valid_from)
|
||||
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401','ACTIVE','normal',now())`, memoryID, memoryPublicID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO memory_version(id,public_id,memory_id,version_no,content,source_refs,confirmed_by,change_reason,content_digest)
|
||||
VALUES($1::uuid,$2::uuid,$3::uuid,1,$4,'[{"type":"integration"}]','10000000-0000-0000-0000-000000000101','原始原因',$5)`,
|
||||
versionID, versionPublicID, memoryID, content, strings.Repeat("c", 64)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE curated_memory SET current_version_id=$1::uuid WHERE id=$2::uuid`, versionID, memoryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO memory_index_entry(tenant_id,subject_id,memory_id,memory_version_id,index_type,embedding,provider_ref,status,indexed_at)
|
||||
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2::uuid,'pgvector',$3::vector,'integration','ACTIVE',now())`,
|
||||
memoryID, versionID, developmentembedding.Literal(developmentembedding.Vector(content))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := processor.Handle(ctx, outbox.Event{AggregateID: uuid.NewString()}); err != nil {
|
||||
t.Fatalf("missing memory deletion: %v", err)
|
||||
}
|
||||
event := outbox.Event{AggregateID: memoryID}
|
||||
if err := processor.Handle(ctx, event); err != nil {
|
||||
t.Fatalf("active memory no-op: %v", err)
|
||||
}
|
||||
var status string
|
||||
if err := pool.QueryRow(ctx, `SELECT status FROM curated_memory WHERE id=$1::uuid`, memoryID).Scan(&status); err != nil || status != "ACTIVE" {
|
||||
t.Fatalf("active memory status=%s err=%v", status, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE curated_memory SET status='PENDING_DELETE',valid_to=now() WHERE id=$1::uuid`, memoryID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := processor.Handle(ctx, event); err != nil {
|
||||
t.Fatalf("delete memory: %v", err)
|
||||
}
|
||||
var deletedAtSet, embeddingIsNull, providerIsNull bool
|
||||
var version int64
|
||||
if err := pool.QueryRow(ctx, `SELECT status,version,deleted_at IS NOT NULL FROM curated_memory WHERE id=$1::uuid`, memoryID).Scan(&status, &version, &deletedAtSet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status != "DELETED" || version != 2 || !deletedAtSet {
|
||||
t.Fatalf("deleted memory status=%s version=%d deleted_at=%v", status, version, deletedAtSet)
|
||||
}
|
||||
var redactedContent, changeReason, contentDigest, indexStatus string
|
||||
if err := pool.QueryRow(ctx, `SELECT content,change_reason,content_digest FROM memory_version WHERE id=$1::uuid`, versionID).Scan(&redactedContent, &changeReason, &contentDigest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedDigest := sha256.Sum256([]byte(deletedMemoryMarker))
|
||||
if redactedContent != deletedMemoryMarker || !strings.Contains(changeReason, "user requested deletion") || contentDigest != hex.EncodeToString(expectedDigest[:]) {
|
||||
t.Fatalf("redacted content=%q reason=%q digest=%q", redactedContent, changeReason, contentDigest)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT status,embedding IS NULL,provider_ref IS NULL FROM memory_index_entry WHERE memory_id=$1::uuid`, memoryID).Scan(&indexStatus, &embeddingIsNull, &providerIsNull); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if indexStatus != "DELETED" || !embeddingIsNull || !providerIsNull {
|
||||
t.Fatalf("deleted index status=%s embedding_null=%v provider_null=%v", indexStatus, embeddingIsNull, providerIsNull)
|
||||
}
|
||||
if err := processor.Handle(ctx, event); err != nil {
|
||||
t.Fatalf("replay deletion: %v", err)
|
||||
}
|
||||
var auditCount int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE resource_id=$1::uuid AND action='memory.delete.complete'`, memoryPublicID).Scan(&auditCount); err != nil || auditCount != 1 {
|
||||
t.Fatalf("deletion audit count=%d err=%v", auditCount, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user