chore: initialize go-sip repository
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
type CommandEnvelope struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
CommandType string `json:"command_type"`
|
||||
CommandID string `json:"command_id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantKey string `json:"tenant_key"`
|
||||
TraceID string `json:"trace_id"`
|
||||
IssuedAt string `json:"issued_at"`
|
||||
NotAfter string `json:"not_after"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
type ExecutePayload struct {
|
||||
ExecutionID string `json:"execution_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskItemID string `json:"task_item_id"`
|
||||
TaskRevision int64 `json:"task_revision"`
|
||||
Callee string `json:"callee"`
|
||||
RoutePolicyID string `json:"route_policy_id"`
|
||||
CallerProfileID string `json:"caller_profile_id"`
|
||||
AgentVersionID string `json:"agent_version_id"`
|
||||
Variables map[string]any `json:"variables"`
|
||||
RingTimeoutMS int64 `json:"ring_timeout_ms"`
|
||||
MaxCallDurationMS int64 `json:"max_call_duration_ms"`
|
||||
}
|
||||
|
||||
type EventEnvelope struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantKey string `json:"tenant_key"`
|
||||
TraceID string `json:"trace_id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
AggregateType string `json:"aggregate_type"`
|
||||
AggregateID string `json:"aggregate_id"`
|
||||
AggregateVersion int64 `json:"aggregate_version"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
}
|
||||
|
||||
var ErrInvalidTenantKey = errors.New("invalid tenant_key")
|
||||
|
||||
// ValidateJSON applies the imported JSON Schema. It intentionally validates
|
||||
// the source contract instead of maintaining a second hand-written schema.
|
||||
func ValidateJSON(raw []byte) error {
|
||||
return ValidateSourceSchema("mq.schema.json", raw)
|
||||
}
|
||||
|
||||
// ValidateEvent applies the event-specific payload contract in addition to the
|
||||
// generic MQ envelope contract.
|
||||
func ValidateEvent(raw []byte) error {
|
||||
return ValidateSourceSchema("event-payloads.schema.json", raw)
|
||||
}
|
||||
|
||||
func ValidateSourceSchema(schemaName string, raw []byte) error {
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return fmt.Errorf("decode json: %w", err)
|
||||
}
|
||||
var schemaDoc any
|
||||
if err := contracts.ReadJSON(schemaName, &schemaDoc); err != nil {
|
||||
return err
|
||||
}
|
||||
resource := "https://agent-call.invalid/contracts/" + schemaName
|
||||
compiler := jsonschema.NewCompiler()
|
||||
if err := compiler.AddResource(resource, schemaDoc); err != nil {
|
||||
return fmt.Errorf("register %s: %w", schemaName, err)
|
||||
}
|
||||
schema, err := compiler.Compile(resource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compile %s: %w", schemaName, err)
|
||||
}
|
||||
if err := schema.Validate(value); err != nil {
|
||||
return fmt.Errorf("%s validation: %w", schemaName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DecodeExecute(raw []byte) (CommandEnvelope, ExecutePayload, error) {
|
||||
if err := ValidateJSON(raw); err != nil {
|
||||
return CommandEnvelope{}, ExecutePayload{}, err
|
||||
}
|
||||
var envelope CommandEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("decode command envelope: %w", err)
|
||||
}
|
||||
if envelope.CommandType != "call.execute" {
|
||||
return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("unsupported command_type %q", envelope.CommandType)
|
||||
}
|
||||
if err := ValidateTenantKey(envelope.TenantKey); err != nil {
|
||||
return CommandEnvelope{}, ExecutePayload{}, err
|
||||
}
|
||||
var payload ExecutePayload
|
||||
if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
|
||||
return CommandEnvelope{}, ExecutePayload{}, fmt.Errorf("decode call.execute payload: %w", err)
|
||||
}
|
||||
return envelope, payload, nil
|
||||
}
|
||||
|
||||
func ValidateTenantKey(key string) error {
|
||||
if key == "" || !utf8.ValidString(key) {
|
||||
return fmt.Errorf("%w: must be non-empty valid UTF-8", ErrInvalidTenantKey)
|
||||
}
|
||||
if len([]byte(key)) > 224 {
|
||||
return fmt.Errorf("%w: %d UTF-8 bytes exceeds 224-byte routing budget", ErrInvalidTenantKey, len([]byte(key)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NotAfterExpired(raw string, now time.Time) (bool, error) {
|
||||
deadline, err := time.Parse(time.RFC3339Nano, raw)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("parse not_after: %w", err)
|
||||
}
|
||||
return !now.Before(deadline), nil
|
||||
}
|
||||
|
||||
func CloneJSON(raw []byte) json.RawMessage {
|
||||
return bytes.Clone(raw)
|
||||
}
|
||||
|
||||
type EventBuilder struct {
|
||||
TenantID string
|
||||
TenantKey string
|
||||
TraceID string
|
||||
EventType string
|
||||
Aggregate string
|
||||
AggregateID string
|
||||
Version int64
|
||||
Payload map[string]any
|
||||
}
|
||||
|
||||
func (b EventBuilder) Marshal(now time.Time, eventID string) ([]byte, error) {
|
||||
if err := ValidateTenantKey(b.TenantKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.Version < 1 {
|
||||
return nil, errors.New("aggregate version must be positive")
|
||||
}
|
||||
e := EventEnvelope{
|
||||
SchemaVersion: "1.0", EventID: eventID, EventType: b.EventType,
|
||||
TenantID: b.TenantID, TenantKey: b.TenantKey, TraceID: b.TraceID,
|
||||
OccurredAt: now.UTC().Format(time.RFC3339Nano), AggregateType: b.Aggregate,
|
||||
AggregateID: b.AggregateID, AggregateVersion: b.Version, Payload: b.Payload,
|
||||
}
|
||||
raw, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateJSON(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateEvent(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
)
|
||||
|
||||
func TestDecodeExecuteValidatesImportedSchema(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/call.execute.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envelope, payload, err := DecodeExecute(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.TenantKey != "tenant-demo-key" || payload.ExecutionID == "" {
|
||||
t.Fatalf("unexpected decoded command: %+v %+v", envelope, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTenantKeyPreservesRawValueAndBudget(t *testing.T) {
|
||||
for _, key := range []string{"客户-A", " tenant /raw "} {
|
||||
if err := ValidateTenantKey(key); err != nil {
|
||||
t.Fatalf("tenant key %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
if err := ValidateTenantKey(strings.Repeat("x", 225)); err == nil {
|
||||
t.Fatal("expected routing budget rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventFixtures(t *testing.T) {
|
||||
positive := []string{
|
||||
"examples/event-command-result.json",
|
||||
"examples/event-call-status.json",
|
||||
"examples/event-transcript-updated.json",
|
||||
"examples/event-call-finished.json",
|
||||
"examples/event-recording-ready.json",
|
||||
"examples/event-recording-failed.json",
|
||||
"examples/event-transcript-failed.json",
|
||||
"examples/event-contact-opt-out.json",
|
||||
}
|
||||
for _, name := range positive {
|
||||
raw, err := contracts.Read(name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if err := ValidateEvent(raw); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
invalid, err := contracts.Read("examples/invalid-event-unknown-type.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateEvent(invalid); err == nil {
|
||||
t.Fatal("expected invalid transcript alias to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectOwnedW01Fixtures(t *testing.T) {
|
||||
valid := []string{
|
||||
"examples/ai-authorization.json",
|
||||
"examples/oss-upload-grant.json",
|
||||
"examples/static-cell-artifact.json",
|
||||
"p1-development-profile.json",
|
||||
}
|
||||
for _, name := range valid {
|
||||
raw, err := contracts.Read(name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if err := ValidateSourceSchema(schemaNameForFixture(name), raw); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
invalid := []struct {
|
||||
name string
|
||||
schema string
|
||||
}{
|
||||
{"examples/invalid-ai-authorization-revoked.json", "ai-authorization.schema.json"},
|
||||
{"examples/invalid-oss-upload-http.json", "oss-upload.schema.json"},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
raw, err := contracts.Read(tc.name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.name, err)
|
||||
}
|
||||
if err := ValidateSourceSchema(tc.schema, raw); err == nil {
|
||||
t.Fatalf("%s: expected rejection", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func schemaNameForFixture(name string) string {
|
||||
switch name {
|
||||
case "examples/ai-authorization.json":
|
||||
return "ai-authorization.schema.json"
|
||||
case "examples/oss-upload-grant.json":
|
||||
return "oss-upload.schema.json"
|
||||
case "examples/static-cell-artifact.json":
|
||||
return "static-cell-artifact.schema.json"
|
||||
case "p1-development-profile.json":
|
||||
return "p1-development-profile.schema.json"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventBuilder(t *testing.T) {
|
||||
raw, err := (EventBuilder{
|
||||
TenantID: "tenant-1", TenantKey: "tenant-1", TraceID: "trace-1",
|
||||
EventType: "command.result", Aggregate: "command", AggregateID: "cmd-1", Version: 1,
|
||||
Payload: map[string]any{
|
||||
"command_id": "cmd-1", "command_type": "call.execute",
|
||||
"status": "accepted", "reason_code": "accepted",
|
||||
"execution_id": "execution-1",
|
||||
},
|
||||
}).Marshal(time.Unix(0, 0), "event-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateEvent(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
)
|
||||
|
||||
func TestContractBundleIsReadable(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"mq.schema.json", "event-payloads.schema.json", "ai-config.schema.json",
|
||||
"ai-authorization.schema.json", "oss-upload.schema.json",
|
||||
"static-cell-artifact.schema.json", "p1-development-profile.schema.json",
|
||||
"executor.openapi.yaml", "saas.openapi.yaml", "cell-agent.openapi.yaml",
|
||||
} {
|
||||
data, err := contracts.Read(name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatalf("%s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// StaticCellArtifact is the management-approved, immutable Cell/SIP hand-off
|
||||
// artifact. Its JSON shape is owned by static-cell-artifact.schema.json; this
|
||||
// type only provides a typed boundary after schema validation.
|
||||
type StaticCellArtifact struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
SourceRelease string `json:"source_release"`
|
||||
SourceDigest string `json:"source_digest"`
|
||||
ApprovalReference string `json:"approval_reference"`
|
||||
CellID string `json:"cell_id"`
|
||||
Revision uint64 `json:"revision"`
|
||||
ConfigSHA256 string `json:"config_sha256"`
|
||||
Mode string `json:"mode"`
|
||||
AllowedTargets []string `json:"allowed_targets"`
|
||||
Trunks []StaticTrunk `json:"trunks"`
|
||||
ARI *StaticARI `json:"ari,omitempty"`
|
||||
MediaProfiles map[string]StaticMediaProfile `json:"media_profiles,omitempty"`
|
||||
Media *StaticMedia `json:"media,omitempty"`
|
||||
Recording *StaticRecording `json:"recording,omitempty"`
|
||||
LoadEvidence *StaticLoadEvidence `json:"load_evidence"`
|
||||
}
|
||||
|
||||
type StaticTrunk struct {
|
||||
TrunkID string `json:"trunk_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
EgressPoolID string `json:"egress_pool_id"`
|
||||
Codec string `json:"codec"`
|
||||
CallerProfileIDs []string `json:"caller_profile_ids"`
|
||||
DialPrefix string `json:"dial_prefix"`
|
||||
Enabled bool `json:"enabled"`
|
||||
SIPEndpointRef string `json:"sip_endpoint_ref"`
|
||||
CredentialRef *string `json:"credential_ref"`
|
||||
MediaProfileID string `json:"media_profile_id,omitempty"`
|
||||
}
|
||||
|
||||
type StaticARI struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
WebsocketURL string `json:"websocket_url"`
|
||||
Application string `json:"application"`
|
||||
CredentialRef string `json:"credential_ref"`
|
||||
}
|
||||
|
||||
type StaticMedia struct {
|
||||
BindAddress string `json:"bind_address"`
|
||||
Port int `json:"port"`
|
||||
Format string `json:"format"`
|
||||
SampleRateHz int `json:"sample_rate_hz"`
|
||||
Channels int `json:"channels"`
|
||||
PayloadType int `json:"payload_type"`
|
||||
}
|
||||
|
||||
type StaticMediaProfile struct {
|
||||
Format string `json:"format"`
|
||||
SampleRateHz int `json:"sample_rate_hz"`
|
||||
Channels int `json:"channels"`
|
||||
PayloadType int `json:"payload_type"`
|
||||
}
|
||||
|
||||
type StaticRecording struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Format string `json:"format"`
|
||||
Directory string `json:"directory"`
|
||||
MaxBytes int64 `json:"max_bytes"`
|
||||
}
|
||||
|
||||
type StaticLoadEvidence struct {
|
||||
AsteriskConfigSHA256 string `json:"asterisk_config_sha256"`
|
||||
LoadedAt string `json:"loaded_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// StaticArtifactExpectation contains deployment-local binding constraints.
|
||||
// Empty string/slice values leave the corresponding optional check disabled;
|
||||
// the source contract remains mandatory and is always validated first.
|
||||
type StaticArtifactExpectation struct {
|
||||
CellID string
|
||||
Mode string
|
||||
SourceRelease string
|
||||
SourceDigest string
|
||||
ConfigSHA256 string
|
||||
MinimumRevision uint64
|
||||
AllowedEgressPoolIDs []string
|
||||
RequiredTrunkIDs []string
|
||||
}
|
||||
|
||||
// ValidateStaticArtifact validates the imported artifact schema and then
|
||||
// applies the local Cell hand-off bindings. It deliberately does not claim
|
||||
// that Asterisk has loaded the artifact: load_evidence.status is explicitly
|
||||
// "not-yet-loaded" in the contract until an independent load check exists.
|
||||
func ValidateStaticArtifact(raw []byte, expected StaticArtifactExpectation) (StaticCellArtifact, error) {
|
||||
if err := ValidateSourceSchema("static-cell-artifact.schema.json", raw); err != nil {
|
||||
return StaticCellArtifact{}, err
|
||||
}
|
||||
|
||||
var artifact StaticCellArtifact
|
||||
if err := json.Unmarshal(raw, &artifact); err != nil {
|
||||
return StaticCellArtifact{}, fmt.Errorf("decode static Cell artifact: %w", err)
|
||||
}
|
||||
if expected.CellID != "" && artifact.CellID != expected.CellID {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact cell binding mismatch: got %q, want %q", artifact.CellID, expected.CellID)
|
||||
}
|
||||
if expected.Mode != "" && artifact.Mode != expected.Mode {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact mode mismatch: got %q, want %q", artifact.Mode, expected.Mode)
|
||||
}
|
||||
if expected.SourceRelease != "" && artifact.SourceRelease != expected.SourceRelease {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact source release mismatch: got %q, want %q", artifact.SourceRelease, expected.SourceRelease)
|
||||
}
|
||||
if expected.SourceDigest != "" && artifact.SourceDigest != expected.SourceDigest {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact source digest mismatch: got %q, want %q", artifact.SourceDigest, expected.SourceDigest)
|
||||
}
|
||||
if expected.ConfigSHA256 != "" && artifact.ConfigSHA256 != expected.ConfigSHA256 {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact config digest mismatch: got %q, want %q", artifact.ConfigSHA256, expected.ConfigSHA256)
|
||||
}
|
||||
if expected.MinimumRevision != 0 && artifact.Revision < expected.MinimumRevision {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact revision %d is older than required %d", artifact.Revision, expected.MinimumRevision)
|
||||
}
|
||||
|
||||
allowedEgress := make(map[string]struct{}, len(expected.AllowedEgressPoolIDs))
|
||||
for _, egressPoolID := range expected.AllowedEgressPoolIDs {
|
||||
allowedEgress[egressPoolID] = struct{}{}
|
||||
}
|
||||
requiredTrunks := make(map[string]struct{}, len(expected.RequiredTrunkIDs))
|
||||
for _, trunkID := range expected.RequiredTrunkIDs {
|
||||
requiredTrunks[trunkID] = struct{}{}
|
||||
}
|
||||
seenTrunks := make(map[string]struct{}, len(artifact.Trunks))
|
||||
for _, trunk := range artifact.Trunks {
|
||||
if _, duplicate := seenTrunks[trunk.TrunkID]; duplicate {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact contains duplicate trunk_id %q", trunk.TrunkID)
|
||||
}
|
||||
seenTrunks[trunk.TrunkID] = struct{}{}
|
||||
if len(allowedEgress) != 0 {
|
||||
if _, ok := allowedEgress[trunk.EgressPoolID]; !ok {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact trunk %q uses disallowed egress pool %q", trunk.TrunkID, trunk.EgressPoolID)
|
||||
}
|
||||
}
|
||||
if _, required := requiredTrunks[trunk.TrunkID]; required && !trunk.Enabled {
|
||||
return StaticCellArtifact{}, fmt.Errorf("required static artifact trunk %q is disabled", trunk.TrunkID)
|
||||
}
|
||||
if trunk.MediaProfileID != "" {
|
||||
if _, ok := artifact.MediaProfiles[trunk.MediaProfileID]; !ok {
|
||||
return StaticCellArtifact{}, fmt.Errorf("static artifact trunk %q references unknown media profile %q", trunk.TrunkID, trunk.MediaProfileID)
|
||||
}
|
||||
}
|
||||
}
|
||||
for trunkID := range requiredTrunks {
|
||||
if _, present := seenTrunks[trunkID]; !present {
|
||||
return StaticCellArtifact{}, fmt.Errorf("required static artifact trunk %q is missing", trunkID)
|
||||
}
|
||||
}
|
||||
return artifact, nil
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
)
|
||||
|
||||
func TestValidateStaticArtifactBindsCellAndTrunks(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/static-cell-artifact.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
artifact, err := ValidateStaticArtifact(raw, StaticArtifactExpectation{
|
||||
CellID: "cell-a",
|
||||
Mode: "mock",
|
||||
SourceRelease: "management-snapshot-1",
|
||||
SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
MinimumRevision: 1,
|
||||
AllowedEgressPoolIDs: []string{"egress-mock"},
|
||||
RequiredTrunkIDs: []string{"trunk-mock"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("valid artifact rejected: %v", err)
|
||||
}
|
||||
if artifact.CellID != "cell-a" || artifact.Revision != 1 || len(artifact.Trunks) != 1 {
|
||||
t.Fatalf("unexpected artifact: %+v", artifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRealStaticArtifact(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/static-cell-artifact-real-v1.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
artifact, err := ValidateStaticArtifact(raw, StaticArtifactExpectation{
|
||||
CellID: "cell-single",
|
||||
Mode: "real",
|
||||
SourceRelease: "asterisk-22.10.1-native-v1",
|
||||
SourceDigest: "68006a1a8efed288be4ca4a2ae3cb9554a31d733eac08eaacf4c646c95faf74d",
|
||||
ConfigSHA256: "89d2686d0d1ca60159c3c6bd725dc9e6f511cbdb56bf6ce7b65ca7d4dc3f2d60",
|
||||
AllowedEgressPoolIDs: []string{"egress-single"},
|
||||
RequiredTrunkIDs: []string{"provider-second"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("valid real artifact rejected: %v", err)
|
||||
}
|
||||
if artifact.ARI == nil || artifact.Media == nil || artifact.Recording == nil {
|
||||
t.Fatalf("real artifact lost runtime sections: %+v", artifact)
|
||||
}
|
||||
if artifact.Media.Format != "alaw" || artifact.Media.SampleRateHz != 8000 || artifact.Media.PayloadType != 8 || artifact.Recording.Format != "wav" {
|
||||
t.Fatalf("unexpected real media/recording contract: %+v %+v", artifact.Media, artifact.Recording)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStaticArtifactRejectsBindingViolations(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/static-cell-artifact.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
base := func() StaticArtifactExpectation {
|
||||
return StaticArtifactExpectation{
|
||||
CellID: "cell-a",
|
||||
Mode: "mock",
|
||||
SourceRelease: "management-snapshot-1",
|
||||
SourceDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
ConfigSHA256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
AllowedEgressPoolIDs: []string{"egress-mock"},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected StaticArtifactExpectation
|
||||
mutate func(*StaticCellArtifact)
|
||||
}{
|
||||
{name: "wrong cell", expected: func() StaticArtifactExpectation { e := base(); e.CellID = "cell-b"; return e }()},
|
||||
{name: "wrong source digest", expected: func() StaticArtifactExpectation {
|
||||
e := base()
|
||||
e.SourceDigest = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
return e
|
||||
}()},
|
||||
{name: "old revision", expected: func() StaticArtifactExpectation { e := base(); e.MinimumRevision = 2; return e }()},
|
||||
{name: "disallowed egress", expected: func() StaticArtifactExpectation {
|
||||
e := base()
|
||||
e.AllowedEgressPoolIDs = []string{"egress-other"}
|
||||
return e
|
||||
}()},
|
||||
{name: "missing required trunk", expected: func() StaticArtifactExpectation {
|
||||
e := base()
|
||||
e.RequiredTrunkIDs = []string{"trunk-required"}
|
||||
return e
|
||||
}()},
|
||||
{name: "disabled required trunk", expected: func() StaticArtifactExpectation { e := base(); e.RequiredTrunkIDs = []string{"trunk-mock"}; return e }(), mutate: func(a *StaticCellArtifact) { a.Trunks[0].Enabled = false }},
|
||||
{name: "duplicate trunk", expected: base(), mutate: func(a *StaticCellArtifact) { a.Trunks = append(a.Trunks, a.Trunks[0]) }},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := raw
|
||||
if test.mutate != nil {
|
||||
var artifact StaticCellArtifact
|
||||
if err := json.Unmarshal(raw, &artifact); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
test.mutate(&artifact)
|
||||
candidate, err = json.Marshal(artifact)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := ValidateStaticArtifact(candidate, test.expected); err == nil {
|
||||
t.Fatal("expected static artifact validation to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStaticArtifactAlwaysChecksSourceSchema(t *testing.T) {
|
||||
raw, err := contracts.Read("examples/static-cell-artifact.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value["unexpected"] = true
|
||||
candidate, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ValidateStaticArtifact(candidate, StaticArtifactExpectation{}); err == nil {
|
||||
t.Fatal("expected schema validation failure")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user