Files
go-sip/internal/rpc/recording_server_flow_test.go
T

271 lines
14 KiB
Go

package rpc
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/oss"
"git.ipao.vip/rogee/go-sip/internal/store"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)
const recordingDispatcherID = "c046b893-8628-4589-ae50-619d049248a6"
func recordingRPCFixture(t *testing.T) (*RecordingServer, *store.CurrentStore, context.Context, *agentpb.RequestRecordingUploadRequest, configread.CurrentSnapshot) {
t.Helper()
database, err := store.OpenCurrent(filepath.Join(t.TempDir(), "recordings.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
readExample := func(name string, dst any) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", name+".json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, dst); err != nil {
t.Fatal(err)
}
}
var snapshot configread.CurrentSnapshot
readExample("config-read-task-asr", &snapshot.Task)
readExample("config-read-sip", &snapshot.SIP)
readExample("config-read-quota", &snapshot.Quota)
var providers struct {
Providers []configread.CurrentProvider `json:"providers"`
}
readExample("config-read-providers", &providers)
snapshot.Providers = make(map[string]configread.CurrentProvider)
for _, provider := range providers.Providers {
snapshot.Providers[provider.ProviderRef] = provider
}
snapshot.SIP.Trunks = []byte(strings.Replace(string(snapshot.SIP.Trunks), `"max_concurrent_calls":null`, `"max_concurrent_calls":2`, 1))
if err := database.ApplyDiscoverySnapshot(recordingDispatcherID, []configread.CurrentDiscoveredTask{{TaskID: snapshot.Task.TaskID, TenantID: snapshot.Task.TenantID, TaskRevision: snapshot.Task.TaskRevision, Status: "running"}}); err != nil {
t.Fatal(err)
}
if err := database.SaveSnapshot(snapshot); err != nil {
t.Fatal(err)
}
if err := database.MarkReadyForSIP(recordingDispatcherID, 8); err != nil {
t.Fatal(err)
}
command := store.CurrentExecuteCommand{DispatcherID: recordingDispatcherID, EventID: "call-recording-1", TenantID: 1001, TaskID: "task-asr", Callee: "15003164745", IssuedAt: "2026-09-21T01:30:00Z"}
if _, _, err := database.RecordExecute(command); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 9, 21, 1, 30, 0, 0, time.UTC)
if err := database.ReserveExecute(recordingDispatcherID, command.EventID, store.CurrentCallReservation{
TrunkID: "trunk-mock", SIPRevision: 8, CallerID: "BD00000000", DialedCallee: command.Callee, Deadline: now.Add(2 * time.Minute),
}, now); err != nil {
t.Fatal(err)
}
client, err := oss.NewClient(oss.Config{
Endpoint: "https://oss.example.invalid", Region: "cn-test", Bucket: "mock-bucket", KeyPrefix: "approved",
AccessKeyID: "local-test-key", AccessKeySecret: "local-test-secret", GrantTTL: 15 * time.Minute, MaxAssetBytes: 1024,
})
if err != nil {
t.Fatal(err)
}
leaf := &x509.Certificate{Raw: []byte("isolated-test-agent-peer")}
fingerprint := CertificateFingerprint(leaf)
ctx := peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{leaf}}}}})
server := &RecordingServer{
Store: database, OSS: client, DispatcherID: recordingDispatcherID,
TrustedFingerprints: map[string]struct{}{fingerprint: {}}, Now: func() time.Time { return now },
AuthorizeSession: func(meta *agentpb.RequestMeta) error {
if meta.AgentId != "agent-mock" || meta.CellId != "cell-mock" || meta.BootId != "boot-mock" || meta.DispatcherEpoch != "epoch-7" || meta.SessionGeneration != 1 {
return errors.New("Agent boot/session is not activated")
}
return nil
},
}
request := &agentpb.RequestRecordingUploadRequest{
Meta: &agentpb.RequestMeta{AgentId: "agent-mock", CellId: "cell-mock", BootId: "boot-mock", DispatcherEpoch: "epoch-7", SessionGeneration: 1, OperationId: "request-1", IdempotencyKey: "request-1"},
DispatcherId: recordingDispatcherID, TenantId: 1001, SourceEventId: command.EventID, UploadId: "upload-recording-1",
Asset: &agentpb.AssetDescriptor{Kind: agentpb.AssetKind_ASSET_KIND_RECORDING, ExecutionId: command.EventID, CallId: command.EventID,
AssetId: "recording-1", Format: "wav", Channels: 1, SampleRateHz: 16000, DurationMs: 3000, SizeBytes: 128, ChecksumSha256: strings.Repeat("a", 64)},
}
return server, database, ctx, request, snapshot
}
func recordingResultPayload(t *testing.T, task configread.CurrentSnapshot, grant *agentpb.UploadGrant) []byte {
t.Helper()
result := map[string]any{
"task_id": "task-asr", "caller_profile_id": task.Task.CallerProfileID,
"callee": "15003164745", "trunk_id": "trunk-mock",
"started_at": "2026-09-21T01:30:01Z", "ended_at": "2026-09-21T01:30:06Z", "duration_ms": 5000,
"outcome": "no_answer", "reason_code": 486, "reason_message": "busy",
"transcript": []any{}, "opt_out": false, "recording": map[string]any{},
}
if grant != nil {
result["outcome"] = "answered"
result["reason_code"] = 200
result["reason_message"] = "completed"
result["recording"] = map[string]any{
"status": "uploaded", "bucket": grant.GetBucket(), "object_key": grant.GetObjectKey(),
"format": "wav", "channels": 1, "sample_rate_hz": 16000, "duration_ms": 3000,
"size_bytes": 128, "checksum_sha256": strings.Repeat("a", 64),
}
}
raw, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
return raw
}
func TestRecordingServerAuthenticatesOriginalGrantAndExplicitReissue(t *testing.T) {
server, database, ctx, request, _ := recordingRPCFixture(t)
if _, err := server.RequestRecordingUpload(context.Background(), request); status.Code(err) != codes.Unauthenticated {
t.Fatalf("upload token issued without verified mTLS peer: %v", err)
}
if _, err := database.LoadRecordingUpload(request.DispatcherId, request.SourceEventId); !errors.Is(err, store.ErrCurrentUploadNotFound) {
t.Fatalf("unauthenticated Agent persisted a grant: %v", err)
}
wrongDispatcher := proto.Clone(request).(*agentpb.RequestRecordingUploadRequest)
wrongDispatcher.DispatcherId = "another-dispatcher"
if _, err := server.RequestRecordingUpload(ctx, wrongDispatcher); status.Code(err) != codes.PermissionDenied {
t.Fatalf("another Dispatcher obtained this D's token: %v", err)
}
wrongTenant := proto.Clone(request).(*agentpb.RequestRecordingUploadRequest)
wrongTenant.TenantId++
if _, err := server.RequestRecordingUpload(ctx, wrongTenant); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("foreign tenant obtained an OSS grant: %v", err)
}
wrongSession := proto.Clone(request).(*agentpb.RequestRecordingUploadRequest)
wrongSession.Meta.BootId = "not-active-boot"
if _, err := server.RequestRecordingUpload(ctx, wrongSession); status.Code(err) != codes.PermissionDenied {
t.Fatalf("changed Agent boot obtained an OSS token: %v", err)
}
response, err := server.RequestRecordingUpload(ctx, request)
grant := response.GetGrant()
if err != nil || grant.GetBucket() != "mock-bucket" || grant.GetObjectKey() == "" || grant.GetUploadId() != request.UploadId || grant.GetExpiresAtUnixMs() != server.Now().Add(15*time.Minute).UnixMilli() {
t.Fatalf("approved bound recording grant absent or changed: bucket=%q key=%q err=%v", grant.GetBucket(), grant.GetObjectKey(), err)
}
stored, err := database.LoadRecordingUpload(request.DispatcherId, request.SourceEventId)
if err != nil || stored.Bucket != grant.GetBucket() || stored.ObjectKey != grant.GetObjectKey() || stored.UploadID != request.UploadId || stored.ChecksumSHA256 != request.Asset.ChecksumSha256 {
t.Fatalf("Dispatcher did not persist original OSS asset: stored=%+v err=%v", stored, err)
}
reissuedResponse, err := server.RequestRecordingUpload(ctx, request)
reissued := reissuedResponse.GetGrant()
if err != nil || reissued.GetBucket() != grant.GetBucket() || reissued.GetObjectKey() != grant.GetObjectKey() || reissued.GetUploadId() != grant.GetUploadId() {
t.Fatalf("explicit token request changed original target: err=%v", err)
}
changedAsset := proto.Clone(request).(*agentpb.RequestRecordingUploadRequest)
changedAsset.Asset.AssetId = "another-recording"
if _, err := server.RequestRecordingUpload(ctx, changedAsset); status.Code(err) != codes.AlreadyExists {
t.Fatalf("new object replaced approved recording: %v", err)
}
}
func TestRecordingServerConfirmedEndAndUploadedResultOutbox(t *testing.T) {
server, database, ctx, request, snapshot := recordingRPCFixture(t)
response, err := server.RequestRecordingUpload(ctx, request)
if err != nil {
t.Fatal(err)
}
grant := response.GetGrant()
result := &agentpb.ReportCallResultRequest{Meta: request.Meta, DispatcherId: request.DispatcherId, TenantId: request.TenantId, SourceEventId: request.SourceEventId,
ResultPayloadJson: recordingResultPayload(t, snapshot, grant),
Upload: &agentpb.UploadObservation{UploadId: request.UploadId, RecordingId: request.Asset.AssetId, PutStatusCode: 200, SizeBytes: 128, ChecksumSha256: request.Asset.ChecksumSha256}}
if _, err := server.ReportCallResult(ctx, result); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("result escaped before confirmed end: %v", err)
}
end := &agentpb.ReportCallEndedRequest{Meta: request.Meta, DispatcherId: request.DispatcherId, TenantId: request.TenantId, SourceEventId: request.SourceEventId}
if _, err := server.ReportCallEnded(ctx, end); err != nil {
t.Fatalf("early Agent end did not persist original acknowledgment: %v", err)
}
if err := database.MarkExecuteDispatched(request.DispatcherId, request.SourceEventId); err != nil {
t.Fatalf("late originate RPC response broke ended call: %v", err)
}
if _, err := server.ReportCallEnded(ctx, end); err != nil {
t.Fatalf("repeated Agent end fact lost idempotency: %v", err)
}
withoutUpload := proto.Clone(result).(*agentpb.ReportCallResultRequest)
withoutUpload.Upload = nil
withoutUpload.ResultPayloadJson = recordingResultPayload(t, snapshot, nil)
if _, err := server.ReportCallResult(ctx, withoutUpload); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("OSS failure was relabeled as no recording: %v", err)
}
badProof := proto.Clone(result).(*agentpb.ReportCallResultRequest)
badProof.Upload.PutStatusCode = 503
if _, err := server.ReportCallResult(ctx, badProof); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("failed PUT was reported as uploaded: %v", err)
}
first, err := server.ReportCallResult(ctx, result)
if err != nil || first.GetReceipt().GetFactId() == "" {
t.Fatalf("confirmed original upload could not report one result: fact=%q err=%v", first.GetReceipt().GetFactId(), err)
}
stored, err := database.LoadRecordingUpload(request.DispatcherId, request.SourceEventId)
if err != nil || stored.ConfirmedAt == "" {
t.Fatalf("uploaded fact was not committed with result outbox: state=%+v err=%v", stored, err)
}
duplicate, err := server.ReportCallResult(ctx, result)
if err != nil || duplicate.GetReceipt().GetFactId() != first.GetReceipt().GetFactId() {
t.Fatalf("duplicate result created another event: original=%q duplicate=%q err=%v", first.GetReceipt().GetFactId(), duplicate.GetReceipt().GetFactId(), err)
}
outbox, err := database.ListPendingOutbox(request.DispatcherId)
if err != nil || len(outbox) != 2 || outbox[0].EventType != "call.execute" || outbox[1].EventType != "call.execute.result" {
t.Fatalf("Agent end and upload did not produce exactly one ACK+result: events=%+v err=%v", outbox, err)
}
if _, err := server.RequestRecordingUpload(ctx, request); status.Code(err) != codes.FailedPrecondition {
t.Fatalf("confirmed uploaded asset got another PUT token: %v", err)
}
}
func TestRecordingServerRejectsMalformedResultBeforeOutbox(t *testing.T) {
server, database, ctx, request, _ := recordingRPCFixture(t)
end := &agentpb.ReportCallEndedRequest{Meta: request.Meta, DispatcherId: request.DispatcherId, TenantId: request.TenantId, SourceEventId: request.SourceEventId}
if _, err := server.ReportCallEnded(ctx, end); err != nil {
t.Fatal(err)
}
bad := &agentpb.ReportCallResultRequest{Meta: request.Meta, DispatcherId: request.DispatcherId, TenantId: request.TenantId, SourceEventId: request.SourceEventId, ResultPayloadJson: []byte(`{"task_id":`)}
if _, err := server.ReportCallResult(ctx, bad); status.Code(err) != codes.InvalidArgument {
t.Fatalf("malformed result was not reported as bad Agent input: %v", err)
}
pending, err := database.ListPendingOutbox(request.DispatcherId)
if err != nil || len(pending) != 1 || pending[0].EventType != "call.execute" {
t.Fatalf("malformed payload escaped into final result: events=%+v err=%v", pending, err)
}
}
func TestRecordingServerNoRecordingResultCannotGainLaterGrant(t *testing.T) {
server, database, ctx, request, snapshot := recordingRPCFixture(t)
end := &agentpb.ReportCallEndedRequest{Meta: request.Meta, DispatcherId: request.DispatcherId, TenantId: request.TenantId, SourceEventId: request.SourceEventId}
if _, err := server.ReportCallEnded(ctx, end); err != nil {
t.Fatal(err)
}
result := &agentpb.ReportCallResultRequest{Meta: request.Meta, DispatcherId: request.DispatcherId, TenantId: request.TenantId, SourceEventId: request.SourceEventId,
ResultPayloadJson: recordingResultPayload(t, snapshot, nil)}
first, err := server.ReportCallResult(ctx, result)
if err != nil || first.GetReceipt().GetFactId() == "" {
t.Fatalf("no-recording confirmed end was not reported: fact=%q err=%v", first.GetReceipt().GetFactId(), err)
}
if repeated, err := server.ReportCallResult(ctx, result); err != nil || repeated.GetReceipt().GetFactId() != first.GetReceipt().GetFactId() {
t.Fatalf("no-recording result replay changed identity: err=%v", err)
}
if _, err := server.RequestRecordingUpload(ctx, request); status.Code(err) != codes.AlreadyExists {
t.Fatalf("final no-recording result gained a later object: %v", err)
}
outbox, err := database.ListPendingOutbox(request.DispatcherId)
if err != nil || len(outbox) != 2 {
t.Fatalf("no-recording end has multiple or missing results: events=%+v err=%v", outbox, err)
}
}