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