ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
174 lines
9.5 KiB
Go
174 lines
9.5 KiB
Go
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) })
|
|
}
|