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") } }) } }