271 lines
14 KiB
Go
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)
|
|
}
|
|
}
|