Skip to content
File

Blob: integrations/compat_test.go

go185 lines
1package integrations
2 
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "sync"
16 "syscall"
17 "testing"
18 "time"
19 
20 clientcmd "go.miragespace.co/specter/cmd/client"
21 
22 "github.com/stretchr/testify/require"
23)
24 
25type compatLog struct {
26 mu sync.Mutex
27 bytes.Buffer
28}
29 
30func (b *compatLog) Write(p []byte) (int, error) {
31 b.mu.Lock()
32 defer b.mu.Unlock()
33 return b.Buffer.Write(p)
34}
35 
36func (b *compatLog) messages() []map[string]any {
37 b.mu.Lock()
38 defer b.mu.Unlock()
39 var messages []map[string]any
40 for _, line := range strings.Split(b.Buffer.String(), "\n") {
41 var msg map[string]any
42 if json.Unmarshal([]byte(line), &msg) == nil {
43 messages = append(messages, msg)
44 }
45 }
46 return messages
47}
48 
49func startCompatBinary(t *testing.T, binary string, signal os.Signal, args ...string) (*compatLog, func()) {
50 t.Helper()
51 cmd := exec.Command(binary, args...)
52 logs := &compatLog{}
53 cmd.Stdout = io.Discard
54 cmd.Stderr = logs
55 require.NoError(t, cmd.Start())
56 done := make(chan error, 1)
57 go func() { done <- cmd.Wait() }()
58 stopped := false
59 stop := func() {
60 if stopped {
61 return
62 }
63 stopped = true
64 cmd.Process.Signal(signal)
65 select {
66 case err := <-done:
67 require.NoError(t, err)
68 case <-time.After(30 * time.Second):
69 cmd.Process.Kill()
70 <-done
71 t.Error("compat binary required forced termination")
72 }
73 }
74 t.Cleanup(stop)
75 t.Cleanup(func() {
76 if t.Failed() {
77 for _, msg := range logs.messages() {
78 t.Log(msg)
79 }
80 }
81 })
82 return logs, stop
83}
84 
85func TestIntegrationCompat(t *testing.T) {
86 if os.Getenv("GO_INTEGRATION_COMPAT") != "1" {
87 t.Skip("set GO_INTEGRATION_COMPAT=1 and SPECTER_COMPAT_OLD_WORKTREE")
88 }
89 old := os.Getenv("SPECTER_COMPAT_OLD_WORKTREE")
90 require.NotEmpty(t, old)
91 git := func(args ...string) string {
92 cmd := exec.Command("git", append([]string{"-C", old}, args...)...)
93 out, err := cmd.CombinedOutput()
94 require.NoError(t, err, string(out))
95 return strings.TrimSpace(string(out))
96 }
97 require.Empty(t, git("status", "--porcelain"))
98 revision := git("rev-parse", "HEAD")
99 t.Log("Old revision:", revision)
100 require.True(t, strings.HasPrefix(revision, "5d469e8"), "expected pre-feature revision")
101 t.Cleanup(func() { require.Empty(t, git("status", "--porcelain")) })
102 binary := filepath.Join(t.TempDir(), "specter-old")
103 build := exec.Command("go", "build", "-tags", "no_mocks", "-ldflags", "-X go.miragespace.co/specter/cmd/client.devApexOverride=dev.con.nect.sh", "-o", binary, ".")
104 build.Dir = old
105 build.Env = append(os.Environ(), "GOTOOLCHAIN=local")
106 out, err := build.CombinedOutput()
107 require.NoError(t, err, string(out))
108 startLightweightServers(t, []int{21968, 21969}, []int{21868, 21869})
109 certs, err := filepath.Abs("../certs")
110 require.NoError(t, err)
111 oldLogs, _ := startCompatBinary(t, binary, syscall.SIGTERM, "server", "--cert-dir", certs, "--data-dir", t.TempDir(), "--listen", "127.0.0.1:21973", "--listen-http", "21873", "--apex", serverApex, "--join", "127.0.0.1:21968")
112 require.Eventually(t, func() bool {
113 count := 0
114 for _, msg := range oldLogs.messages() {
115 if msg["msg"] == "specter server started" || msg["msg"] == "gateway server started" {
116 count++
117 }
118 }
119 return count == 2
120 }, 30*time.Second, 50*time.Millisecond)
121 target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "compat") }))
122 defer target.Close()
123 path := filepath.Join(t.TempDir(), "owner.yaml")
124 require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("version: 2\napex: 127.0.0.1:21973\ntunnels:\n - target: %s\n", target.URL)), 0600))
125 ownerLogs, stopOwner := startCompatBinary(t, binary, syscall.SIGINT, "client", "--insecure", "tunnel", "--config", path)
126 var authority string
127 require.Eventually(t, func() bool {
128 for _, msg := range ownerLogs.messages() {
129 if text, ok := msg["msg"].(string); ok && strings.Contains(text, "published") {
130 if hostname, ok := msg["hostname"].(string); ok {
131 authority = hostname
132 return true
133 }
134 }
135 }
136 return false
137 }, 30*time.Second, 50*time.Millisecond)
138 if !strings.Contains(authority, ".") {
139 authority += "." + serverApex
140 }
141 code, body := fetchLightweight(t, authority, 21968)
142 require.Equal(t, 200, code, body)
143 require.Equal(t, "compat", body)
144 stopOwner()
145 hostname := strings.TrimSuffix(authority, "."+serverApex)
146 output, err := runTokenCLI(t, "mint", "--config", path, hostname)
147 require.ErrorContains(t, err, "server does not support domain tokens")
148 require.Empty(t, output)
149 // The baseline TunnelService lacks OpenEphemeralSession. Classify the missing
150 // method as unsupported without printing an ephemeral URL.
151 time.Sleep(1100 * time.Millisecond)
152 app, _ := compileApp(clientcmd.Generate())
153 app.Metadata["apexOverride"] = serverApex
154 var sessionOutput bytes.Buffer
155 app.Writer = &sessionOutput
156 openCtx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
157 err = app.Run(openCtx, []string{
158 "specter", "client", "--insecure", "expose",
159 "--apex", "127.0.0.1:21973", target.URL,
160 })
161 cancel()
162 require.ErrorContains(t, err, "server 127.0.0.1:21973 does not support lightweight tunnels")
163 require.Empty(t, sessionOutput.String())
164 // The same registered owner now uses an upgraded endpoint in the shared ring.
165 data, err := os.ReadFile(path)
166 require.NoError(t, err)
167 data = bytes.Replace(data, []byte("127.0.0.1:21973"), []byte("127.0.0.1:21968"), 1)
168 require.NoError(t, os.WriteFile(path, data, 0600))
169 output, err = runTokenCLI(t, "mint", "--config", path, hostname)
170 require.NoError(t, err)
171 var minted struct {
172 Token string `json:"token"`
173 }
174 require.NoError(t, json.Unmarshal([]byte(output), &minted))
175 require.NotEmpty(t, minted.Token)
176 tokenPath := filepath.Join(t.TempDir(), "token")
177 require.NoError(t, os.WriteFile(tokenPath, []byte(minted.Token), 0600))
178 logs, _ := startIntegrationApp(t, clientcmd.Generate(), "client", "--insecure", "serve", "--apex", "127.0.0.1:21969", "--token-file", tokenPath, target.URL)
179 require.Equal(t, authority, lightweightAuthority(t, logs))
180 code, body = fetchLightweight(t, authority, 21973)
181 require.Equal(t, 200, code, body)
182 require.Equal(t, "compat", body)
183 t.Logf("Compatibility record: old=%s; old client -> new gateway=PASS; new mint -> old server=UNSUPPORTED; new session -> old server=UNSUPPORTED; new serve -> old gateway=PASS", revision)
184}