v3.3.1 31f1eec

admin_apps_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close