Files
ctrld/cmd/cli/control_server_test.go
T
Anthony Wong 8934e09428 feat(cli): keep a log journal on disk with file headers and an upload limit
ctrld keeps a second log file, ctrld-journal.log, next to ctrld.log. The
journal holds all warnings and errors and each event with the field
journal=true. It stays on disk after a start of the service and after a
self-upgrade. Each log file starts with a Log header line. Each log send
upload starts with the same line, made at send time, and has a limit of
about 16 MB.
2026-09-28 16:47:40 +07:00

163 lines
4.4 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"strings"
"testing"
"github.com/Control-D-Inc/ctrld"
)
func TestControlServer(t *testing.T) {
f, err := os.CreateTemp("", "")
if err != nil {
t.Fatal(err)
}
defer os.Remove(f.Name())
f.Close()
s, err := newControlServer(f.Name())
if err != nil {
t.Fatal(err)
}
pattern := "/ping"
respBody := []byte("pong")
s.register(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(respBody)
}))
if err := s.start(); err != nil {
t.Fatal(err)
}
c := newControlClient(f.Name())
resp, err := c.post(pattern, nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("unepxected response code: %d", resp.StatusCode)
}
if ct := resp.Header.Get("content-type"); ct != contentTypeJson {
t.Fatalf("unexpected content type: %s", ct)
}
buf, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(buf, respBody) {
t.Errorf("unexpected response body, want: %q, got: %q", string(respBody), string(buf))
}
if err := s.stop(); err != nil {
t.Fatal(err)
}
}
// jsonStringTestPayload holds every byte class that a JSON string must escape
// and splits a multi-byte character across the chunk boundary, so a chunk that
// ends inside a character is covered.
func jsonStringTestPayload() []byte {
var payload bytes.Buffer
payload.WriteString("quotes \" backslash \\ tab\t return\r newline\n")
payload.Write([]byte{0x00, 0x01, 0x1f})
for payload.Len() < jsonStringChunkSize-1 {
payload.WriteByte('x')
}
payload.WriteString("é日本語 and an emoji 🙂\n")
payload.WriteString("the tail after the boundary\n")
return payload.Bytes()
}
func Test_writeJSONString(t *testing.T) {
payload := jsonStringTestPayload()
var written bytes.Buffer
if err := writeJSONString(&written, bytes.NewReader(payload)); err != nil {
t.Fatalf("writeJSONString: %v", err)
}
var got string
if err := json.Unmarshal(written.Bytes(), &got); err != nil {
t.Fatalf("unmarshal %d bytes: %v", written.Len(), err)
}
if got != string(payload) {
t.Fatalf("the round trip returned %d bytes, want the %d bytes of the payload", len(got), len(payload))
}
}
// newLogViewTestServer serves the log view handler of p on a temporary socket
// and returns a client of it.
func newLogViewTestServer(t *testing.T, p *prog) *controlClient {
t.Helper()
f, err := os.CreateTemp("", "")
if err != nil {
t.Fatalf("temporary socket: %v", err)
}
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
s, err := newControlServer(f.Name())
if err != nil {
t.Fatalf("newControlServer: %v", err)
}
s.register(viewLogsPath, http.HandlerFunc(p.handleLogView))
if err := s.start(); err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() { _ = s.stop() })
return newControlClient(f.Name())
}
func Test_logViewHandler(t *testing.T) {
t.Run("answers the log as one JSON string", func(t *testing.T) {
p := setupMemoryLogReaderTest(t)
line := "a line with \"quotes\", a tab\there and 日本語\n"
if _, err := p.internalLogWriter.Write([]byte(line)); err != nil {
t.Fatalf("write the debug buffer: %v", err)
}
resp, err := newLogViewTestServer(t, p).post(viewLogsPath, nil)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if ct := resp.Header.Get("content-type"); ct != contentTypeJson {
t.Fatalf("content type = %s, want %s", ct, contentTypeJson)
}
var view logViewResponse
if err := json.NewDecoder(resp.Body).Decode(&view); err != nil {
t.Fatalf("decode the answer: %v", err)
}
if !strings.Contains(view.Data, line) {
t.Fatalf("the answer holds %.64q, want the line %q in it", view.Data, line)
}
if !strings.Contains(view.Data, logWriterLogEndMarker) {
t.Fatalf("the answer holds no log end marker: %.64q", view.Data)
}
})
t.Run("answers 301 without a log", func(t *testing.T) {
origSilent, origCdUID := silent, cdUID
t.Cleanup(func() { silent, cdUID = origSilent, origCdUID })
silent, cdUID = false, ""
p := &prog{cfg: &ctrld.Config{}}
resp, err := newLogViewTestServer(t, p).post(viewLogsPath, nil)
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusMovedPermanently)
}
})
}