package saas import ( "context" "errors" "os" "strings" "testing" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/identity" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) func TestPrivacyRequestReadsAfterSubjectDeletion(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() exec := func(sql string, args ...any) { t.Helper() if _, err := pool.Exec(ctx, sql, args...); err != nil { t.Fatal(err) } } tenantID, ownerID, adminID, outsiderID := uuid.NewString(), uuid.NewString(), uuid.NewString(), uuid.NewString() householdID, otherHouseholdID := uuid.NewString(), uuid.NewString() issuer := "urn:test:privacy-read:" + tenantID owner := identity.Principal{Issuer: issuer, Subject: ownerID} admin := identity.Principal{Issuer: issuer, Subject: adminID} outsider := identity.Principal{Issuer: issuer, Subject: outsiderID} defer func() { // DELETE request inserts invoke call_privacy_fence, which retains a // memory_user_state row even when the synthetic subject has no memories. for _, table := range []string{"web_event", "privacy_request", "memory_projection_job", "memory_user_state", "subject_profile", "household_member", "household", "user_account"} { if _, err := pool.Exec(ctx, `DELETE FROM `+table+` WHERE tenant_id=$1::uuid`, tenantID); err != nil { t.Errorf("clean up %s: %v", table, err) } } if _, err := pool.Exec(ctx, `DELETE FROM tenant WHERE id=$1::uuid`, tenantID); err != nil { t.Error(err) } }() exec(`INSERT INTO tenant(id,name) VALUES($1::uuid,'synthetic privacy receipt test')`, tenantID) for _, userID := range []string{ownerID, adminID, outsiderID} { exec(`INSERT INTO user_account(id,tenant_id,oidc_issuer,oidc_subject) VALUES($1::uuid,$2::uuid,$3,$1::text)`, userID, tenantID, issuer) } exec(`INSERT INTO household(id,public_id,tenant_id,owner_user_id,name) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'receipt household'),($4::uuid,$4::uuid,$2::uuid,$5::uuid,'unrelated household')`, householdID, tenantID, ownerID, otherHouseholdID, outsiderID) exec(`INSERT INTO household_member(tenant_id,household_id,user_id,role) VALUES($1::uuid,$2::uuid,$3::uuid,'owner'),($1::uuid,$2::uuid,$4::uuid,'admin'),($1::uuid,$5::uuid,$6::uuid,'owner')`, tenantID, householdID, ownerID, adminID, otherHouseholdID, outsiderID) type receipt struct{ subjectID, requestID, status string } receipts := make([]receipt, 0, 2) for index, subjectStatus := range []string{"deleting", "deleted"} { item := receipt{subjectID: uuid.NewString(), requestID: uuid.NewString(), status: []string{"EXECUTING", "COMPLETED"}[index]} exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'[已删除]',$4)`, item.subjectID, tenantID, householdID, subjectStatus) exec(`INSERT INTO privacy_request(id,public_id,tenant_id,subject_id,request_type,requested_by,status,idempotency_key,request_digest,completed_at) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'DELETE',$4::uuid,$5::varchar,$1::text,$6,CASE WHEN $5::varchar='COMPLETED' THEN now() ELSE NULL END)`, item.requestID, tenantID, item.subjectID, ownerID, item.status, strings.Repeat("a", 64)) exec(`INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref,status) VALUES($1::uuid,'postgresql','subject_personal_data',$2,$3)`, item.requestID, item.subjectID, []string{"PENDING", "COMPLETED"}[index]) receipts = append(receipts, item) } otherSubjectID, otherRequestID := uuid.NewString(), uuid.NewString() exec(`INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'[已删除]','deleted')`, otherSubjectID, tenantID, otherHouseholdID) exec(`INSERT INTO privacy_request(id,public_id,tenant_id,subject_id,request_type,requested_by,status,idempotency_key,request_digest) VALUES($1::uuid,$1::uuid,$2::uuid,$3::uuid,'DELETE',$4::uuid,'COMPLETED',$1::text,$5)`, otherRequestID, tenantID, otherSubjectID, outsiderID, strings.Repeat("b", 64)) service := NewService(pool, audit.NewRepository(), nil, nil) for _, principal := range []identity.Principal{owner, admin} { items, err := service.ListHouseholdPrivacyRequests(ctx, principal, householdID) if err != nil || len(items) != 2 { t.Fatalf("household receipts: items=%+v err=%v", items, err) } for _, item := range items { if item.ID == otherRequestID { t.Fatal("another household's receipt leaked") } } for _, expected := range receipts { items, err := service.ListPrivacyRequests(ctx, principal, expected.subjectID) if err != nil || len(items) != 1 || items[0].ID != expected.requestID || items[0].Status != expected.status { t.Fatalf("subject receipt: items=%+v err=%v", items, err) } item, err := service.GetPrivacyRequest(ctx, principal, expected.requestID) if err != nil || item.SubjectID != expected.subjectID || item.Status != expected.status || len(item.Subtasks) != 1 { t.Fatalf("receipt details: item=%+v err=%v", item, err) } if expected.status == "COMPLETED" && (item.CompletedAt == nil || item.Subtasks[0].Status != "COMPLETED") { t.Fatalf("terminal receipt evidence missing: %+v", item) } if _, err := service.GetSubject(ctx, principal, expected.subjectID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("deleted profile should remain inaccessible: %v", err) } } } if subjects, err := service.ListSubjects(ctx, owner, householdID); err != nil || len(subjects) != 0 { t.Fatalf("deleted profiles returned in subject list: %+v err=%v", subjects, err) } t.Run("failed subtask exposes attention state without rewriting receipt", func(t *testing.T) { item := receipts[0] exec(`UPDATE privacy_subtask SET status='FAILED',last_error='synthetic provider failure' WHERE privacy_request_id=$1::uuid`, item.requestID) assertStatus := func(want string) { t.Helper() bySubject, err := service.ListPrivacyRequests(ctx, owner, item.subjectID) if err != nil || len(bySubject) != 1 || bySubject[0].Status != want { t.Fatalf("subject list status: items=%+v err=%v want=%s", bySubject, err, want) } byHousehold, err := service.ListHouseholdPrivacyRequests(ctx, owner, householdID) if err != nil { t.Fatal(err) } found := false for _, receipt := range byHousehold { if receipt.ID == item.requestID { found = true if receipt.Status != want { t.Fatalf("household list status=%s want=%s", receipt.Status, want) } } } if !found { t.Fatal("household list omitted the receipt") } detail, err := service.GetPrivacyRequest(ctx, owner, item.requestID) if err != nil || detail.Status != want { t.Fatalf("detail status: item=%+v err=%v want=%s", detail, err, want) } } assertStatus("PARTIAL_FAILED") var persistedStatus string if err := pool.QueryRow(ctx, `SELECT status FROM privacy_request WHERE id=$1::uuid`, item.requestID).Scan(&persistedStatus); err != nil || persistedStatus != "EXECUTING" { t.Fatalf("read changed stored status=%s err=%v", persistedStatus, err) } exec(`UPDATE privacy_subtask SET status='COMPLETED',last_error=NULL,completed_at=now() WHERE privacy_request_id=$1::uuid`, item.requestID) exec(`UPDATE privacy_request SET status='COMPLETED',completed_at=now() WHERE id=$1::uuid`, item.requestID) assertStatus("COMPLETED") }) assertDenied := func(t *testing.T, principal identity.Principal, householdID, subjectID, requestID string) { t.Helper() if _, err := service.ListHouseholdPrivacyRequests(ctx, principal, householdID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("household privacy read should be denied: %v", err) } if _, err := service.ListPrivacyRequests(ctx, principal, subjectID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("subject privacy read should be denied: %v", err) } if _, err := service.GetPrivacyRequest(ctx, principal, requestID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("privacy receipt details should be denied: %v", err) } } t.Run("other household", func(t *testing.T) { assertDenied(t, owner, otherHouseholdID, otherSubjectID, otherRequestID) assertDenied(t, outsider, householdID, receipts[0].subjectID, receipts[0].requestID) }) for _, role := range []string{"editor", "viewer"} { t.Run(role+" lacks privacy.read", func(t *testing.T) { exec(`UPDATE household_member SET role=$2 WHERE user_id=$1::uuid`, adminID, role) assertDenied(t, admin, householdID, receipts[0].subjectID, receipts[0].requestID) }) } exec(`UPDATE household_member SET role='admin',status='removed' WHERE user_id=$1::uuid`, adminID) t.Run("removed member", func(t *testing.T) { assertDenied(t, admin, householdID, receipts[0].subjectID, receipts[0].requestID) }) exec(`UPDATE household_member SET status='active' WHERE user_id=$1::uuid`, adminID) exec(`UPDATE user_account SET status='disabled' WHERE id=$1::uuid`, adminID) t.Run("disabled account", func(t *testing.T) { assertDenied(t, admin, householdID, receipts[0].subjectID, receipts[0].requestID) }) exec(`UPDATE tenant SET status='suspended' WHERE id=$1::uuid`, tenantID) t.Run("suspended tenant", func(t *testing.T) { assertDenied(t, owner, householdID, receipts[0].subjectID, receipts[0].requestID) }) exec(`UPDATE tenant SET status='active' WHERE id=$1::uuid`, tenantID) exec(`UPDATE household SET status='deleted' WHERE id=$1::uuid`, householdID) t.Run("deleted household", func(t *testing.T) { assertDenied(t, owner, householdID, receipts[0].subjectID, receipts[0].requestID) }) }