-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathapiKey_test.go
More file actions
452 lines (412 loc) · 17.1 KB
/
Copy pathapiKey_test.go
File metadata and controls
452 lines (412 loc) · 17.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/maxcnunes/httpfake"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
// apiKeyFixture reads an api-key response fixture from testdata/service-account.
// The fixtures hold the canonical API response bodies so the response contract
// lives in one place (see the README there).
func apiKeyFixture(t *testing.T, name string) string {
t.Helper()
body, err := os.ReadFile(filepath.Join("testdata", "service-account", name))
require.NoError(t, err, "failed to read fixture %s", name)
return string(body)
}
func TestPrintApiKeyAsTable(t *testing.T) {
// The API returns timestamps as floating-point epoch seconds (with a
// fractional part), so the response struct/table rendering must accept them.
raw := `{"id":"key-1","key":"sk_secret_value","description":"ci key","created_at":1780584129.6878593,"expires_at":0,"grace_period_expires_at":1780670529.5}`
var buf bytes.Buffer
err := printApiKeyAsTable(raw, &buf, 0)
require.NoError(t, err)
out := buf.String()
require.Contains(t, out, "key-1")
require.Contains(t, out, "sk_secret_value")
require.Contains(t, out, "ci key")
require.Contains(t, out, "Old Key Valid Until")
// expires_at of 0 means "no expiry" and must render as N/A, not epoch zero
require.Regexp(t, `Expires At:\s+N/A`, out)
require.NotContains(t, out, "1970")
}
func TestPrintApiKeysAsTable(t *testing.T) {
// The rotate command aggregates one or more rotated keys into a JSON array.
raw := `[{"id":"key-1","key":"sk_one","description":"first","created_at":1780584129.5,"expires_at":0},` +
`{"id":"key-2","key":"sk_two","description":"second","created_at":1780584130.5,"expires_at":0}]`
var buf bytes.Buffer
err := printApiKeysAsTable(raw, &buf, 0)
require.NoError(t, err)
out := buf.String()
require.Contains(t, out, "key-1")
require.Contains(t, out, "sk_one")
require.Contains(t, out, "key-2")
require.Contains(t, out, "sk_two")
}
func TestPrintApiKeysListAsTable(t *testing.T) {
// The list endpoint returns key metadata only (no secret key value).
raw := `[{"id":"key-1","description":"first","created_at":1780584129.5,"expires_at":0,"last_used_at":0},` +
`{"id":"key-2","description":"second","created_at":1780584130.5,"expires_at":0,"last_used_at":0}]`
var buf bytes.Buffer
err := printApiKeysListAsTable(raw, &buf, 0)
require.NoError(t, err)
out := buf.String()
for _, want := range []string{"ID", "DESCRIPTION", "CREATED", "EXPIRES", "LAST USED", "key-1", "first", "key-2", "second"} {
require.Contains(t, out, want)
}
// expires_at and last_used_at of 0 must render as N/A, not epoch zero (1970)
require.Contains(t, out, "N/A")
require.NotContains(t, out, "1970")
}
func TestParseExpiresAt(t *testing.T) {
tests := []struct {
name string
input string
want int64
wantErr bool
}{
{name: "empty returns zero", input: "", want: 0},
{name: "bare epoch is passed through", input: "1798675200", want: 1798675200},
{name: "date only is parsed as UTC midnight", input: "2026-06-04", want: time.Date(2026, 6, 4, 0, 0, 0, 0, time.UTC).Unix()},
{name: "date with unpadded day/month is parsed", input: "2026-6-5", want: time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).Unix()},
{name: "date and time is parsed as UTC", input: "2026-06-04 15:04:05", want: time.Date(2026, 6, 4, 15, 4, 5, 0, time.UTC).Unix()},
{name: "RFC3339 is parsed", input: "2026-06-04T15:04:05Z", want: time.Date(2026, 6, 4, 15, 4, 5, 0, time.UTC).Unix()},
{name: "invalid value errors", input: "not-a-date", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseExpiresAt(tt.input)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
// Define the suite, and absorb the built-in basic suite functionality from testify.
type ApiKeyCommandTestSuite struct {
suite.Suite
defaultKosliArguments string
}
func (suite *ApiKeyCommandTestSuite) SetupTest() {
global = &GlobalOpts{
ApiToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6ImNkNzg4OTg5In0.e8i_lA_QrEhFncb05Xw6E_tkCHU9QfcY4OLTVUCHffY",
Org: "docs-cmd-test-user",
Host: "http://localhost:8001",
}
suite.defaultKosliArguments = fmt.Sprintf(" --host %s --org %s --api-token %s", global.Host, global.Org, global.ApiToken)
}
func (suite *ApiKeyCommandTestSuite) TestCreateApiKeyCmd() {
tests := []cmdTestCase{
{
wantError: false,
name: "create builds the right url and payload (dry-run)",
cmd: "create api-key --service-account test-sa --description 'ci key' --dry-run" + suite.defaultKosliArguments,
goldenRegex: `(?s)service-accounts/docs-cmd-test-user/test-sa/api-keys.*"description": "ci key"`,
},
{
wantError: false,
name: "create with a date --expires-at converts to an epoch timestamp (dry-run)",
cmd: "create api-key --service-account test-sa --description 'ci key' --expires-at 2026-12-31 --dry-run" + suite.defaultKosliArguments,
goldenRegex: `(?s)"description": "ci key".*"expires_at": 1798675200`,
},
{
wantError: false,
name: "the api-key alias (ak) and -s shorthand work",
cmd: "create ak -s test-sa --description 'ci key' --dry-run" + suite.defaultKosliArguments,
goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys`,
},
{
wantError: true,
name: "create fails when --service-account is missing",
cmd: "create api-key --description 'ci key'" + suite.defaultKosliArguments,
golden: "Error: required flag(s) \"service-account\" not set\n",
},
{
wantError: true,
name: "create fails when --description is missing",
cmd: "create api-key --service-account test-sa" + suite.defaultKosliArguments,
golden: "Error: required flag(s) \"description\" not set\n",
},
{
wantError: true,
name: "create fails with an invalid --expires-at value",
cmd: "create api-key --service-account test-sa --description 'ci key' --expires-at not-a-date --dry-run" + suite.defaultKosliArguments,
goldenRegex: `Error: invalid --expires-at value`,
},
}
runTestCmd(suite.T(), tests)
}
func (suite *ApiKeyCommandTestSuite) TestRotateApiKeyCmd() {
tests := []cmdTestCase{
{
wantError: false,
name: "rotate without --grace-period-hours sends an empty payload (server owns the default)",
cmd: "rotate api-key key-123 --service-account test-sa --dry-run" + suite.defaultKosliArguments,
goldenRegex: `(?s)service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123/rotate.*real run:\s*\{\}`,
},
{
wantError: false,
name: "rotate honours a custom --grace-period-hours (dry-run)",
cmd: "rotate api-key key-123 --service-account test-sa --grace-period-hours 48 --dry-run" + suite.defaultKosliArguments,
goldenRegex: `"grace_period_hours": 48`,
},
{
wantError: false,
name: "the api-key alias (ak) and -s shorthand work",
cmd: "rotate ak key-123 -s test-sa --dry-run" + suite.defaultKosliArguments,
goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123/rotate`,
},
{
wantError: false,
name: "the -g and -e shorthands work (dry-run)",
cmd: "rotate ak key-123 -s test-sa -g 1 -e 2026-6-5 --dry-run" + suite.defaultKosliArguments,
goldenRegex: `(?s)"grace_period_hours": 1.*"expires_at": 1780617600`,
},
{
wantError: false,
name: "rotate accepts multiple KEY-IDs (dry-run)",
cmd: "rotate api-key key-1 key-2 --service-account test-sa --dry-run" + suite.defaultKosliArguments,
goldenRegex: `(?s)api-keys/key-1/rotate.*api-keys/key-2/rotate`,
},
{
wantError: true,
name: "rotate fails when KEY-ID argument is missing",
cmd: "rotate api-key --service-account test-sa" + suite.defaultKosliArguments,
golden: "Error: requires at least 1 arg(s), only received 0\n",
},
{
wantError: true,
name: "rotate fails when --service-account is missing",
cmd: "rotate api-key key-123" + suite.defaultKosliArguments,
golden: "Error: required flag(s) \"service-account\" not set\n",
},
}
runTestCmd(suite.T(), tests)
}
func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyCmd() {
tests := []cmdTestCase{
{
wantError: false,
name: "delete without confirmation (empty stdin) is cancelled and makes no call",
cmd: "delete api-key key-123 --service-account test-sa" + suite.defaultKosliArguments,
golden: "Are you sure you want to delete API key(s) key-123 for service account test-sa? [y/N] Deletion of API key(s) key-123 was cancelled.\n",
},
{
wantError: false,
name: "delete accepts multiple KEY-IDs (dry-run)",
cmd: "delete api-key key-1 key-2 --service-account test-sa --assume-yes --dry-run" + suite.defaultKosliArguments,
goldenRegex: `(?s)api-keys/key-1.*api-keys/key-2`,
},
{
wantError: false,
name: "delete with --assume-yes and --dry-run builds the right url",
cmd: "delete api-key key-123 --service-account test-sa --assume-yes --dry-run" + suite.defaultKosliArguments,
goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123`,
},
{
wantError: false,
name: "the --yes alias bypasses confirmation too",
cmd: "delete api-key key-123 --service-account test-sa --yes --dry-run" + suite.defaultKosliArguments,
goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123`,
},
{
wantError: false,
name: "the api-key alias (ak), -s and -y shorthands work",
cmd: "delete ak key-123 -s test-sa -y --dry-run" + suite.defaultKosliArguments,
goldenRegex: `service-accounts/docs-cmd-test-user/test-sa/api-keys/key-123`,
},
{
wantError: true,
name: "delete fails when KEY-ID argument is missing",
cmd: "delete api-key --service-account test-sa" + suite.defaultKosliArguments,
golden: "Error: requires at least 1 arg(s), only received 0\n",
},
{
wantError: true,
name: "delete fails when --service-account is missing",
cmd: "delete api-key key-123" + suite.defaultKosliArguments,
golden: "Error: required flag(s) \"service-account\" not set\n",
},
}
runTestCmd(suite.T(), tests)
}
// TestApiKeysSuccessOutput stubs successful (2xx) API responses to verify that
// create/list/rotate render the server's response on the happy path.
func (suite *ApiKeyCommandTestSuite) TestApiKeysSuccessOutput() {
fake := httpfake.New()
defer fake.Close()
fake.NewHandler().
Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys").
Reply(201).
BodyString(apiKeyFixture(suite.T(), "created_api_key.json"))
fake.NewHandler().
Get("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys").
Reply(200).
BodyString(apiKeyFixture(suite.T(), "listed_api_keys.json"))
fake.NewHandler().
Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k1/rotate").
Reply(201).
BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json"))
fake.NewHandler().
Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate").
Reply(201).
BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json"))
args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken)
tests := []cmdTestCase{
{
wantError: false,
name: "create prints the new key value",
cmd: "create api-key -s test-sa -d ci --output json" + args,
goldenRegex: `sk_created`,
},
{
wantError: false,
name: "list prints the returned keys",
cmd: "list api-keys -s test-sa --output json" + args,
goldenRegex: `id-1`,
},
{
wantError: false,
name: "rotate of multiple keys prints all rotated keys",
cmd: "rotate api-key k1 k2 -s test-sa --output json" + args,
goldenJson: []jsonCheck{
{Path: "", Want: "length:2"},
{Path: "[0].key", Want: "sk_one"},
},
},
}
runTestCmd(suite.T(), tests)
}
// TestUpdatePartialFailure verifies that when one key in a multi-key rotate
// fails, the keys already rotated are still printed (their values are only
// returned once) before the error is surfaced.
func (suite *ApiKeyCommandTestSuite) TestUpdatePartialFailure() {
fake := httpfake.New()
defer fake.Close()
fake.NewHandler().
Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k1/rotate").
Reply(201).
BodyString(apiKeyFixture(suite.T(), "rotated_api_key.json"))
fake.NewHandler().
Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2/rotate").
Reply(404).
BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json"))
args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken)
tests := []cmdTestCase{
{
wantError: true,
name: "rotate prints already-rotated keys then surfaces the error",
cmd: "rotate api-key k1 k2 -s test-sa --output json" + args,
goldenRegex: `(?s)sk_one.*Error: failed to rotate API key: API key not found`,
},
}
runTestCmd(suite.T(), tests)
}
// TestDeletePartialFailure verifies that when one key in a multi-key delete
// fails, the keys already deleted are reported (deletion is destructive and
// one-way) before the error is surfaced.
func (suite *ApiKeyCommandTestSuite) TestDeletePartialFailure() {
fake := httpfake.New()
defer fake.Close()
fake.NewHandler().
Delete("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k1").
Reply(200).
BodyString(apiKeyFixture(suite.T(), "revoke_success.json"))
fake.NewHandler().
Delete("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/k2").
Reply(404).
BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json"))
args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken)
tests := []cmdTestCase{
{
wantError: true,
name: "delete reports deleted keys before a later key fails",
cmd: "delete api-key k1 k2 -s test-sa --assume-yes" + args,
goldenRegex: `(?s)API key k1 for service account test-sa was deleted!.*already deleted before this failure: k1.*failed to delete API key: API key not found`,
},
}
runTestCmd(suite.T(), tests)
}
// TestDeleteApiKeyNotFound stubs the API with a 404 to verify that deleting a
// non-existing key surfaces the server's "API key not found" error instead of
// reporting success.
func (suite *ApiKeyCommandTestSuite) TestDeleteApiKeyNotFound() {
fake := httpfake.New()
defer fake.Close()
fake.NewHandler().
Delete("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/missing-key").
Reply(404).
BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json"))
args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken)
tests := []cmdTestCase{
{
wantError: true,
name: "delete surfaces a 404 from the API as an error",
cmd: "delete api-key missing-key --service-account test-sa --assume-yes" + args,
goldenRegex: `(?s)failed to delete API key: API key not found`,
},
}
runTestCmd(suite.T(), tests)
}
// TestApiErrorsAreSurfaced stubs the API with 4xx responses to verify that
// create/rotate/list surface the server's error message instead of succeeding.
func (suite *ApiKeyCommandTestSuite) TestApiErrorsAreSurfaced() {
fake := httpfake.New()
defer fake.Close()
fake.NewHandler().
Post("/api/v2/service-accounts/docs-cmd-test-user/missing-sa/api-keys").
Reply(404).
BodyString(apiKeyFixture(suite.T(), "error_service_account_not_found.json"))
fake.NewHandler().
Post("/api/v2/service-accounts/docs-cmd-test-user/test-sa/api-keys/missing-key/rotate").
Reply(404).
BodyString(apiKeyFixture(suite.T(), "error_api_key_not_found.json"))
fake.NewHandler().
Get("/api/v2/service-accounts/docs-cmd-test-user/missing-sa/api-keys").
Reply(403).
BodyString(apiKeyFixture(suite.T(), "error_forbidden.json"))
args := fmt.Sprintf(" --host %s --org %s --api-token %s", fake.Server.URL, global.Org, global.ApiToken)
tests := []cmdTestCase{
{
wantError: true,
name: "create surfaces a 404 from the API as an error",
cmd: "create api-key --service-account missing-sa --description x" + args,
goldenRegex: `Error: Service account not found`,
},
{
wantError: true,
name: "rotate surfaces a 404 from the API as an error",
cmd: "rotate api-key missing-key --service-account test-sa" + args,
goldenRegex: `Error: failed to rotate API key: API key not found`,
},
{
wantError: true,
name: "list surfaces a 403 from the API as an error",
cmd: "list api-keys --service-account missing-sa" + args,
goldenRegex: `Error: You don't have permission to access this resource`,
},
}
runTestCmd(suite.T(), tests)
}
func (suite *ApiKeyCommandTestSuite) TestListApiKeysCmd() {
tests := []cmdTestCase{
{
wantError: true,
name: "list fails when --service-account is missing",
cmd: "list api-keys" + suite.defaultKosliArguments,
golden: "Error: required flag(s) \"service-account\" not set\n",
},
}
runTestCmd(suite.T(), tests)
}
func TestApiKeyCommandTestSuite(t *testing.T) {
suite.Run(t, new(ApiKeyCommandTestSuite))
}