Skip to content

Commit 4de1fc8

Browse files
authored
CLI: coder licenses list (#3686)
* Check GET license calls authz Signed-off-by: Spike Curtis <spike@coder.com> * CLI: coder licenses list Signed-off-by: Spike Curtis <spike@coder.com> Signed-off-by: Spike Curtis <spike@coder.com>
1 parent a05fad4 commit 4de1fc8

File tree

6 files changed

+217
-52
lines changed

6 files changed

+217
-52
lines changed

coderd/coderdtest/authtest.go

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,12 @@ import (
88
"strings"
99
"testing"
1010

11-
"github.com/coder/coder/coderd"
12-
1311
"github.com/go-chi/chi/v5"
1412
"github.com/stretchr/testify/assert"
1513
"github.com/stretchr/testify/require"
1614
"golang.org/x/xerrors"
1715

16+
"github.com/coder/coder/coderd"
1817
"github.com/coder/coder/coderd/rbac"
1918
"github.com/coder/coder/codersdk"
2019
"github.com/coder/coder/provisioner/echo"
@@ -33,8 +32,8 @@ type AuthTester struct {
3332
t *testing.T
3433
api *coderd.API
3534
authorizer *recordingAuthorizer
36-
client *codersdk.Client
3735

36+
Client *codersdk.Client
3837
Workspace codersdk.Workspace
3938
Organization codersdk.Organization
4039
Admin codersdk.CreateFirstUserResponse
@@ -117,14 +116,11 @@ func NewAuthTester(ctx context.Context, t *testing.T, options *Options) *AuthTes
117116
})
118117
require.NoError(t, err, "create template param")
119118

120-
// Always fail auth from this point forward
121-
authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil)
122-
123119
return &AuthTester{
124120
t: t,
125121
api: api,
126122
authorizer: authorizer,
127-
client: client,
123+
Client: client,
128124
Workspace: workspace,
129125
Organization: organization,
130126
Admin: admin,
@@ -386,6 +382,9 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
386382
}
387383

388384
func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck, skipRoutes map[string]string) {
385+
// Always fail auth from this point forward
386+
a.authorizer.AlwaysReturn = rbac.ForbiddenWithInternal(xerrors.New("fake implementation"), nil, nil)
387+
389388
for k, v := range assertRoute {
390389
noTrailSlash := strings.TrimRight(k, "/")
391390
if _, ok := assertRoute[noTrailSlash]; ok && noTrailSlash != k {
@@ -450,7 +449,7 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck
450449
route = strings.ReplaceAll(route, "{scope}", string(a.TemplateParam.Scope))
451450
route = strings.ReplaceAll(route, "{id}", a.TemplateParam.ScopeID.String())
452451

453-
resp, err := a.client.Request(ctx, method, route, nil)
452+
resp, err := a.Client.Request(ctx, method, route, nil)
454453
require.NoError(t, err, "do req")
455454
body, _ := io.ReadAll(resp.Body)
456455
t.Logf("Response Body: %q", string(body))

enterprise/cli/licenses.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func licenses() *cobra.Command {
2626
}
2727
cmd.AddCommand(
2828
licenseAdd(),
29+
licensesList(),
2930
)
3031
return cmd
3132
}
@@ -112,3 +113,32 @@ func validJWT(s string) error {
112113
}
113114
return xerrors.New("Invalid license")
114115
}
116+
117+
func licensesList() *cobra.Command {
118+
cmd := &cobra.Command{
119+
Use: "list",
120+
Short: "List licenses (including expired)",
121+
Aliases: []string{"ls"},
122+
Args: cobra.ExactArgs(0),
123+
RunE: func(cmd *cobra.Command, args []string) error {
124+
client, err := agpl.CreateClient(cmd)
125+
if err != nil {
126+
return err
127+
}
128+
129+
licenses, err := client.Licenses(cmd.Context())
130+
if err != nil {
131+
return err
132+
}
133+
// Ensure that we print "[]" instead of "null" when there are no licenses.
134+
if licenses == nil {
135+
licenses = make([]codersdk.License, 0)
136+
}
137+
138+
enc := json.NewEncoder(cmd.OutOrStdout())
139+
enc.SetIndent("", " ")
140+
return enc.Encode(licenses)
141+
},
142+
}
143+
return cmd
144+
}

enterprise/cli/licenses_test.go

Lines changed: 103 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"testing"
1313
"time"
1414

15+
"github.com/go-chi/chi/v5"
1516
"github.com/spf13/cobra"
1617
"github.com/stretchr/testify/assert"
1718
"github.com/stretchr/testify/require"
@@ -28,7 +29,7 @@ import (
2829

2930
const fakeLicenseJWT = "test.jwt.sig"
3031

31-
func TestLicensesAddSuccess(t *testing.T) {
32+
func TestLicensesAddFake(t *testing.T) {
3233
t.Parallel()
3334
// We can't check a real license into the git repo, and can't patch out the keys from here,
3435
// so instead we have to fake the HTTP interaction.
@@ -117,9 +118,9 @@ func TestLicensesAddSuccess(t *testing.T) {
117118
})
118119
}
119120

120-
func TestLicensesAddFail(t *testing.T) {
121+
func TestLicensesAddReal(t *testing.T) {
121122
t.Parallel()
122-
t.Run("LFlag", func(t *testing.T) {
123+
t.Run("Fails", func(t *testing.T) {
123124
t.Parallel()
124125
client := coderdtest.New(t, &coderdtest.Options{APIBuilder: coderd.NewEnterprise})
125126
coderdtest.CreateFirstUser(t, client)
@@ -141,9 +142,58 @@ func TestLicensesAddFail(t *testing.T) {
141142
})
142143
}
143144

145+
func TestLicensesListFake(t *testing.T) {
146+
t.Parallel()
147+
// We can't check a real license into the git repo, and can't patch out the keys from here,
148+
// so instead we have to fake the HTTP interaction.
149+
t.Run("Mainline", func(t *testing.T) {
150+
t.Parallel()
151+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
152+
defer cancel()
153+
cmd := setupFakeLicenseServerTest(t, "licenses", "list")
154+
stdout := new(bytes.Buffer)
155+
cmd.SetOut(stdout)
156+
errC := make(chan error)
157+
go func() {
158+
errC <- cmd.ExecuteContext(ctx)
159+
}()
160+
require.NoError(t, <-errC)
161+
var licenses []codersdk.License
162+
err := json.Unmarshal(stdout.Bytes(), &licenses)
163+
require.NoError(t, err)
164+
require.Len(t, licenses, 2)
165+
assert.Equal(t, int32(1), licenses[0].ID)
166+
assert.Equal(t, "claim1", licenses[0].Claims["h1"])
167+
assert.Equal(t, int32(5), licenses[1].ID)
168+
assert.Equal(t, "claim2", licenses[1].Claims["h2"])
169+
})
170+
}
171+
172+
func TestLicensesListReal(t *testing.T) {
173+
t.Parallel()
174+
t.Run("Empty", func(t *testing.T) {
175+
t.Parallel()
176+
client := coderdtest.New(t, &coderdtest.Options{APIBuilder: coderd.NewEnterprise})
177+
coderdtest.CreateFirstUser(t, client)
178+
cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(),
179+
"licenses", "list")
180+
stdout := new(bytes.Buffer)
181+
cmd.SetOut(stdout)
182+
clitest.SetupConfig(t, client, root)
183+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
184+
defer cancel()
185+
errC := make(chan error)
186+
go func() {
187+
errC <- cmd.ExecuteContext(ctx)
188+
}()
189+
require.NoError(t, <-errC)
190+
assert.Equal(t, "[]\n", stdout.String())
191+
})
192+
}
193+
144194
func setupFakeLicenseServerTest(t *testing.T, args ...string) *cobra.Command {
145195
t.Helper()
146-
s := httptest.NewServer(&fakeAddLicenseServer{t})
196+
s := httptest.NewServer(newFakeLicenseAPI(t))
147197
t.Cleanup(s.Close)
148198
cmd, root := clitest.NewWithSubcommands(t, cli.EnterpriseSubcommands(), args...)
149199
err := root.URL().Write(s.URL)
@@ -160,16 +210,28 @@ func attachPty(t *testing.T, cmd *cobra.Command) *ptytest.PTY {
160210
return pty
161211
}
162212

163-
type fakeAddLicenseServer struct {
213+
func newFakeLicenseAPI(t *testing.T) http.Handler {
214+
r := chi.NewRouter()
215+
a := &fakeLicenseAPI{t: t, r: r}
216+
r.NotFound(a.notFound)
217+
r.Post("/api/v2/licenses", a.postLicense)
218+
r.Get("/api/v2/licenses", a.licenses)
219+
r.Get("/api/v2/buildinfo", a.noop)
220+
return r
221+
}
222+
223+
type fakeLicenseAPI struct {
164224
t *testing.T
225+
r chi.Router
165226
}
166227

167-
func (s *fakeAddLicenseServer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
168-
if r.URL.Path == "/api/v2/buildinfo" {
169-
return
170-
}
171-
assert.Equal(s.t, http.MethodPost, r.Method)
172-
assert.Equal(s.t, "/api/v2/licenses", r.URL.Path)
228+
func (s *fakeLicenseAPI) notFound(_ http.ResponseWriter, r *http.Request) {
229+
s.t.Errorf("unexpected HTTP call: %s", r.URL.Path)
230+
}
231+
232+
func (*fakeLicenseAPI) noop(_ http.ResponseWriter, _ *http.Request) {}
233+
234+
func (s *fakeLicenseAPI) postLicense(rw http.ResponseWriter, r *http.Request) {
173235
var req codersdk.AddLicenseRequest
174236
err := json.NewDecoder(r.Body).Decode(&req)
175237
require.NoError(s.t, err)
@@ -190,3 +252,33 @@ func (s *fakeAddLicenseServer) ServeHTTP(rw http.ResponseWriter, r *http.Request
190252
err = json.NewEncoder(rw).Encode(resp)
191253
assert.NoError(s.t, err)
192254
}
255+
256+
func (s *fakeLicenseAPI) licenses(rw http.ResponseWriter, _ *http.Request) {
257+
resp := []codersdk.License{
258+
{
259+
ID: 1,
260+
UploadedAt: time.Now(),
261+
Claims: map[string]interface{}{
262+
"h1": "claim1",
263+
"features": map[string]int64{
264+
"f1": 1,
265+
"f2": 2,
266+
},
267+
},
268+
},
269+
{
270+
ID: 5,
271+
UploadedAt: time.Now(),
272+
Claims: map[string]interface{}{
273+
"h2": "claim2",
274+
"features": map[string]int64{
275+
"f3": 3,
276+
"f4": 4,
277+
},
278+
},
279+
},
280+
}
281+
rw.WriteHeader(http.StatusOK)
282+
err := json.NewEncoder(rw).Encode(resp)
283+
assert.NoError(s.t, err)
284+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package coderd
2+
3+
import (
4+
"context"
5+
"crypto/ed25519"
6+
"crypto/rand"
7+
"net/http"
8+
"testing"
9+
"time"
10+
11+
"github.com/golang-jwt/jwt/v4"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/coder/coder/coderd/coderdtest"
15+
"github.com/coder/coder/coderd/rbac"
16+
"github.com/coder/coder/codersdk"
17+
"github.com/coder/coder/testutil"
18+
)
19+
20+
// TestAuthorizeAllEndpoints will check `authorize` is called on every endpoint registered.
21+
// these tests patch the map of license keys, so cannot be run in parallel
22+
// nolint:paralleltest
23+
func TestAuthorizeAllEndpoints(t *testing.T) {
24+
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
25+
require.NoError(t, err)
26+
keyID := "testing"
27+
oldKeys := keys
28+
defer func() {
29+
t.Log("restoring keys")
30+
keys = oldKeys
31+
}()
32+
keys = map[string]ed25519.PublicKey{keyID: pubKey}
33+
34+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
35+
defer cancel()
36+
a := coderdtest.NewAuthTester(ctx, t, &coderdtest.Options{APIBuilder: NewEnterprise})
37+
38+
// We need a license in the DB, so that when we call GET api/v2/licenses there is one in the
39+
// list to check authz on.
40+
claims := &Claims{
41+
RegisteredClaims: jwt.RegisteredClaims{
42+
Issuer: "test@coder.test",
43+
IssuedAt: jwt.NewNumericDate(time.Now()),
44+
NotBefore: jwt.NewNumericDate(time.Now()),
45+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
46+
},
47+
LicenseExpires: jwt.NewNumericDate(time.Now().Add(time.Hour)),
48+
AccountType: AccountTypeSalesforce,
49+
AccountID: "testing",
50+
Version: CurrentVersion,
51+
Features: Features{
52+
UserLimit: 0,
53+
AuditLog: 1,
54+
},
55+
}
56+
lic, err := makeLicense(claims, privKey, keyID)
57+
require.NoError(t, err)
58+
_, err = a.Client.AddLicense(ctx, codersdk.AddLicenseRequest{
59+
License: lic,
60+
})
61+
require.NoError(t, err)
62+
63+
skipRoutes, assertRoute := coderdtest.AGPLRoutes(a)
64+
assertRoute["POST:/api/v2/licenses"] = coderdtest.RouteCheck{
65+
AssertAction: rbac.ActionCreate,
66+
AssertObject: rbac.ResourceLicense,
67+
}
68+
assertRoute["GET:/api/v2/licenses"] = coderdtest.RouteCheck{
69+
StatusCode: http.StatusOK,
70+
AssertAction: rbac.ActionRead,
71+
AssertObject: rbac.ResourceLicense,
72+
}
73+
a.Test(ctx, assertRoute, skipRoutes)
74+
}

enterprise/coderd/auth_test.go

Lines changed: 0 additions & 31 deletions
This file was deleted.

enterprise/coderd/licenses.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import (
1212
"strings"
1313
"time"
1414

15-
"cdr.dev/slog"
1615
"github.com/go-chi/chi/v5"
1716
"github.com/golang-jwt/jwt/v4"
1817
"golang.org/x/xerrors"
1918

19+
"cdr.dev/slog"
20+
2021
"github.com/coder/coder/coderd"
2122
"github.com/coder/coder/coderd/database"
2223
"github.com/coder/coder/coderd/httpapi"
@@ -253,7 +254,7 @@ func decodeClaims(l database.License) (jwt.MapClaims, error) {
253254
if len(parts) != 3 {
254255
return nil, xerrors.Errorf("Unable to parse license %d as JWT", l.ID)
255256
}
256-
cb, err := base64.URLEncoding.DecodeString(parts[1])
257+
cb, err := base64.RawURLEncoding.DecodeString(parts[1])
257258
if err != nil {
258259
return nil, xerrors.Errorf("Unable to decode license %d claims: %w", l.ID, err)
259260
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy