502 lines
25 KiB
Go
502 lines
25 KiB
Go
package callmemory
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"data-terminal-cloud/internal/deviceauth"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"data-terminal-cloud/internal/outbox"
|
||
|
|
"data-terminal-cloud/internal/privacy"
|
||
|
|
"encoding/json"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
"github.com/redis/go-redis/v9"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
type fixture struct {
|
||
|
|
s *Service
|
||
|
|
p *pgxpool.Pool
|
||
|
|
principal identity.Principal
|
||
|
|
elder, subject, tenant, device, boot string
|
||
|
|
}
|
||
|
|
|
||
|
|
func setup(t *testing.T) fixture {
|
||
|
|
t.Helper()
|
||
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
||
|
|
if url == "" {
|
||
|
|
t.Skip("TEST_DATABASE_URL required")
|
||
|
|
}
|
||
|
|
p, e := pgxpool.New(context.Background(), url)
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
t.Cleanup(p.Close)
|
||
|
|
f := fixture{p: p, s: NewService(p, nil), elder: uuid.NewString(), subject: uuid.NewString(), tenant: "10000000-0000-0000-0000-000000000001", device: uuid.NewString(), boot: uuid.NewString(), principal: identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}}
|
||
|
|
f.exec(t, `INSERT INTO subject_profile(id,public_id,tenant_id,household_id,preferred_name,status) VALUES($1::uuid,$2::uuid,$3::uuid,'10000000-0000-0000-0000-000000000201','call-test','active')`, f.subject, f.elder, f.tenant)
|
||
|
|
f.exec(t, `INSERT INTO consent_record(tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref) SELECT tenant_id,$1::uuid,consent_type,scope,policy_version,notice_digest,actor_user_id,evidence_ref FROM consent_record WHERE subject_id='10000000-0000-0000-0000-000000000401' AND withdrawn_at IS NULL`, f.subject)
|
||
|
|
f.exec(t, `INSERT INTO consent_snapshot(tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids) VALUES($1::uuid,$2::uuid,1,'{"voice_chat":true}','[]')`, f.tenant, f.subject)
|
||
|
|
f.exec(t, `INSERT INTO device(id,tenant_id,serial_digest,model,hardware_revision,status,credential_ref,minimum_security_version,minimum_firmware_version,onboarding_boot_id,binding_generation) VALUES($1::uuid,$2::uuid,$3,'test','test','registered','local-dev://call-test','1','1',$4::uuid,0)`, f.device, f.tenant, hash([]byte(f.device)), f.boot)
|
||
|
|
f.exec(t, `INSERT INTO device_binding(tenant_id,device_id,household_id,subject_id,bound_by,reason,generation) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000201',$3::uuid,'10000000-0000-0000-0000-000000000101','test',1)`, f.tenant, f.device, f.subject)
|
||
|
|
f.exec(t, `INSERT INTO memory_user_state(subject_id,tenant_id,legacy_projection_pending) VALUES($1::uuid,$2::uuid,false)`, f.subject, f.tenant)
|
||
|
|
return f
|
||
|
|
}
|
||
|
|
func (f fixture) exec(t *testing.T, q string, args ...any) {
|
||
|
|
t.Helper()
|
||
|
|
if _, e := f.p.Exec(context.Background(), q, args...); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func (f fixture) request(t *testing.T, op string, in any) map[string]any {
|
||
|
|
t.Helper()
|
||
|
|
out, e := f.s.MemoryRequest(context.Background(), f.principal, op, jsonBytes(in))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatalf("%s: %v", op, e)
|
||
|
|
}
|
||
|
|
var m map[string]any
|
||
|
|
json.Unmarshal(jsonBytes(out), &m)
|
||
|
|
return m
|
||
|
|
}
|
||
|
|
func (f fixture) add(t *testing.T) (string, map[string]any) {
|
||
|
|
t.Helper()
|
||
|
|
content := "喜欢喝淡茶"
|
||
|
|
out := f.request(t, "mutations", MutationInput{ID: uuid.NewString(), ElderID: f.elder, Action: "ADD", Targets: []string{}, Expected: map[string]int64{}, Patch: MemoryPatch{Content: &content}})
|
||
|
|
return out["targets"].([]any)[0].(string), out
|
||
|
|
}
|
||
|
|
func (f fixture) call(t *testing.T) (Manifest, CallKey, deviceauth.Identity) {
|
||
|
|
t.Helper()
|
||
|
|
m := testManifest()
|
||
|
|
m.BootID = f.boot
|
||
|
|
m.DeviceID = f.device
|
||
|
|
m.PrivacyEpoch = 0
|
||
|
|
b, mac, e := EncodeManifest([]byte(strings.Repeat("k", 32)), m)
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
f.exec(t, `INSERT INTO call_session(id,tenant_id,subject_id,device_id,boot_id,hook_event_id,call_generation,binding_generation,manifest_id,manifest_b64,manifest_mac,manifest_status,memory_mode,expires_at,lease_expires_at) VALUES($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,1,1,$7::uuid,$8,$9,'ISSUED','HOT_ONLY',$10,$11)`, m.SessionID, f.tenant, f.subject, f.device, f.boot, m.HookID, m.ManifestID, b, mac, time.UnixMilli(m.ExpiresAt), time.UnixMilli(m.LeaseExpiresAt))
|
||
|
|
return m, CallKey{SessionID: &m.SessionID, BootID: f.boot, HookID: m.HookID, Generation: 1, Binding: 1}, deviceauth.Identity{ID: f.device, TenantID: f.tenant, EnrollmentMode: "physical_confirm", BindingGeneration: 1}
|
||
|
|
}
|
||
|
|
func TestMutationVersionFenceAndConfirmationReplay(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
id, _ := f.add(t)
|
||
|
|
m, _, _ := f.call(t)
|
||
|
|
out := f.request(t, "mutations", MutationInput{ID: uuid.NewString(), ElderID: f.elder, Action: "DELETE", Targets: []string{}, Expected: map[string]int64{}, Patch: MemoryPatch{}})
|
||
|
|
if out["mutation_state"] != "NEEDS_CONFIRMATION" {
|
||
|
|
t.Fatal(out)
|
||
|
|
}
|
||
|
|
resolved := f.request(t, "mutations/resolve", ResolveInput{ID: out["mutation_id"].(string), Revision: int64(out["mutation_revision"].(float64)), Targets: []string{id}, Expected: map[string]int64{id: 1}, Patch: MemoryPatch{}})
|
||
|
|
ci := ConfirmInput{ID: out["mutation_id"].(string), Revision: int64(resolved["mutation_revision"].(float64)), Digest: resolved["proposal_digest"].(string), Token: resolved["confirmation_token"].(string), Decision: "CONFIRM"}
|
||
|
|
done := f.request(t, "mutations/confirm", ci)
|
||
|
|
if done["mutation_state"] != "PROPAGATING" {
|
||
|
|
t.Fatal(done)
|
||
|
|
}
|
||
|
|
ci.Decision = "CANCEL"
|
||
|
|
replay := f.request(t, "mutations/confirm", ci)
|
||
|
|
if replay["mutation_state"] != "PROPAGATING" {
|
||
|
|
t.Fatal("cancel rolled back committed mutation")
|
||
|
|
}
|
||
|
|
var status string
|
||
|
|
var epoch int64
|
||
|
|
if e := f.p.QueryRow(context.Background(), `SELECT manifest_status FROM call_session WHERE id=$1::uuid`, m.SessionID).Scan(&status); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if status != "REVOKED" {
|
||
|
|
t.Fatal("issued manifest survived deletion")
|
||
|
|
}
|
||
|
|
f.p.QueryRow(context.Background(), `SELECT privacy_epoch FROM memory_user_state WHERE subject_id=$1::uuid`, f.subject).Scan(&epoch)
|
||
|
|
if epoch != 1 {
|
||
|
|
t.Fatal(epoch)
|
||
|
|
}
|
||
|
|
if e := f.s.Maintain(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
progress := f.request(t, "mutations/status", map[string]any{"mutation_id": ci.ID})
|
||
|
|
stages := progress["stage_states"].(map[string]any)
|
||
|
|
if stages["active_context"] != "PENDING" || stages["backup_cleanup"] != "PENDING" || progress["mutation_state"] == "COMPLETED" {
|
||
|
|
t.Fatal(progress)
|
||
|
|
}
|
||
|
|
f.exec(t, `UPDATE call_session SET local_audio_off=true,sdk_stopped=true,remote_state='UNKNOWN' WHERE id=$1::uuid`, m.SessionID)
|
||
|
|
if e := f.s.Maintain(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
progress = f.request(t, "mutations/status", map[string]any{"mutation_id": ci.ID})
|
||
|
|
if progress["stage_states"].(map[string]any)["active_context"] != "PENDING" {
|
||
|
|
t.Fatal("local SDK evidence falsely proved supplier context cleanup")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func TestLeaseExpiresAndCannotResurrect(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
m, k, d := f.call(t)
|
||
|
|
in := HeartbeatInput{CallKey: k, ManifestID: m.ManifestID, AttemptID: uuid.NewString(), Runtime: "STARTING"}
|
||
|
|
req := uuid.NewString()
|
||
|
|
first, e := f.s.DeviceRequest(context.Background(), d, nil, req, "heartbeat", jsonBytes(in))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if first.(map[string]any)["allowed"] != true {
|
||
|
|
var details []byte
|
||
|
|
f.p.QueryRow(context.Background(), `SELECT jsonb_build_object('device',to_jsonb(d)-'credential_ref'-'serial_digest','binding',to_jsonb(b),'consents',(SELECT jsonb_agg(cr.consent_type) FROM consent_record cr WHERE cr.subject_id=b.subject_id)) FROM device d JOIN device_binding b ON b.device_id=d.id WHERE d.id=$1::uuid`, f.device).Scan(&details)
|
||
|
|
t.Log(string(details))
|
||
|
|
t.Fatal(first)
|
||
|
|
}
|
||
|
|
if first.(map[string]any)["transcript_upload_allowed"] != true {
|
||
|
|
t.Fatal("granted transcript upload not advertised")
|
||
|
|
}
|
||
|
|
f.exec(t, `UPDATE call_session SET lease_expires_at=now()-interval '1 second' WHERE id=$1::uuid`, m.SessionID)
|
||
|
|
late, e := f.s.DeviceRequest(context.Background(), d, nil, req, "heartbeat", jsonBytes(in))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if late.(map[string]any)["allowed"] != false {
|
||
|
|
t.Fatal("cached heartbeat revived expired lease", late)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func TestTextRevisionDrainingAndConsent(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
m, k, d := f.call(t)
|
||
|
|
attempt := uuid.NewString()
|
||
|
|
send := func(seq int64, rev int64, text string) map[string]any {
|
||
|
|
ev := Event{ID: uuid.NewString(), Seq: seq, Type: "TRANSCRIPT_REVISION", Occurred: time.Now().UnixMilli(), AttemptID: attempt, Payload: map[string]any{"utterance_id": "u1", "source_revision": rev, "speaker": "USER", "source_final": true, "text": text}}
|
||
|
|
out, e := f.s.DeviceRequest(context.Background(), d, nil, uuid.NewString(), "events", jsonBytes(EventsInput{CallKey: k, Events: []Event{ev}}))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
return out.(map[string]any)
|
||
|
|
}
|
||
|
|
first := send(1, 1, "明天看牙")
|
||
|
|
if len(first["accepted_event_ids"].([]string)) != 1 {
|
||
|
|
t.Fatal(first)
|
||
|
|
}
|
||
|
|
_, e := f.s.DeviceRequest(context.Background(), d, nil, uuid.NewString(), "end", jsonBytes(EndInput{CallKey: k, Reason: "ON_HOOK", AudioStopped: true, SDKStatus: "STOPPED_OBSERVED"}))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
f.exec(t, `UPDATE call_session SET drain_started_at=now()-interval '20 seconds',last_text_at=now()-interval '20 seconds' WHERE id=$1::uuid`, m.SessionID)
|
||
|
|
if e = f.s.Maintain(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
send(2, 2, "明天不去看牙")
|
||
|
|
var state, text string
|
||
|
|
var revision int64
|
||
|
|
e = f.p.QueryRow(context.Background(), `SELECT c.transcript_state,c.transcript_revision,u.text_content FROM call_session c JOIN call_utterance u ON u.session_id=c.id WHERE c.id=$1::uuid`, m.SessionID).Scan(&state, &revision, &text)
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if state != "DRAINING" || revision != 2 || text != "明天不去看牙" {
|
||
|
|
t.Fatal(state, revision, text)
|
||
|
|
}
|
||
|
|
f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='transcript_storage'`, f.subject)
|
||
|
|
rejected := send(3, 3, "不得保存")
|
||
|
|
if len(rejected["accepted_event_ids"].([]string)) != 0 {
|
||
|
|
t.Fatal(rejected)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func TestMemoryGrantIndependentOfDeviceRole(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
f.principal.Subject = "00000000-0000-0000-0000-000000000102"
|
||
|
|
_, e := f.s.MemoryRequest(context.Background(), f.principal, "query", jsonBytes(QueryInput{ElderID: f.elder}))
|
||
|
|
if e == nil {
|
||
|
|
t.Fatal("editor inherited memory permission")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestPrepareIdempotenceCancelAndFirstHeartbeat(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
profileID := "test-" + uuid.NewString()
|
||
|
|
caps, evidence, mapping, template := map[string]bool{}, map[string]string{}, map[string]string{}, map[string]any{}
|
||
|
|
for _, g := range []string{"H01", "H02", "H04", "H05", "H06", "H07", "H09", "H11"} {
|
||
|
|
caps[g] = true
|
||
|
|
evidence[g] = "synthetic integration test, no hardware claim"
|
||
|
|
}
|
||
|
|
for _, field := range []string{"memory_enabled", "hot_context", "welcome_text", "user_id", "assistant_id", "dialog_id"} {
|
||
|
|
mapping[field] = "/" + field
|
||
|
|
template[field] = ""
|
||
|
|
}
|
||
|
|
f.exec(t, `INSERT INTO call_provider_profile(id,protocol_version,sdk_version,bot_id,model_version,transport,audio_profile,vendor_template,field_mapping,capabilities,evidence,published) VALUES($1,'CALL-MEMORY-1.1','test-sdk','test-bot','test-model','RTC','{"id":"test-audio"}',$2::jsonb,$3::jsonb,$4::jsonb,$5::jsonb,true)`, profileID, jsonBytes(template), jsonBytes(mapping), jsonBytes(caps), jsonBytes(evidence))
|
||
|
|
f.exec(t, `UPDATE device SET activation_state='ACTIVE' WHERE id=$1::uuid`, f.device)
|
||
|
|
f.exec(t, `INSERT INTO device_activation_job(device_id,tenant_id,provider_device_name,status,binding_generation,config_version,config_ack_at,engine_ready_at,sdk_registered_at,config_expires_at,ready_binding_generation) VALUES($1::uuid,$2::uuid,$3,'READY',1,1,now(),now(),now(),now()+interval '1 hour',1)`, f.device, f.tenant, "test-"+f.device)
|
||
|
|
d := deviceauth.Identity{ID: f.device, PublicID: f.device, TenantID: f.tenant, EnrollmentMode: "physical_confirm", BindingGeneration: 1, ActivationState: "ACTIVE"}
|
||
|
|
key := []byte(strings.Repeat("k", 32))
|
||
|
|
if _, e := f.s.DeviceRequest(ctx, d, key, uuid.NewString(), "warmup", jsonBytes(WarmupInput{Contract: ContractVersion, KnownVersion: 999, BootID: f.boot, Binding: 1, SDK: "test-sdk", Firmware: "test", AudioID: "test-audio"})); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
k := CallKey{BootID: f.boot, HookID: "cancel-hook", Generation: 1, Binding: 1}
|
||
|
|
if _, e := f.s.DeviceRequest(ctx, d, key, uuid.NewString(), "end", jsonBytes(EndInput{CallKey: k, Reason: "CANCEL_BEFORE_START", AudioStopped: true, SDKStatus: "NOT_STARTED"})); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if _, e := f.s.DeviceRequest(ctx, d, key, uuid.NewString(), "prepare", jsonBytes(PrepareInput{CallKey: k, AudioID: "test-audio", Purpose: "NORMAL"})); e == nil {
|
||
|
|
t.Fatal("end-before-prepare was resurrected")
|
||
|
|
}
|
||
|
|
k.HookID = "new-hook"
|
||
|
|
k.Generation = 2
|
||
|
|
input := jsonBytes(PrepareInput{CallKey: k, AudioID: "test-audio", Purpose: "NORMAL"})
|
||
|
|
first, e := f.s.DeviceRequest(ctx, d, key, uuid.NewString(), "prepare", input)
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
second, e := f.s.DeviceRequest(ctx, d, key, uuid.NewString(), "prepare", input)
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if string(jsonBytes(first)) != string(jsonBytes(second)) {
|
||
|
|
t.Fatal("prepare created a second manifest")
|
||
|
|
}
|
||
|
|
signed := first.(map[string]any)
|
||
|
|
m, e := DecodeManifest(key, signed["manifest_b64"].(string), signed["manifest_mac"].(string))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
k.SessionID = &m.SessionID
|
||
|
|
attempt := uuid.NewString()
|
||
|
|
ev := EventsInput{CallKey: k, Events: []Event{{ID: uuid.NewString(), Seq: 1, Type: "START_INTENT", AttemptID: attempt, Occurred: time.Now().UnixMilli(), Payload: map[string]any{"manifest_id": m.ManifestID}}}}
|
||
|
|
out, e := f.s.DeviceRequest(ctx, d, key, uuid.NewString(), "events", jsonBytes(ev))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if len(out.(map[string]any)["accepted_event_ids"].([]string)) != 0 {
|
||
|
|
t.Fatal("start accepted without first heartbeat")
|
||
|
|
}
|
||
|
|
if _, e = f.s.DeviceRequest(ctx, d, key, uuid.NewString(), "heartbeat", jsonBytes(HeartbeatInput{CallKey: k, ManifestID: m.ManifestID, AttemptID: attempt, Runtime: "STARTING"})); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
out, e = f.s.DeviceRequest(ctx, d, key, uuid.NewString(), "events", jsonBytes(ev))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if len(out.(map[string]any)["accepted_event_ids"].([]string)) != 1 {
|
||
|
|
t.Fatal(out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type projectionFunc func(context.Context, ProjectionRecord, string, string, bool) (ProjectionResult, error)
|
||
|
|
|
||
|
|
func (f projectionFunc) Project(c context.Context, r ProjectionRecord, k, id string, reconcile bool) (ProjectionResult, error) {
|
||
|
|
return f(c, r, k, id, reconcile)
|
||
|
|
}
|
||
|
|
func TestProjectionAckIsNotVisibilityAndWatermarkHasNoHoles(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
_, one := f.add(t)
|
||
|
|
f.add(t)
|
||
|
|
f.add(t)
|
||
|
|
f.exec(t, `UPDATE memory_projection_job SET next_attempt_at='2000-01-01',state=CASE WHEN memory_revision=3 THEN 'VISIBLE' ELSE state END WHERE subject_id=$1::uuid`, f.subject)
|
||
|
|
visible := false
|
||
|
|
f.s.ConfigureProjection(projectionFunc(func(_ context.Context, r ProjectionRecord, _, _ string, _ bool) (ProjectionResult, error) {
|
||
|
|
return ProjectionResult{Kind: "event", ID: r.ID, Visible: visible}, nil
|
||
|
|
}))
|
||
|
|
if e := f.s.ProjectNext(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
status := f.request(t, "sync/status", map[string]any{"elder_id": f.elder})
|
||
|
|
if status["viking_visible_revision"].(float64) != 0 {
|
||
|
|
t.Fatal("ack counted as visible", status)
|
||
|
|
}
|
||
|
|
visible = true
|
||
|
|
f.exec(t, `UPDATE memory_projection_job SET next_attempt_at='2000-01-01' WHERE subject_id=$1::uuid`, f.subject)
|
||
|
|
if e := f.s.ProjectNext(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
status = f.request(t, "sync/status", map[string]any{"elder_id": f.elder})
|
||
|
|
if status["viking_visible_revision"].(float64) != 1 {
|
||
|
|
t.Fatal("watermark skipped gap at 2", status)
|
||
|
|
}
|
||
|
|
if e := f.s.Maintain(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
done := f.request(t, "mutations/status", map[string]any{"mutation_id": one["mutation_id"]})
|
||
|
|
if done["mutation_state"] != "COMPLETED" {
|
||
|
|
t.Fatal(done)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSensitiveMutationStatusCannotBypassListGrant(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
content, sensitivity := "需要权限的内容", "sensitive"
|
||
|
|
out := f.request(t, "mutations", MutationInput{ID: uuid.NewString(), ElderID: f.elder, Action: "ADD", Targets: []string{}, Expected: map[string]int64{}, Patch: MemoryPatch{Content: &content, Sensitivity: &sensitivity}})
|
||
|
|
f.request(t, "access/grant", AccessInput{Elder: f.elder, User: "10000000-0000-0000-0000-000000000112", Read: true})
|
||
|
|
f.principal.Subject = "00000000-0000-0000-0000-000000000102"
|
||
|
|
rows := f.request(t, "query", QueryInput{ElderID: f.elder})
|
||
|
|
if len(rows["items"].([]any)) != 0 {
|
||
|
|
t.Fatal(rows)
|
||
|
|
}
|
||
|
|
if _, e := f.s.MemoryRequest(context.Background(), f.principal, "mutations/status", jsonBytes(map[string]any{"mutation_id": out["mutation_id"]})); e == nil {
|
||
|
|
t.Fatal("sensitive status bypass")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProjectionRechecksConsentAndSensitiveState(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
id, _ := f.add(t)
|
||
|
|
// An expiry is a clock event, not an UPDATE of withdrawn_at.
|
||
|
|
f.exec(t, `UPDATE consent_record SET expires_at=now()+interval '1 hour' WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, f.subject)
|
||
|
|
var observed string
|
||
|
|
f.s.ConfigureProjection(projectionFunc(func(_ context.Context, r ProjectionRecord, _, _ string, _ bool) (ProjectionResult, error) {
|
||
|
|
observed = r.Status
|
||
|
|
f.exec(t, `UPDATE consent_record SET withdrawn_at=now() WHERE subject_id=$1::uuid AND consent_type='personalized_memory'`, f.subject)
|
||
|
|
return ProjectionResult{Kind: "event", ID: r.ID, Visible: true}, nil
|
||
|
|
}))
|
||
|
|
if e := f.s.ProjectNext(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if observed != "ACTIVE" {
|
||
|
|
t.Fatal(observed)
|
||
|
|
}
|
||
|
|
var visible int64
|
||
|
|
if e := f.p.QueryRow(context.Background(), `SELECT viking_visible_revision FROM memory_user_state WHERE subject_id=$1::uuid`, f.subject).Scan(&visible); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if visible != 0 {
|
||
|
|
t.Fatal("concurrent consent withdrawal advanced visibility")
|
||
|
|
}
|
||
|
|
f.s.ConfigureProjection(projectionFunc(func(_ context.Context, r ProjectionRecord, _, _ string, _ bool) (ProjectionResult, error) {
|
||
|
|
if r.ID == id && r.Status != "DISABLED" {
|
||
|
|
t.Fatal("withdrawn consent projected as active")
|
||
|
|
}
|
||
|
|
return ProjectionResult{Kind: "event", ID: r.ID, Visible: true}, nil
|
||
|
|
}))
|
||
|
|
if e := f.s.ProjectNext(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
// A separate subject with a sensitive ALLOWED record must not publish plaintext.
|
||
|
|
other := setup(t)
|
||
|
|
content, sensitivity := "private medical detail", "sensitive"
|
||
|
|
other.request(t, "mutations", MutationInput{ID: uuid.NewString(), ElderID: other.elder, Action: "ADD", Targets: []string{}, Expected: map[string]int64{}, Patch: MemoryPatch{Content: &content, Sensitivity: &sensitivity}})
|
||
|
|
f.exec(t, `UPDATE memory_projection_job SET next_attempt_at=now()+interval '1 hour' WHERE subject_id<>$1::uuid`, other.subject)
|
||
|
|
other.s.ConfigureProjection(projectionFunc(func(_ context.Context, r ProjectionRecord, _, _ string, _ bool) (ProjectionResult, error) {
|
||
|
|
if r.Status != "DISABLED" {
|
||
|
|
t.Fatal("sensitive memory eligible for projection")
|
||
|
|
}
|
||
|
|
return ProjectionResult{Kind: "event", Visible: true}, nil
|
||
|
|
}))
|
||
|
|
if e := other.s.ProjectNext(context.Background()); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type noObjects struct{}
|
||
|
|
|
||
|
|
func (noObjects) Remove(context.Context, string) error { return nil }
|
||
|
|
func TestPrivacyDeleteIncludesNewCallDataAndWaitsForRuntime(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
_, mutation := f.add(t)
|
||
|
|
m, _, _ := f.call(t)
|
||
|
|
f.exec(t, `INSERT INTO call_utterance(session_id,utterance_id,source_revision,speaker,text_content,interrupted,source_at) VALUES($1::uuid,'u1',1,'USER','private final text',false,now())`, m.SessionID)
|
||
|
|
story, _ := addCallSourceStory(t, f, m.SessionID, "u1", 1)
|
||
|
|
f.exec(t, `UPDATE call_session SET budget_snapshot='{"protocol":"DIRECT-CHAT-1","story_context":{"private":"original words"}}'::jsonb WHERE id=$1::uuid`, m.SessionID)
|
||
|
|
f.exec(t, `INSERT INTO outbox_event(tenant_id,aggregate_type,aggregate_id,event_type,payload) VALUES($1::uuid,'call_session',$2::uuid,'call.story_extraction_requested','{"transcript_revision":1}'::jsonb)`, f.tenant, m.SessionID)
|
||
|
|
request := uuid.NewString()
|
||
|
|
f.exec(t, `INSERT INTO privacy_request(id,tenant_id,subject_id,request_type,scope,requested_by,idempotency_key,request_digest) VALUES($1::uuid,$2::uuid,$3::uuid,'DELETE','{}','10000000-0000-0000-0000-000000000101',$1,repeat('a',64))`, request, f.tenant, f.subject)
|
||
|
|
f.exec(t, `INSERT INTO privacy_subtask(privacy_request_id,subsystem,target_type,target_ref) VALUES($1::uuid,'postgresql','subject',$2)`, request, f.subject)
|
||
|
|
cache := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"})
|
||
|
|
defer cache.Close()
|
||
|
|
processor, e := privacy.NewDeleteProcessor(f.p, cache, noObjects{})
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if e = processor.Handle(ctx, outbox.Event{AggregateID: request}); e == nil || !outbox.IsRetryable(e) || outbox.RetryDelay(e) < time.Minute {
|
||
|
|
t.Fatalf("unproved runtime/projection must hold deletion open with a delayed retry: %v", e)
|
||
|
|
}
|
||
|
|
var text, patch string
|
||
|
|
var manifest *string
|
||
|
|
if e = f.p.QueryRow(ctx, `SELECT text_content FROM call_utterance WHERE session_id=$1::uuid`, m.SessionID).Scan(&text); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if text != "[deleted]" {
|
||
|
|
t.Fatal("new call transcript survived privacy deletion")
|
||
|
|
}
|
||
|
|
if e = f.p.QueryRow(ctx, `SELECT manifest_b64 FROM call_session WHERE id=$1::uuid`, m.SessionID).Scan(&manifest); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if manifest != nil {
|
||
|
|
t.Fatal("manifest retained private context")
|
||
|
|
}
|
||
|
|
if e = f.p.QueryRow(ctx, `SELECT patch::text FROM memory_mutation WHERE id=$1::uuid`, mutation["mutation_id"]).Scan(&patch); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if patch != "{}" {
|
||
|
|
t.Fatal("mutation retained deleted plaintext")
|
||
|
|
}
|
||
|
|
var storyStatus, storyContent, extractionStatus string
|
||
|
|
var hasContext bool
|
||
|
|
if e = f.p.QueryRow(ctx, `SELECT sd.status,sr.content FROM story_draft sd JOIN story_revision sr ON sr.id=sd.current_revision_id WHERE sd.id=$1::uuid`, story).Scan(&storyStatus, &storyContent); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if storyStatus != "SUPERSEDED" || storyContent != "[已按隐私请求删除]" {
|
||
|
|
t.Fatal("direct-call story survived privacy deletion")
|
||
|
|
}
|
||
|
|
if e = f.p.QueryRow(ctx, `SELECT budget_snapshot ? 'story_context' FROM call_session WHERE id=$1::uuid`, m.SessionID).Scan(&hasContext); e != nil || hasContext {
|
||
|
|
t.Fatalf("direct-call story context survived privacy deletion: %v", e)
|
||
|
|
}
|
||
|
|
if e = f.p.QueryRow(ctx, `SELECT status FROM outbox_event WHERE aggregate_id=$1::uuid AND event_type='call.story_extraction_requested'`, m.SessionID).Scan(&extractionStatus); e != nil || extractionStatus != "completed" {
|
||
|
|
t.Fatalf("direct-call extraction job survived privacy deletion: status=%s err=%v", extractionStatus, e)
|
||
|
|
}
|
||
|
|
// Synthetic local/remote/backup evidence; this is not hardware acceptance.
|
||
|
|
f.exec(t, `UPDATE memory_projection_job SET state='VISIBLE' WHERE subject_id=$1::uuid`, f.subject)
|
||
|
|
f.exec(t, `UPDATE memory_deletion_ledger SET backup_cleanup_verified_at=now(),evidence_ref='synthetic test' WHERE subject_id=$1::uuid`, f.subject)
|
||
|
|
if e = processor.Handle(ctx, outbox.Event{AggregateID: request}); e == nil {
|
||
|
|
t.Fatal("missing device stop was ignored")
|
||
|
|
}
|
||
|
|
f.exec(t, `UPDATE call_session SET local_audio_off=true,sdk_stopped=true,remote_state='STOPPED_OBSERVED' WHERE id=$1::uuid`, m.SessionID)
|
||
|
|
if e = processor.Handle(ctx, outbox.Event{AggregateID: request}); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
var state string
|
||
|
|
if e = f.p.QueryRow(ctx, `SELECT status FROM privacy_request WHERE id=$1::uuid`, request).Scan(&state); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if state != "COMPLETED" {
|
||
|
|
t.Fatal(state)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLateRevisionCannotRestoreDeletedSource(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
m, k, d := f.call(t)
|
||
|
|
id, _ := f.add(t)
|
||
|
|
f.exec(t, `INSERT INTO memory_source_suppression(subject_id,memory_id,source_refs,privacy_epoch,scope) SELECT subject_id,id,$2::jsonb,0,'SOURCE_AND_DERIVED' FROM curated_memory WHERE public_id=$1::uuid`, id, jsonBytes([]map[string]any{{"session_id": m.SessionID, "utterance_id": "erased-source"}}))
|
||
|
|
event := Event{ID: uuid.NewString(), Seq: 1, Type: "TRANSCRIPT_REVISION", AttemptID: uuid.NewString(), Occurred: time.Now().UnixMilli(), Payload: map[string]any{"utterance_id": "erased-source", "source_revision": 2, "speaker": "USER", "source_final": true, "text": "must stay erased"}}
|
||
|
|
result, e := f.s.DeviceRequest(ctx, d, nil, uuid.NewString(), "events", jsonBytes(EventsInput{CallKey: k, Events: []Event{event}}))
|
||
|
|
if e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
raw := string(jsonBytes(result))
|
||
|
|
if !strings.Contains(raw, "SOURCE_REDACTED") {
|
||
|
|
t.Fatal(raw)
|
||
|
|
}
|
||
|
|
var count int
|
||
|
|
if e = f.p.QueryRow(ctx, `SELECT count(*) FROM call_utterance WHERE session_id=$1::uuid`, m.SessionID).Scan(&count); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if count != 0 {
|
||
|
|
t.Fatal("late final recreated source text")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestStatusUsesDocumentKeyWithoutRenewingLease(t *testing.T) {
|
||
|
|
f := setup(t)
|
||
|
|
m, k, d := f.call(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
var before, after time.Time
|
||
|
|
if e := f.p.QueryRow(ctx, `SELECT lease_expires_at FROM call_session WHERE id=$1::uuid`, m.SessionID).Scan(&before); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
input := StatusInput{SessionID: k.SessionID, BootID: k.BootID, HookID: k.HookID}
|
||
|
|
if _, e := f.s.DeviceRequest(ctx, d, nil, uuid.NewString(), "status", jsonBytes(input)); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if e := f.p.QueryRow(ctx, `SELECT lease_expires_at FROM call_session WHERE id=$1::uuid`, m.SessionID).Scan(&after); e != nil {
|
||
|
|
t.Fatal(e)
|
||
|
|
}
|
||
|
|
if !before.Equal(after) {
|
||
|
|
t.Fatal("status renewed lease")
|
||
|
|
}
|
||
|
|
input.BootID = uuid.NewString()
|
||
|
|
if _, e := f.s.DeviceRequest(ctx, d, nil, uuid.NewString(), "status", jsonBytes(input)); e == nil {
|
||
|
|
t.Fatal("status accepted another boot identity")
|
||
|
|
}
|
||
|
|
}
|