Files

188 lines
6.3 KiB
Go

package cli
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"server-deploy/internal/planner"
)
func fixture() string {
return `{"protocolVersion":1,"hostId":"host-one","instanceId":"git-one","appId":"gitea","packageDigest":"sha256:` + strings.Repeat("a", 64) + `","imageDigest":"sha256:` + strings.Repeat("b", 64) + `","domain":"git.example.com","observedStateDigest":"sha256:` + strings.Repeat("c", 64) + `"}`
}
func run(args []string, input string) (int, string, string) {
var out, diagnostic bytes.Buffer
code := Run(args, strings.NewReader(input), &out, &diagnostic, func() time.Time { return time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC) })
return code, out.String(), diagnostic.String()
}
func TestPlanAndVerify(t *testing.T) {
code, output, diagnostic := run([]string{"plan"}, fixture())
if code != 0 || diagnostic != "" {
t.Fatalf("plan failed: %d %s", code, diagnostic)
}
var response struct {
ProtocolVersion int `json:"protocolVersion"`
Mode string `json:"mode"`
Executable bool `json:"executable"`
Plan planner.Plan `json:"plan"`
}
if err := json.Unmarshal([]byte(output), &response); err != nil {
t.Fatal(err)
}
if response.Mode != "offline-preview" || response.Executable || response.ProtocolVersion != 1 || response.Plan.ProjectName != "sd-git-one" {
t.Fatalf("misleading plan: %s", output)
}
request, _ := json.Marshal(struct {
Plan planner.Plan `json:"plan"`
Current planner.Intent `json:"current"`
}{response.Plan, response.Plan.Intent})
code, output, diagnostic = run([]string{"verify-plan"}, string(request))
if code != 0 || diagnostic != "" || !strings.Contains(output, `"valid":true`) {
t.Fatalf("verify failed: %d %s %s", code, output, diagnostic)
}
response.Plan.Hash = "tampered"
request, _ = json.Marshal(struct {
Plan planner.Plan `json:"plan"`
Current planner.Intent `json:"current"`
}{response.Plan, response.Plan.Intent})
code, _, _ = run([]string{"verify-plan"}, string(request))
if code == 0 {
t.Fatal("accepted tampered plan")
}
}
func TestRejectsAmbiguousAndOversizedInput(t *testing.T) {
cases := []string{
``, `{}`, `null`, `[]`,
strings.TrimSuffix(fixture(), "}") + `,"password":"do-not-echo"}`,
strings.TrimSuffix(fixture(), "}") + `,"hostId":"other"}`,
strings.TrimSuffix(fixture(), "}") + `,"HostId":"other"}`,
strings.Replace(fixture(), `"hostId"`, `"HostId"`, 1),
strings.Replace(fixture(), `"protocolVersion":1`, `"protocolVersion":null`, 1),
fixture() + ` {}`, fixture() + ` trailing`,
strings.Repeat(" ", 1024*1024) + fixture(),
}
for _, input := range cases {
code, out, diagnostic := run([]string{"plan"}, input)
if code == 0 || out != "" || diagnostic == "" {
t.Fatalf("invalid input accepted: code=%d", code)
}
if strings.Contains(diagnostic, "do-not-echo") {
t.Fatal("secret leaked")
}
}
}
func TestRejectsWriteCommandsAndUnexpectedArguments(t *testing.T) {
for _, args := range [][]string{nil, {"apply"}, {"upgrade"}, {"restore"}, {"plan", "extra"}, {"version", "extra"}} {
code, out, _ := run(args, fixture())
if code == 0 || out != "" {
t.Fatalf("accepted command %v", args)
}
}
}
func TestVersion(t *testing.T) {
code, out, diagnostic := run([]string{"version"}, "")
if code != 0 || diagnostic != "" || !json.Valid([]byte(out)) {
t.Fatal("version failed")
}
}
func TestInspectIsReadOnlyAndExplicitlyIncomplete(t *testing.T) {
code, out, diagnostics := run([]string{"inspect"}, "")
if code != 0 || diagnostics != "" {
t.Fatalf("inspect failed: %s", diagnostics)
}
var report struct {
ProtocolVersion int `json:"protocolVersion"`
OS string `json:"os"`
DeploymentReady bool `json:"deploymentReady"`
DockerDaemon string `json:"dockerDaemon"`
}
if err := json.Unmarshal([]byte(out), &report); err != nil {
t.Fatal(err)
}
if report.ProtocolVersion != 1 || report.OS == "" || report.DeploymentReady || report.DockerDaemon != "not_checked" {
t.Fatalf("misleading report: %s", out)
}
}
func TestNestedVerificationRejectsDuplicateAndNullFields(t *testing.T) {
_, output, _ := run([]string{"plan"}, fixture())
var response struct {
Plan json.RawMessage `json:"plan"`
}
if err := json.Unmarshal([]byte(output), &response); err != nil {
t.Fatal(err)
}
valid := `{"plan":` + string(response.Plan) + `,"current":` + fixture() + `}`
for _, input := range []string{
strings.Replace(valid, `"projectName":"sd-git-one"`, `"projectName":"sd-git-one","projectName":"sd-git-one"`, 1),
strings.Replace(valid, `"createdAt":"2026-09-25T12:00:00Z"`, `"createdAt":null`, 1),
strings.Replace(valid, `"intent":`, `"Intent":`, 1),
} {
code, out, _ := run([]string{"verify-plan"}, input)
if code == 0 || out != "" {
t.Fatal("accepted ambiguous nested request")
}
}
}
func TestVerificationRequiresCanonicalUTCTimestamps(t *testing.T) {
_, output, _ := run([]string{"plan"}, fixture())
var response struct {
Plan json.RawMessage `json:"plan"`
}
if err := json.Unmarshal([]byte(output), &response); err != nil {
t.Fatal(err)
}
valid := `{"plan":` + string(response.Plan) + `,"current":` + fixture() + `}`
for _, timestamp := range []string{
"2026-09-25T12:00:00,000Z",
"2026-09-25T12:00:00.000Z",
"2026-09-25T12:00:00+00:00",
"2026-09-26T12:00:00+24:00",
"2026-09-25T13:00:00+00:60",
} {
t.Run(timestamp, func(t *testing.T) {
input := strings.Replace(valid, "2026-09-25T12:00:00Z", timestamp, 1)
code, out, _ := run([]string{"verify-plan"}, input)
if code == 0 || out != "" {
t.Fatal("accepted noncanonical timestamp")
}
})
}
}
type brokenReader struct{}
func (brokenReader) Read([]byte) (int, error) { return 0, errors.New("secret reader error") }
type brokenWriter struct{}
func (brokenWriter) Write([]byte) (int, error) { return 0, errors.New("secret writer error") }
func TestIOErrorsFailWithoutLeakingDetails(t *testing.T) {
var out, diagnostics bytes.Buffer
if Run([]string{"plan"}, brokenReader{}, &out, &diagnostics, time.Now) == 0 {
t.Fatal("ignored read error")
}
if out.Len() != 0 || strings.Contains(diagnostics.String(), "secret") {
t.Fatal("leaked input error")
}
diagnostics.Reset()
if Run([]string{"version"}, strings.NewReader(""), brokenWriter{}, &diagnostics, time.Now) == 0 {
t.Fatal("ignored write error")
}
if strings.Contains(diagnostics.String(), "secret") {
t.Fatal("leaked output error")
}
}