Files
go-sip/internal/sipcall/configuration_test.go
T

354 lines
11 KiB
Go

package sipcall
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
func configCLI(c Config, basePath string) cliFunc {
return func(_ context.Context, command string) ([]byte, error) {
switch command {
case "core show channels count":
return []byte("0 active channels\n0 active calls\n"), nil
case "pjsip show transports":
return []byte("Transport: go-sip-udp udp 0 0 0.0.0.0:5060\n"), nil
case "module reload res_pjsip.so":
return []byte("Module 'res_pjsip.so' reloaded successfully.\n"), nil
case "pjsip show endpoint " + c.Endpoint():
data, err := os.ReadFile(basePath)
if err != nil {
return nil, err
}
if !bytes.Contains(data, []byte(testInclude)) {
return []byte("Unable to find object " + c.Endpoint()), nil
}
if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); os.IsNotExist(err) {
return []byte("Unable to find object " + c.Endpoint()), nil
} else if err != nil {
return nil, err
}
return []byte(fmt.Sprintf("Endpoint: %s\nAor: %s-aor\nallow : (alaw)\ntransport : go-sip-udp\ncontext : go-sip-no-inbound\nfrom_user : %s\ncallerid : \"%s\" <%s>\n", c.Endpoint(), c.Endpoint(), c.CallerID, c.CallerID, c.CallerID)), nil
case "pjsip show aor " + c.Endpoint() + "-aor":
if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); os.IsNotExist(err) {
return []byte("Unable to find object " + c.Endpoint() + "-aor"), nil
} else if err != nil {
return nil, err
}
return []byte(fmt.Sprintf("Contact: sip:%s:%d", c.Server, c.Port)), nil
default:
return nil, fmt.Errorf("unexpected CLI: %s", command)
}
}
}
func stageFixture(t *testing.T) (Config, string, []byte) {
t.Helper()
c := fixtureConfig(t)
c.ConfigDir = t.TempDir()
path := filepath.Join(c.ConfigDir, "pjsip.conf")
base := []byte("; original bytes\n[go-sip-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\n" + testInclude)
if err := os.WriteFile(path, base, 0600); err != nil {
t.Fatal(err)
}
return c, path, base
}
func TestTemporaryConfigurationPreservesBusinessBytes(t *testing.T) {
c, path, base := stageFixture(t)
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
if err != nil {
t.Fatal(err)
}
current, err := os.ReadFile(path)
if err != nil || !bytes.Equal(current, base) {
t.Fatal("business config was modified while applying test line")
}
during, err := os.Stat(path)
if err != nil || !os.SameFile(info, during) || !info.ModTime().Equal(during.ModTime()) {
t.Fatal("business config was replaced or rewritten")
}
if _, err := os.Stat(filepath.Join(c.resultDir, "pjsip.conf.before")); !os.IsNotExist(err) {
t.Fatalf("whole configuration backup must not be created: %v", err)
}
managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf")
if err := checkConfigMetadata(managed, info); err != nil {
t.Fatalf("temporary include owner/group/mode: %v", err)
}
data, err := os.ReadFile(managed)
if err != nil || !bytes.Contains(data, []byte("from_user=BD1234")) {
t.Fatal("requested identity not configured")
}
if err = restore(); err != nil {
t.Fatal(err)
}
if err := checkConfigMetadata(path, info); err != nil {
t.Fatalf("restored owner/group/mode: %v", err)
}
data, err = os.ReadFile(path)
if err != nil || !bytes.Equal(data, base) {
t.Fatal("business config bytes changed")
}
if _, err = os.Stat(managed); !os.IsNotExist(err) {
t.Fatal("temporary file not removed")
}
after, err := os.Stat(path)
if err != nil || !os.SameFile(info, after) || !info.ModTime().Equal(after.ModTime()) {
t.Fatal("business config was replaced or rewritten during cleanup")
}
}
func TestPrivateConfigInfoRejectsRootAndDifferentOwner(t *testing.T) {
_, path, base := stageFixture(t)
for _, uid := range []int{0, os.Geteuid() + 1} {
if _, err := privateConfigInfo(path, uid); err == nil || !strings.Contains(err.Error(), "sudo sip-call") {
t.Fatalf("uid %d was not rejected with actionable guidance: %v", uid, err)
}
}
if _, err := privateConfigInfo(path, os.Geteuid()); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil || !bytes.Equal(data, base) {
t.Fatal("owner preflight changed configuration")
}
}
type configOwnerInfo struct {
os.FileInfo
owner syscall.Stat_t
}
func (i configOwnerInfo) Sys() any { return &i.owner }
func TestConfigMetadataChecksBothOwnerAndGroup(t *testing.T) {
_, path, _ := stageFixture(t)
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"uid", "gid"} {
owner := *info.Sys().(*syscall.Stat_t)
if field == "uid" {
owner.Uid++
} else {
owner.Gid++
}
if err := checkConfigMetadata(path, configOwnerInfo{FileInfo: info, owner: owner}); err == nil {
t.Fatalf("%s mismatch was ignored", field)
}
}
}
func TestRestoreRefusesChangedConfigMetadata(t *testing.T) {
c, path, base := stageFixture(t)
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, 0644); err != nil {
t.Fatal(err)
}
if err := restore(); err == nil {
t.Fatal("restoration silently replaced configuration with changed metadata")
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0644 {
t.Fatalf("changed configuration was overwritten: %v, %v", info, err)
}
if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); err != nil {
t.Fatalf("recovery include was removed: %v", err)
}
current, err := os.ReadFile(path)
if err != nil || !bytes.Equal(current, base) {
t.Fatal("cleanup changed content after metadata mismatch")
}
}
func TestPrivateConfigInfoRejectsInvalidExistingFile(t *testing.T) {
for _, mode := range []string{"public", "symlink", "missing"} {
t.Run(mode, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "pjsip.conf")
switch mode {
case "public":
if err := os.WriteFile(path, []byte("original"), 0644); err != nil {
t.Fatal(err)
}
case "symlink":
target := filepath.Join(dir, "target")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
}
if _, err := privateConfigInfo(path, os.Geteuid()); err == nil {
t.Fatal("invalid configuration was accepted")
}
})
}
}
func TestConfigurationRequiresOnePreconfiguredInclude(t *testing.T) {
for _, include := range []string{"", "; #tryinclude sip-call-managed.conf\n", "#include sip-call-managed.conf\n", testInclude + testInclude} {
t.Run(include, func(t *testing.T) {
c, path, base := stageFixture(t)
base = append(bytes.TrimSuffix(base, []byte(testInclude)), []byte(include)...)
if err := os.WriteFile(path, base, 0600); err != nil {
t.Fatal(err)
}
cli := func(context.Context, string) ([]byte, error) {
t.Fatal("missing or duplicate include must fail before accessing Asterisk")
return nil, nil
}
restore, err := applyConfiguration(context.Background(), c, cli)
if err == nil || !strings.Contains(err.Error(), "#tryinclude sip-call-managed.conf") || restore != nil {
t.Fatalf("invalid preparation was not rejected: %v", err)
}
current, err := os.ReadFile(path)
if err != nil || !bytes.Equal(current, base) {
t.Fatal("tool silently prepared or modified the original config")
}
if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); !os.IsNotExist(err) {
t.Fatalf("temporary config created without a valid include: %v", err)
}
})
}
}
func TestConfigurationCleanupPreservesChangedTemporaryFile(t *testing.T) {
c, path, _ := stageFixture(t)
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
if err != nil {
t.Fatal(err)
}
managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf")
changed := []byte("concurrent temporary config edit")
if err := os.WriteFile(managed, changed, 0600); err != nil {
t.Fatal(err)
}
if err := restore(); err == nil {
t.Fatal("cleanup deleted a changed temporary file")
}
current, err := os.ReadFile(managed)
if err != nil || !bytes.Equal(current, changed) {
t.Fatal("changed temporary file was not preserved")
}
}
func TestConfigurationCleanupConfirmsEndpointAndAORRemoval(t *testing.T) {
for _, object := range []string{"endpoint", "aor"} {
t.Run(object, func(t *testing.T) {
c, path, _ := stageFixture(t)
fixture := configCLI(c, path)
cli := func(ctx context.Context, command string) ([]byte, error) {
out, err := fixture(ctx, command)
if strings.HasPrefix(command, "pjsip show "+object+" ") && bytes.Contains(out, []byte("Unable to find object")) {
return []byte("stale runtime object"), nil
}
return out, err
}
restore, err := applyConfiguration(context.Background(), c, cli)
if err != nil {
t.Fatal(err)
}
if err := restore(); err == nil || !strings.Contains(err.Error(), object) {
t.Fatalf("stale %s was reported as cleaned: %v", object, err)
}
})
}
}
func TestConfigurationRefusesOldAndConcurrentState(t *testing.T) {
c, path, _ := stageFixture(t)
managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf")
if err := os.WriteFile(managed, []byte("unfinished"), 0600); err != nil {
t.Fatal(err)
}
if _, err := applyConfiguration(context.Background(), c, configCLI(c, path)); err == nil {
t.Fatal("old test state overwritten")
}
if err := os.Remove(managed); err != nil {
t.Fatal(err)
}
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
if err != nil {
t.Fatal(err)
}
concurrent := []byte("concurrent config edit")
if err = os.WriteFile(path, concurrent, 0600); err != nil {
t.Fatal(err)
}
if err = restore(); err == nil {
t.Fatal("concurrent changes swallowed")
}
data, _ := os.ReadFile(path)
if !bytes.Equal(data, concurrent) {
t.Fatal("concurrent bytes overwritten")
}
}
func TestConfigurationFailsClosedOnRuntimeMismatch(t *testing.T) {
for _, failure := range []string{"transport", "caller", "reload", "aor", "busy"} {
t.Run(failure, func(t *testing.T) {
c, path, base := stageFixture(t)
normal := configCLI(c, path)
cli := func(ctx context.Context, s string) ([]byte, error) {
out, err := normal(ctx, s)
if err != nil {
return nil, err
}
if failure == "transport" && s == "pjsip show transports" {
return []byte("unknown"), nil
}
if failure == "caller" && strings.HasPrefix(s, "pjsip show endpoint") {
return bytes.ReplaceAll(out, []byte("BD1234"), []byte("WRONG")), nil
}
if failure == "reload" && s == "module reload res_pjsip.so" {
return nil, errors.New("reload failed")
}
if failure == "aor" && strings.HasPrefix(s, "pjsip show aor") && !bytes.Contains(out, []byte("Unable to find object")) {
return []byte("sip:192.0.2.99:5060"), nil
}
if failure == "busy" && s == "core show channels count" {
return []byte("1 active channel"), nil
}
return out, nil
}
restore, err := applyConfiguration(context.Background(), c, cli)
if failure == "busy" {
if err != nil {
t.Fatal(err)
}
if err = restore(); err == nil {
t.Fatal("restoration ignored active channel")
}
return
}
if err == nil {
t.Fatal("runtime mismatch accepted")
}
if restore != nil {
if err = restore(); err != nil && failure != "reload" {
t.Fatal(err)
}
}
data, _ := os.ReadFile(path)
if !bytes.Equal(data, base) {
t.Fatal("failed apply changed original config")
}
})
}
}