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)