140 lines
5.8 KiB
Go
140 lines
5.8 KiB
Go
package callmemory
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"data-terminal-cloud/internal/memoryidentity"
|
||
|
|
"errors"
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
type fakeVikingConsole struct {
|
||
|
|
user string
|
||
|
|
records []VikingRecord
|
||
|
|
failQuery, failDelete, keepAfterDelete, limited bool
|
||
|
|
queries, deletes int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (p *fakeVikingConsole) ChatMemories(_ context.Context, user, kind, query string, limit int, start, end int64) ([]VikingRecord, bool, error) {
|
||
|
|
p.queries++
|
||
|
|
if p.failQuery {
|
||
|
|
return nil, false, errors.New("provider unreachable")
|
||
|
|
}
|
||
|
|
out := []VikingRecord{}
|
||
|
|
if user == p.user {
|
||
|
|
for _, r := range p.records {
|
||
|
|
if r.Kind == kind {
|
||
|
|
out = append(out, r)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out, p.limited, nil
|
||
|
|
}
|
||
|
|
func (p *fakeVikingConsole) DeleteChatMemory(_ context.Context, kind, id string) error {
|
||
|
|
p.deletes++
|
||
|
|
if p.failDelete {
|
||
|
|
return errors.New("uncertain delete")
|
||
|
|
}
|
||
|
|
if !p.keepAfterDelete {
|
||
|
|
p.records = nil
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
func consoleFixture(t *testing.T) (fixture, *fakeVikingConsole, VikingDelete) {
|
||
|
|
f := setup(t)
|
||
|
|
r := VikingRecord{ID: "synthetic-event", Kind: "event", Time: 123000, Content: "合成记忆", Digest: strings.Repeat("a", 64)}
|
||
|
|
p := &fakeVikingConsole{user: memoryidentity.UserID(f.tenant, f.subject), records: []VikingRecord{r}}
|
||
|
|
f.s.ConfigureVikingConsole(p)
|
||
|
|
return f, p, VikingDelete{ElderID: f.elder, OperationID: uuid.NewString(), Kind: "event", ID: r.ID, Digest: r.Digest, Time: r.Time, Reason: "合成删除测试"}
|
||
|
|
}
|
||
|
|
func consoleError(t *testing.T, f fixture, principal identity.Principal, op string, in any, code string) {
|
||
|
|
t.Helper()
|
||
|
|
_, e := f.s.MemoryRequest(context.Background(), principal, op, jsonBytes(in))
|
||
|
|
var typed *Error
|
||
|
|
if !errors.As(e, &typed) || typed.Code != code {
|
||
|
|
t.Fatalf("want %s, got %v", code, e)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func TestVikingConsoleDeleteVerifiedAuditAndReplay(t *testing.T) {
|
||
|
|
f, p, in := consoleFixture(t)
|
||
|
|
out := f.request(t, "viking/query", VikingQuery{ElderID: f.elder, Kind: "event"})
|
||
|
|
if out["source"] != "VIKING_NATIVE_CHAT" {
|
||
|
|
t.Fatal(out)
|
||
|
|
}
|
||
|
|
if !f.request(t, "viking/delete", in)["deleted"].(bool) {
|
||
|
|
t.Fatal("not verified")
|
||
|
|
}
|
||
|
|
f.request(t, "viking/delete", in)
|
||
|
|
if p.deletes != 1 {
|
||
|
|
t.Fatal("replay deleted again", p.deletes)
|
||
|
|
}
|
||
|
|
var state, result string
|
||
|
|
if e := f.p.QueryRow(context.Background(), `SELECT detail->>'state',result FROM audit_event WHERE id=$1::uuid`, in.OperationID).Scan(&state, &result); e != nil || state != "DELETED" || result != "success" {
|
||
|
|
t.Fatal(state, result, e)
|
||
|
|
}
|
||
|
|
in.ID = "other"
|
||
|
|
consoleError(t, f, f.principal, "viking/delete", in, "VIKING_OPERATION_CONFLICT")
|
||
|
|
}
|
||
|
|
func TestVikingConsoleCannotDeleteAnotherElderOrChangedRecord(t *testing.T) {
|
||
|
|
f, p, in := consoleFixture(t)
|
||
|
|
g := setup(t)
|
||
|
|
in.ElderID = g.elder
|
||
|
|
consoleError(t, f, f.principal, "viking/delete", in, "VIKING_RECORD_NOT_FOUND")
|
||
|
|
in.ElderID = f.elder
|
||
|
|
p.records[0].Digest = strings.Repeat("b", 64)
|
||
|
|
consoleError(t, f, f.principal, "viking/delete", in, "VIKING_RECORD_CHANGED")
|
||
|
|
if p.deletes != 0 {
|
||
|
|
t.Fatal("unsafe provider delete")
|
||
|
|
}
|
||
|
|
outsider := identity.Principal{Issuer: f.principal.Issuer, Subject: uuid.NewString()}
|
||
|
|
consoleError(t, f, outsider, "viking/query", VikingQuery{ElderID: f.elder, Kind: "event"}, "MEMORY_ACCESS_DENIED")
|
||
|
|
}
|
||
|
|
func TestVikingConsoleFailureNeverReportsDeletedAndRetryReconciles(t *testing.T) {
|
||
|
|
f, p, in := consoleFixture(t)
|
||
|
|
p.failDelete = true
|
||
|
|
consoleError(t, f, f.principal, "viking/delete", in, "VIKING_DELETE_UNCONFIRMED")
|
||
|
|
p.failDelete = false
|
||
|
|
p.records = nil // The first external request may have succeeded after a timeout.
|
||
|
|
if !f.request(t, "viking/delete", in)["deleted"].(bool) || p.deletes != 1 {
|
||
|
|
t.Fatal("uncertain retry not reconciled")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
func TestVikingConsoleAckWithoutAbsenceIsUnconfirmed(t *testing.T) {
|
||
|
|
f, p, in := consoleFixture(t)
|
||
|
|
p.keepAfterDelete = true
|
||
|
|
consoleError(t, f, f.principal, "viking/delete", in, "VIKING_DELETE_UNCONFIRMED")
|
||
|
|
p.keepAfterDelete = false
|
||
|
|
f.request(t, "viking/delete", in)
|
||
|
|
}
|
||
|
|
func TestVikingConsoleProviderFailureAndTruncationDoNotProveAbsence(t *testing.T) {
|
||
|
|
f, p, in := consoleFixture(t)
|
||
|
|
p.failQuery = true
|
||
|
|
consoleError(t, f, f.principal, "viking/query", VikingQuery{ElderID: f.elder, Kind: "event"}, "VIKING_QUERY_FAILED")
|
||
|
|
p.failQuery = false
|
||
|
|
p.failDelete = true
|
||
|
|
consoleError(t, f, f.principal, "viking/delete", in, "VIKING_DELETE_UNCONFIRMED")
|
||
|
|
p.records = nil
|
||
|
|
p.limited = true
|
||
|
|
consoleError(t, f, f.principal, "viking/delete", in, "VIKING_RECORD_NOT_FOUND")
|
||
|
|
}
|
||
|
|
func TestVikingConsoleSensitivePermissionAndReadOnlyGrant(t *testing.T) {
|
||
|
|
f, p, in := consoleFixture(t)
|
||
|
|
user, sub := uuid.NewString(), uuid.NewString()
|
||
|
|
f.exec(t, `INSERT INTO user_account(id,public_id,tenant_id,oidc_issuer,oidc_subject,display_name,status) VALUES($1::uuid,$2::uuid,$3::uuid,$4,$2,'console-reader','active')`, user, sub, f.tenant, f.principal.Issuer)
|
||
|
|
f.exec(t, `INSERT INTO household_member(tenant_id,household_id,user_id,role,status) VALUES($1::uuid,'10000000-0000-0000-0000-000000000201',$2::uuid,'viewer','active')`, f.tenant, user)
|
||
|
|
f.exec(t, `INSERT INTO memory_access_grant(subject_id,user_id,can_read,can_write,sensitive_read) VALUES($1::uuid,$2::uuid,true,false,false)`, f.subject, user)
|
||
|
|
reader := identity.Principal{Issuer: f.principal.Issuer, Subject: sub}
|
||
|
|
consoleError(t, f, reader, "viking/query", VikingQuery{ElderID: f.elder, Kind: "event"}, "VIKING_SENSITIVE_ACCESS_REQUIRED")
|
||
|
|
f.exec(t, `UPDATE memory_access_grant SET sensitive_read=true WHERE subject_id=$1::uuid AND user_id=$2::uuid`, f.subject, user)
|
||
|
|
out, e := f.s.MemoryRequest(context.Background(), reader, "viking/query", jsonBytes(VikingQuery{ElderID: f.elder, Kind: "event"}))
|
||
|
|
if e != nil || out.(map[string]any)["can_write"] != false {
|
||
|
|
t.Fatal(out, e)
|
||
|
|
}
|
||
|
|
consoleError(t, f, reader, "viking/delete", in, "MEMORY_ACCESS_DENIED")
|
||
|
|
if p.deletes != 0 {
|
||
|
|
t.Fatal("readonly delete")
|
||
|
|
}
|
||
|
|
}
|