1
16
17 package v1
18
19 import (
20 "encoding/json"
21 "reflect"
22 "testing"
23 "time"
24
25 "github.com/pkg/errors"
26
27 v1 "k8s.io/api/core/v1"
28 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
29 bootstrapapi "k8s.io/cluster-bootstrap/token/api"
30 )
31
32 func TestMarshalJSON(t *testing.T) {
33 var tests = []struct {
34 bts BootstrapTokenString
35 expected string
36 }{
37 {BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, `"abcdef.abcdef0123456789"`},
38 {BootstrapTokenString{ID: "foo", Secret: "bar"}, `"foo.bar"`},
39 {BootstrapTokenString{ID: "h", Secret: "b"}, `"h.b"`},
40 }
41 for _, rt := range tests {
42 t.Run(rt.bts.ID, func(t *testing.T) {
43 b, err := json.Marshal(rt.bts)
44 if err != nil {
45 t.Fatalf("json.Marshal returned an unexpected error: %v", err)
46 }
47 if string(b) != rt.expected {
48 t.Errorf(
49 "failed BootstrapTokenString.MarshalJSON:\n\texpected: %s\n\t actual: %s",
50 rt.expected,
51 string(b),
52 )
53 }
54 })
55 }
56 }
57
58 func TestUnmarshalJSON(t *testing.T) {
59 var tests = []struct {
60 input string
61 bts *BootstrapTokenString
62 expectedError bool
63 }{
64 {`"f.s"`, &BootstrapTokenString{}, true},
65 {`"abcdef."`, &BootstrapTokenString{}, true},
66 {`"abcdef:abcdef0123456789"`, &BootstrapTokenString{}, true},
67 {`abcdef.abcdef0123456789`, &BootstrapTokenString{}, true},
68 {`"abcdef.abcdef0123456789`, &BootstrapTokenString{}, true},
69 {`"abcdef.ABCDEF0123456789"`, &BootstrapTokenString{}, true},
70 {`"abcdef.abcdef0123456789"`, &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, false},
71 {`"123456.aabbccddeeffgghh"`, &BootstrapTokenString{ID: "123456", Secret: "aabbccddeeffgghh"}, false},
72 }
73 for _, rt := range tests {
74 t.Run(rt.input, func(t *testing.T) {
75 newbts := &BootstrapTokenString{}
76 err := json.Unmarshal([]byte(rt.input), newbts)
77 if (err != nil) != rt.expectedError {
78 t.Errorf("failed BootstrapTokenString.UnmarshalJSON:\n\texpected error: %t\n\t actual error: %v", rt.expectedError, err)
79 } else if !reflect.DeepEqual(rt.bts, newbts) {
80 t.Errorf(
81 "failed BootstrapTokenString.UnmarshalJSON:\n\texpected: %v\n\t actual: %v",
82 rt.bts,
83 newbts,
84 )
85 }
86 })
87 }
88 }
89
90 func TestJSONRoundtrip(t *testing.T) {
91 var tests = []struct {
92 input string
93 bts *BootstrapTokenString
94 }{
95 {`"abcdef.abcdef0123456789"`, nil},
96 {"", &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}},
97 }
98 for _, rt := range tests {
99 t.Run(rt.input, func(t *testing.T) {
100 if err := roundtrip(rt.input, rt.bts); err != nil {
101 t.Errorf("failed BootstrapTokenString JSON roundtrip with error: %v", err)
102 }
103 })
104 }
105 }
106
107 func roundtrip(input string, bts *BootstrapTokenString) error {
108 var b []byte
109 var err error
110 newbts := &BootstrapTokenString{}
111
112 if len(input) > 0 {
113 if err := json.Unmarshal([]byte(input), newbts); err != nil {
114 return errors.Wrap(err, "expected no unmarshal error, got error")
115 }
116 if b, err = json.Marshal(newbts); err != nil {
117 return errors.Wrap(err, "expected no marshal error, got error")
118 }
119 if input != string(b) {
120 return errors.Errorf(
121 "expected token: %s\n\t actual: %s",
122 input,
123 string(b),
124 )
125 }
126 } else {
127 if b, err = json.Marshal(bts); err != nil {
128 return errors.Wrap(err, "expected no marshal error, got error")
129 }
130 if err := json.Unmarshal(b, newbts); err != nil {
131 return errors.Wrap(err, "expected no unmarshal error, got error")
132 }
133 if !reflect.DeepEqual(bts, newbts) {
134 return errors.Errorf(
135 "expected object: %v\n\t actual: %v",
136 bts,
137 newbts,
138 )
139 }
140 }
141 return nil
142 }
143
144 func TestTokenFromIDAndSecret(t *testing.T) {
145 var tests = []struct {
146 bts BootstrapTokenString
147 expected string
148 }{
149 {BootstrapTokenString{ID: "foo", Secret: "bar"}, "foo.bar"},
150 {BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}, "abcdef.abcdef0123456789"},
151 {BootstrapTokenString{ID: "h", Secret: "b"}, "h.b"},
152 }
153 for _, rt := range tests {
154 t.Run(rt.bts.ID, func(t *testing.T) {
155 actual := rt.bts.String()
156 if actual != rt.expected {
157 t.Errorf(
158 "failed BootstrapTokenString.String():\n\texpected: %s\n\t actual: %s",
159 rt.expected,
160 actual,
161 )
162 }
163 })
164 }
165 }
166
167 func TestNewBootstrapTokenString(t *testing.T) {
168 var tests = []struct {
169 token string
170 expectedError bool
171 bts *BootstrapTokenString
172 }{
173 {token: "", expectedError: true, bts: nil},
174 {token: ".", expectedError: true, bts: nil},
175 {token: "1234567890123456789012", expectedError: true, bts: nil},
176 {token: "12345.1234567890123456", expectedError: true, bts: nil},
177 {token: ".1234567890123456", expectedError: true, bts: nil},
178 {token: "123456.", expectedError: true, bts: nil},
179 {token: "123456:1234567890.123456", expectedError: true, bts: nil},
180 {token: "abcdef:1234567890123456", expectedError: true, bts: nil},
181 {token: "Abcdef.1234567890123456", expectedError: true, bts: nil},
182 {token: "123456.AABBCCDDEEFFGGHH", expectedError: true, bts: nil},
183 {token: "123456.AABBCCD-EEFFGGHH", expectedError: true, bts: nil},
184 {token: "abc*ef.1234567890123456", expectedError: true, bts: nil},
185 {token: "abcdef.1234567890123456", expectedError: false, bts: &BootstrapTokenString{ID: "abcdef", Secret: "1234567890123456"}},
186 {token: "123456.aabbccddeeffgghh", expectedError: false, bts: &BootstrapTokenString{ID: "123456", Secret: "aabbccddeeffgghh"}},
187 {token: "abcdef.abcdef0123456789", expectedError: false, bts: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}},
188 {token: "123456.1234560123456789", expectedError: false, bts: &BootstrapTokenString{ID: "123456", Secret: "1234560123456789"}},
189 }
190 for _, rt := range tests {
191 t.Run(rt.token, func(t *testing.T) {
192 actual, err := NewBootstrapTokenString(rt.token)
193 if (err != nil) != rt.expectedError {
194 t.Errorf(
195 "failed NewBootstrapTokenString for the token %q\n\texpected error: %t\n\t actual error: %v",
196 rt.token,
197 rt.expectedError,
198 err,
199 )
200 } else if !reflect.DeepEqual(actual, rt.bts) {
201 t.Errorf(
202 "failed NewBootstrapTokenString for the token %q\n\texpected: %v\n\t actual: %v",
203 rt.token,
204 rt.bts,
205 actual,
206 )
207 }
208 })
209 }
210 }
211
212 func TestNewBootstrapTokenStringFromIDAndSecret(t *testing.T) {
213 var tests = []struct {
214 id, secret string
215 expectedError bool
216 bts *BootstrapTokenString
217 }{
218 {id: "", secret: "", expectedError: true, bts: nil},
219 {id: "1234567890123456789012", secret: "", expectedError: true, bts: nil},
220 {id: "12345", secret: "1234567890123456", expectedError: true, bts: nil},
221 {id: "", secret: "1234567890123456", expectedError: true, bts: nil},
222 {id: "123456", secret: "", expectedError: true, bts: nil},
223 {id: "Abcdef", secret: "1234567890123456", expectedError: true, bts: nil},
224 {id: "123456", secret: "AABBCCDDEEFFGGHH", expectedError: true, bts: nil},
225 {id: "123456", secret: "AABBCCD-EEFFGGHH", expectedError: true, bts: nil},
226 {id: "abc*ef", secret: "1234567890123456", expectedError: true, bts: nil},
227 {id: "abcdef", secret: "1234567890123456", expectedError: false, bts: &BootstrapTokenString{ID: "abcdef", Secret: "1234567890123456"}},
228 {id: "123456", secret: "aabbccddeeffgghh", expectedError: false, bts: &BootstrapTokenString{ID: "123456", Secret: "aabbccddeeffgghh"}},
229 {id: "abcdef", secret: "abcdef0123456789", expectedError: false, bts: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"}},
230 {id: "123456", secret: "1234560123456789", expectedError: false, bts: &BootstrapTokenString{ID: "123456", Secret: "1234560123456789"}},
231 }
232 for _, rt := range tests {
233 t.Run(rt.id, func(t *testing.T) {
234 actual, err := NewBootstrapTokenStringFromIDAndSecret(rt.id, rt.secret)
235 if (err != nil) != rt.expectedError {
236 t.Errorf(
237 "failed NewBootstrapTokenStringFromIDAndSecret for the token with id %q and secret %q\n\texpected error: %t\n\t actual error: %v",
238 rt.id,
239 rt.secret,
240 rt.expectedError,
241 err,
242 )
243 } else if !reflect.DeepEqual(actual, rt.bts) {
244 t.Errorf(
245 "failed NewBootstrapTokenStringFromIDAndSecret for the token with id %q and secret %q\n\texpected: %v\n\t actual: %v",
246 rt.id,
247 rt.secret,
248 rt.bts,
249 actual,
250 )
251 }
252 })
253 }
254 }
255
256
257 var refTime = time.Date(1970, time.January, 1, 1, 1, 1, 0, time.UTC)
258
259 func TestBootstrapTokenToSecret(t *testing.T) {
260 var tests = []struct {
261 bt *BootstrapToken
262 secret *v1.Secret
263 }{
264 {
265 &BootstrapToken{
266 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
267 Description: "foo",
268 Expires: &metav1.Time{
269 Time: refTime,
270 },
271 Usages: []string{"signing", "authentication"},
272 Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"},
273 },
274 &v1.Secret{
275 ObjectMeta: metav1.ObjectMeta{
276 Name: "bootstrap-token-abcdef",
277 Namespace: "kube-system",
278 },
279 Type: bootstrapapi.SecretTypeBootstrapToken,
280 Data: map[string][]byte{
281 "token-id": []byte("abcdef"),
282 "token-secret": []byte("abcdef0123456789"),
283 "description": []byte("foo"),
284 "expiration": []byte(refTime.Format(time.RFC3339)),
285 "usage-bootstrap-signing": []byte("true"),
286 "usage-bootstrap-authentication": []byte("true"),
287 "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"),
288 },
289 },
290 },
291 }
292 for _, rt := range tests {
293 t.Run(rt.bt.Token.ID, func(t *testing.T) {
294 actual := BootstrapTokenToSecret(rt.bt)
295 if !reflect.DeepEqual(actual, rt.secret) {
296 t.Errorf(
297 "failed BootstrapTokenToSecret():\n\texpected: %v\n\t actual: %v",
298 rt.secret,
299 actual,
300 )
301 }
302 })
303 }
304 }
305
306 func TestBootstrapTokenToSecretRoundtrip(t *testing.T) {
307 var tests = []struct {
308 bt *BootstrapToken
309 }{
310 {
311 &BootstrapToken{
312 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
313 Description: "foo",
314 Expires: &metav1.Time{
315 Time: refTime,
316 },
317 Usages: []string{"authentication", "signing"},
318 Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"},
319 },
320 },
321 }
322 for _, rt := range tests {
323 t.Run(rt.bt.Token.ID, func(t *testing.T) {
324 actual, err := BootstrapTokenFromSecret(BootstrapTokenToSecret(rt.bt))
325 if err != nil {
326 t.Errorf("failed BootstrapToken to Secret roundtrip with error: %v", err)
327 }
328 if !reflect.DeepEqual(actual, rt.bt) {
329 t.Errorf(
330 "failed BootstrapToken to Secret roundtrip:\n\texpected: %v\n\t actual: %v",
331 rt.bt,
332 actual,
333 )
334 }
335 })
336 }
337 }
338
339 func TestEncodeTokenSecretData(t *testing.T) {
340 var tests = []struct {
341 name string
342 bt *BootstrapToken
343 data map[string][]byte
344 }{
345 {
346 "the minimum amount of information needed to be specified",
347 &BootstrapToken{
348 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
349 },
350 map[string][]byte{
351 "token-id": []byte("abcdef"),
352 "token-secret": []byte("abcdef0123456789"),
353 },
354 },
355 {
356 "adds description",
357 &BootstrapToken{
358 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
359 Description: "foo",
360 },
361 map[string][]byte{
362 "token-id": []byte("abcdef"),
363 "token-secret": []byte("abcdef0123456789"),
364 "description": []byte("foo"),
365 },
366 },
367 {
368 "adds ttl",
369 &BootstrapToken{
370 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
371 TTL: &metav1.Duration{
372 Duration: mustParseDuration("2h", t),
373 },
374 },
375 map[string][]byte{
376 "token-id": []byte("abcdef"),
377 "token-secret": []byte("abcdef0123456789"),
378 "expiration": []byte(refTime.Add(mustParseDuration("2h", t)).Format(time.RFC3339)),
379 },
380 },
381 {
382 "adds expiration",
383 &BootstrapToken{
384 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
385 Expires: &metav1.Time{
386 Time: refTime,
387 },
388 },
389 map[string][]byte{
390 "token-id": []byte("abcdef"),
391 "token-secret": []byte("abcdef0123456789"),
392 "expiration": []byte(refTime.Format(time.RFC3339)),
393 },
394 },
395 {
396 "adds ttl and expiration, should favor expiration",
397 &BootstrapToken{
398 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
399 TTL: &metav1.Duration{
400 Duration: mustParseDuration("2h", t),
401 },
402 Expires: &metav1.Time{
403 Time: refTime,
404 },
405 },
406 map[string][]byte{
407 "token-id": []byte("abcdef"),
408 "token-secret": []byte("abcdef0123456789"),
409 "expiration": []byte(refTime.Format(time.RFC3339)),
410 },
411 },
412 {
413 "adds usages",
414 &BootstrapToken{
415 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
416 Usages: []string{"authentication", "signing"},
417 },
418 map[string][]byte{
419 "token-id": []byte("abcdef"),
420 "token-secret": []byte("abcdef0123456789"),
421 "usage-bootstrap-signing": []byte("true"),
422 "usage-bootstrap-authentication": []byte("true"),
423 },
424 },
425 {
426 "adds groups",
427 &BootstrapToken{
428 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
429 Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"},
430 },
431 map[string][]byte{
432 "token-id": []byte("abcdef"),
433 "token-secret": []byte("abcdef0123456789"),
434 "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"),
435 },
436 },
437 {
438 "all together",
439 &BootstrapToken{
440 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
441 Description: "foo",
442 TTL: &metav1.Duration{
443 Duration: mustParseDuration("2h", t),
444 },
445 Expires: &metav1.Time{
446 Time: refTime,
447 },
448 Usages: []string{"authentication", "signing"},
449 Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"},
450 },
451 map[string][]byte{
452 "token-id": []byte("abcdef"),
453 "token-secret": []byte("abcdef0123456789"),
454 "description": []byte("foo"),
455 "expiration": []byte(refTime.Format(time.RFC3339)),
456 "usage-bootstrap-signing": []byte("true"),
457 "usage-bootstrap-authentication": []byte("true"),
458 "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"),
459 },
460 },
461 }
462 for _, rt := range tests {
463 t.Run(rt.name, func(t *testing.T) {
464 actual := encodeTokenSecretData(rt.bt, refTime)
465 if !reflect.DeepEqual(actual, rt.data) {
466 t.Errorf(
467 "failed encodeTokenSecretData:\n\texpected: %v\n\t actual: %v",
468 rt.data,
469 actual,
470 )
471 }
472 })
473 }
474 }
475
476 func mustParseDuration(durationStr string, t *testing.T) time.Duration {
477 d, err := time.ParseDuration(durationStr)
478 if err != nil {
479 t.Fatalf("couldn't parse duration %q: %v", durationStr, err)
480 }
481 return d
482 }
483
484 func TestBootstrapTokenFromSecret(t *testing.T) {
485 var tests = []struct {
486 desc string
487 name string
488 data map[string][]byte
489 bt *BootstrapToken
490 expectedError bool
491 }{
492 {
493 "minimum information",
494 "bootstrap-token-abcdef",
495 map[string][]byte{
496 "token-id": []byte("abcdef"),
497 "token-secret": []byte("abcdef0123456789"),
498 },
499 &BootstrapToken{
500 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
501 },
502 false,
503 },
504 {
505 "invalid token id",
506 "bootstrap-token-abcdef",
507 map[string][]byte{
508 "token-id": []byte("abcdeF"),
509 "token-secret": []byte("abcdef0123456789"),
510 },
511 nil,
512 true,
513 },
514 {
515 "invalid secret naming",
516 "foo",
517 map[string][]byte{
518 "token-id": []byte("abcdef"),
519 "token-secret": []byte("abcdef0123456789"),
520 },
521 nil,
522 true,
523 },
524 {
525 "invalid token secret",
526 "bootstrap-token-abcdef",
527 map[string][]byte{
528 "token-id": []byte("abcdef"),
529 "token-secret": []byte("ABCDEF0123456789"),
530 },
531 nil,
532 true,
533 },
534 {
535 "adds description",
536 "bootstrap-token-abcdef",
537 map[string][]byte{
538 "token-id": []byte("abcdef"),
539 "token-secret": []byte("abcdef0123456789"),
540 "description": []byte("foo"),
541 },
542 &BootstrapToken{
543 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
544 Description: "foo",
545 },
546 false,
547 },
548 {
549 "adds expiration",
550 "bootstrap-token-abcdef",
551 map[string][]byte{
552 "token-id": []byte("abcdef"),
553 "token-secret": []byte("abcdef0123456789"),
554 "expiration": []byte(refTime.Format(time.RFC3339)),
555 },
556 &BootstrapToken{
557 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
558 Expires: &metav1.Time{
559 Time: refTime,
560 },
561 },
562 false,
563 },
564 {
565 "invalid expiration",
566 "bootstrap-token-abcdef",
567 map[string][]byte{
568 "token-id": []byte("abcdef"),
569 "token-secret": []byte("abcdef0123456789"),
570 "expiration": []byte("invalid date"),
571 },
572 nil,
573 true,
574 },
575 {
576 "adds usages",
577 "bootstrap-token-abcdef",
578 map[string][]byte{
579 "token-id": []byte("abcdef"),
580 "token-secret": []byte("abcdef0123456789"),
581 "usage-bootstrap-signing": []byte("true"),
582 "usage-bootstrap-authentication": []byte("true"),
583 },
584 &BootstrapToken{
585 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
586 Usages: []string{"authentication", "signing"},
587 },
588 false,
589 },
590 {
591 "should ignore usages that aren't set to true",
592 "bootstrap-token-abcdef",
593 map[string][]byte{
594 "token-id": []byte("abcdef"),
595 "token-secret": []byte("abcdef0123456789"),
596 "usage-bootstrap-signing": []byte("true"),
597 "usage-bootstrap-authentication": []byte("true"),
598 "usage-bootstrap-foo": []byte("false"),
599 "usage-bootstrap-bar": []byte(""),
600 },
601 &BootstrapToken{
602 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
603 Usages: []string{"authentication", "signing"},
604 },
605 false,
606 },
607 {
608 "adds groups",
609 "bootstrap-token-abcdef",
610 map[string][]byte{
611 "token-id": []byte("abcdef"),
612 "token-secret": []byte("abcdef0123456789"),
613 "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"),
614 },
615 &BootstrapToken{
616 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
617 Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"},
618 },
619 false,
620 },
621 {
622 "all fields set",
623 "bootstrap-token-abcdef",
624 map[string][]byte{
625 "token-id": []byte("abcdef"),
626 "token-secret": []byte("abcdef0123456789"),
627 "description": []byte("foo"),
628 "expiration": []byte(refTime.Format(time.RFC3339)),
629 "usage-bootstrap-signing": []byte("true"),
630 "usage-bootstrap-authentication": []byte("true"),
631 "auth-extra-groups": []byte("system:bootstrappers,system:bootstrappers:foo"),
632 },
633 &BootstrapToken{
634 Token: &BootstrapTokenString{ID: "abcdef", Secret: "abcdef0123456789"},
635 Description: "foo",
636 Expires: &metav1.Time{
637 Time: refTime,
638 },
639 Usages: []string{"authentication", "signing"},
640 Groups: []string{"system:bootstrappers", "system:bootstrappers:foo"},
641 },
642 false,
643 },
644 }
645 for _, rt := range tests {
646 t.Run(rt.desc, func(t *testing.T) {
647 actual, err := BootstrapTokenFromSecret(&v1.Secret{
648 ObjectMeta: metav1.ObjectMeta{
649 Name: rt.name,
650 Namespace: "kube-system",
651 },
652 Type: bootstrapapi.SecretTypeBootstrapToken,
653 Data: rt.data,
654 })
655 if (err != nil) != rt.expectedError {
656 t.Errorf(
657 "failed BootstrapTokenFromSecret\n\texpected error: %t\n\t actual error: %v",
658 rt.expectedError,
659 err,
660 )
661 } else {
662 if actual == nil && rt.bt == nil {
663
664 return
665 }
666
667 if (actual == nil && rt.bt != nil) || (actual != nil && rt.bt == nil) || !reflect.DeepEqual(*actual, *rt.bt) {
668 t.Errorf(
669 "failed BootstrapTokenFromSecret\n\texpected: %s\n\t actual: %s",
670 jsonMarshal(rt.bt),
671 jsonMarshal(actual),
672 )
673 }
674 }
675 })
676 }
677 }
678
679 func jsonMarshal(bt *BootstrapToken) string {
680 b, _ := json.Marshal(*bt)
681 return string(b)
682 }
683
View as plain text