1package dto
2
3// Frozen-wire tests for the v3 app admin DTOs pin the exact JSON field
4// names the daemon and the CLI exchange, and prove no response shape can
5// carry secret values. The single intentional exception is
6// AppSecretSetRequest: the write path must transport values.
7
8import (
9 "encoding/json"
10 "strings"
11 "testing"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15)
16
17func dtoJSONKeys(t *testing.T, v any) map[string]any {
18 t.Helper()
19 raw, err := json.Marshal(v)
20 require.NoError(t, err)
21 var decoded map[string]any
22 require.NoError(t, json.Unmarshal(raw, &decoded))
23 return decoded
24}
25
26func TestAppErrorEnvelopeKeys(t *testing.T) {
27 envelope := AppError{Error: "secret-missing", Message: "db password missing", Cause: "pass lookup failed", Hint: "set it first"}
28 keys := dtoJSONKeys(t, envelope)
29 assert.Equal(t, "secret-missing", keys["error"])
30 assert.Equal(t, "db password missing", keys["message"])
31 assert.Equal(t, "pass lookup failed", keys["cause"])
32 assert.Equal(t, "set it first", keys["hint"])
33 assert.NotContains(t, keys, "logs", "mutations must never carry logs")
34
35 // Mixed-version window: the legacy single-field shape still decodes.
36 var legacy AppError
37 require.NoError(t, json.Unmarshal([]byte(`{"error":"image-unresolvable"}`), &legacy))
38 assert.Equal(t, "image-unresolvable", legacy.Error)
39 assert.Empty(t, legacy.Message)
40}
41
42func TestAppApplyResponseKeys(t *testing.T) {
43 resp := AppApplyResponse{
44 App: "blog",
45 FormerRevision: "rev-old",
46 ResultingRevision: "rev-new",
47 Pending: true,
48 Diff: AppDiffSection{Added: []string{"service.web"}, Removed: []string{}, Changed: []string{}},
49 Intent: "apply-abc",
50 }
51 keys := dtoJSONKeys(t, resp)
52 for _, key := range []string{"app", "former_revision", "resulting_revision", "pending", "diff", "intent"} {
53 assert.Contains(t, keys, key)
54 }
55 diff := keys["diff"].(map[string]any)
56 for _, key := range []string{"added", "removed", "changed"} {
57 assert.Contains(t, diff, key)
58 }
59}
60
61func TestAppDeployResponseKeys(t *testing.T) {
62 resp := AppDeployResponse{
63 Op: "op-abc",
64 App: "blog",
65 Revision: "rev-new",
66 Status: AppStatusRunning,
67 Outcome: "success",
68 Services: map[string]AppServiceResultDTO{
69 "web": {Result: "deployed", EffectiveRevision: "rev-new"},
70 },
71 Steps: []AppStepDTO{{ID: "service.web.replace", State: "succeeded"}},
72 CleanupWarnings: []AppCleanupWarningDTO{
73 {Service: "web", Leftover: "ctr-old", Detail: "retire failed after publish"},
74 },
75 Effective: &AppEffectiveDTO{Converged: true, Services: map[string]string{"web": "rev-new"}},
76 Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},
77 }
78 keys := dtoJSONKeys(t, resp)
79 for _, key := range []string{"op", "app", "revision", "status", "outcome", "services", "steps", "cleanup_warnings", "effective", "retained"} {
80 assert.Contains(t, keys, key)
81 }
82 svc := keys["services"].(map[string]any)["web"].(map[string]any)
83 for _, key := range []string{"result", "effective_revision", "restart_unsafe"} {
84 assert.Contains(t, svc, key)
85 }
86}
87
88func TestAppShowResponseKeys(t *testing.T) {
89 resp := AppShowResponse{
90 App: "blog",
91 Desired: AppDesiredDTO{Revision: "rev-new", Status: "pending", Pending: true},
92 Active: AppActiveDTO{Converged: false, Services: map[string]AppActiveServiceDTO{
93 "web": {EffectiveRevision: "rev-old", Digest: "sha256:abc", Container: "ctr-old", RestartUnsafe: true},
94 }},
95 Intent: AppIntentDTO{Stopped: false},
96 LastOp: &AppLastOpDTO{Op: "op-abc", Outcome: "success"},
97 }
98 keys := dtoJSONKeys(t, resp)
99 for _, key := range []string{"app", "desired", "active", "intent", "retained", "last_op"} {
100 assert.Contains(t, keys, key)
101 }
102 web := keys["active"].(map[string]any)["services"].(map[string]any)["web"].(map[string]any)
103 for _, key := range []string{"effective_revision", "digest", "container", "restart_unsafe"} {
104 assert.Contains(t, web, key)
105 }
106 assert.NotContains(t, web, "env", "active services must not inline env values")
107}
108
109// TestAppResponsesCarryNoSecretValues marshals representative responses and
110// proves a sentinel secret value appears nowhere. The request that carries
111// values (AppSecretSetRequest) is asserted as the single exception.
112func TestAppResponsesCarryNoSecretValues(t *testing.T) {
113 const sentinel = "s3cr3t-value-sentinel"
114
115 responses := []any{
116 AppApplyResponse{App: "blog", ResultingRevision: "rev-x", Intent: "apply-x"},
117 AppDeployResponse{
118 Op: "op-x", App: "blog", Revision: "rev-x", Outcome: "failed",
119 Services: map[string]AppServiceResultDTO{
120 "web": {Result: "failed", EffectiveRevision: "rev-x", Error: "boom"},
121 },
122 Effective: &AppEffectiveDTO{Services: map[string]string{"web": "rev-x"}},
123 Retained: &AppRetainedDTO{Volumes: []string{"blog-db"}, Secrets: []string{"db/password"}},
124 },
125 AppShowResponse{
126 App: "blog",
127 Desired: AppDesiredDTO{Revision: "rev-x", Status: "active"},
128 Active: AppActiveDTO{Services: map[string]AppActiveServiceDTO{
129 "web": {EffectiveRevision: "rev-x", Digest: "sha256:x", Container: "ctr-x"},
130 }},
131 },
132 AppSummaryDTO{App: "blog", Desired: "rev-x", Active: "rev-x"},
133 AppDiffResponse{App: "blog", Diff: AppDiffSection{Changed: []string{"secret.db/password"}}},
134 }
135 for i, resp := range responses {
136 raw, err := json.Marshal(resp)
137 require.NoError(t, err, "response %d", i)
138 body := string(raw)
139 assert.NotContains(t, body, sentinel, "response %d leaks a secret value", i)
140 assert.NotContains(t, strings.ToLower(body), `"value"`, "response %d has a value field", i)
141 }
142
143 // The one intentional exception: the set-secret request transports values.
144 setReq := AppSecretSetRequest{Service: "web", Secrets: map[string]string{"password": sentinel}}
145 raw, err := json.Marshal(setReq)
146 require.NoError(t, err)
147 assert.Contains(t, string(raw), sentinel)
148}