Files
story/internal/saas/privacy_read_integration_test.go
T

174 lines
9.5 KiB
Go
Raw Normal View History

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) })
}