certificates/authority/admin/api/admin_test.go
2021-12-09 17:29:23 +01:00

811 lines
22 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/smallstep/assert"
"github.com/smallstep/certificates/api"
"github.com/smallstep/certificates/authority/admin"
"github.com/smallstep/certificates/authority/provisioner"
"go.step.sm/linkedca"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestCreateAdminRequest_Validate(t *testing.T) {
type fields struct {
Subject string
Provisioner string
Type linkedca.Admin_Type
}
tests := []struct {
name string
fields fields
err *admin.Error
}{
{
name: "fail/subject-empty",
fields: fields{
Subject: "",
Provisioner: "",
Type: 0,
},
err: admin.NewError(admin.ErrorBadRequestType, "subject cannot be empty"),
},
{
name: "fail/provisioner-empty",
fields: fields{
Subject: "admin",
Provisioner: "",
Type: 0,
},
err: admin.NewError(admin.ErrorBadRequestType, "provisioner cannot be empty"),
},
{
name: "fail/invalid-type",
fields: fields{
Subject: "admin",
Provisioner: "prov",
Type: -1,
},
err: admin.NewError(admin.ErrorBadRequestType, "invalid value for admin type"),
},
{
name: "ok",
fields: fields{
Subject: "admin",
Provisioner: "prov",
Type: linkedca.Admin_SUPER_ADMIN,
},
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
car := &CreateAdminRequest{
Subject: tt.fields.Subject,
Provisioner: tt.fields.Provisioner,
Type: tt.fields.Type,
}
err := car.Validate()
if (err != nil) != (tt.err != nil) {
t.Errorf("CreateAdminRequest.Validate() error = %v, wantErr %v", err, (tt.err != nil))
return
}
if err != nil {
assert.Type(t, &admin.Error{}, err)
adminErr, _ := err.(*admin.Error)
assert.Equals(t, tt.err.Type, adminErr.Type)
assert.Equals(t, tt.err.Detail, adminErr.Detail)
assert.Equals(t, tt.err.Status, adminErr.Status)
assert.Equals(t, tt.err.Message, adminErr.Message)
}
})
}
}
func TestUpdateAdminRequest_Validate(t *testing.T) {
type fields struct {
Type linkedca.Admin_Type
}
tests := []struct {
name string
fields fields
err *admin.Error
}{
{
name: "fail/invalid-type",
fields: fields{
Type: -1,
},
err: admin.NewError(admin.ErrorBadRequestType, "invalid value for admin type"),
},
{
name: "ok",
fields: fields{
Type: linkedca.Admin_SUPER_ADMIN,
},
err: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
uar := &UpdateAdminRequest{
Type: tt.fields.Type,
}
err := uar.Validate()
if (err != nil) != (tt.err != nil) {
t.Errorf("CreateAdminRequest.Validate() error = %v, wantErr %v", err, (tt.err != nil))
return
}
if err != nil {
assert.Type(t, &admin.Error{}, err)
adminErr, _ := err.(*admin.Error)
assert.Equals(t, tt.err.Type, adminErr.Type)
assert.Equals(t, tt.err.Detail, adminErr.Detail)
assert.Equals(t, tt.err.Status, adminErr.Status)
assert.Equals(t, tt.err.Message, adminErr.Message)
}
})
}
}
func TestHandler_GetAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth api.LinkedAuthority
statusCode int
err *admin.Error
adm *linkedca.Admin
}
var tests = map[string]func(t *testing.T) test{
"fail/auth.LoadAdminByID-not-found": func(t *testing.T) test {
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
auth := &api.MockAuthority{
MockLoadAdminByID: func(id string) (*linkedca.Admin, bool) {
assert.Equals(t, "adminID", id)
return nil, false
},
}
return test{
ctx: ctx,
auth: auth,
statusCode: 404,
err: &admin.Error{
Type: admin.ErrorNotFoundType.String(),
Status: 404,
Detail: "resource not found",
Message: "admin adminID not found",
},
}
},
"ok": func(t *testing.T) test {
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
createdAt := time.Now()
var deletedAt time.Time
adm := &linkedca.Admin{
Id: "adminID",
AuthorityId: "authorityID",
Subject: "admin",
ProvisionerId: "provID",
Type: linkedca.Admin_SUPER_ADMIN,
CreatedAt: timestamppb.New(createdAt),
DeletedAt: timestamppb.New(deletedAt),
}
auth := &api.MockAuthority{
MockLoadAdminByID: func(id string) (*linkedca.Admin, bool) {
assert.Equals(t, "adminID", id)
return adm, true
},
}
return test{
ctx: ctx,
auth: auth,
statusCode: 200,
err: nil,
adm: adm,
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("GET", "/foo", nil) // chi routing is prepared in test setup
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.GetAdmin(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
if res.StatusCode >= 400 {
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
adm := &linkedca.Admin{}
err := readProtoJSON(res.Body, adm)
assert.FatalError(t, err)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(tc.adm, adm, opts...) {
t.Errorf("linkedca.Admin diff =\n%s", cmp.Diff(tc.adm, adm, opts...))
}
})
}
}
func TestHandler_GetAdmins(t *testing.T) {
type test struct {
ctx context.Context
auth api.LinkedAuthority
req *http.Request
statusCode int
err *admin.Error
resp GetAdminsResponse
}
var tests = map[string]func(t *testing.T) test{
"fail/parse-cursor": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo?limit=A", nil)
return test{
ctx: context.Background(),
req: req,
statusCode: 400,
err: &admin.Error{
Status: 400,
Type: admin.ErrorBadRequestType.String(),
Detail: "bad request",
Message: "error parsing cursor and limit from query params: limit 'A' is not an integer: strconv.Atoi: parsing \"A\": invalid syntax",
},
}
},
"fail/auth.GetAdmins": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo", nil)
auth := &api.MockAuthority{
MockGetAdmins: func(cursor string, limit int) ([]*linkedca.Admin, string, error) {
assert.Equals(t, "", cursor)
assert.Equals(t, 0, limit)
return nil, "", errors.New("force")
},
}
return test{
ctx: context.Background(),
req: req,
auth: auth,
statusCode: 500,
err: &admin.Error{
Status: 500,
Type: admin.ErrorServerInternalType.String(),
Detail: "the server experienced an internal error",
Message: "error retrieving paginated admins: force",
},
}
},
"ok": func(t *testing.T) test {
req := httptest.NewRequest("GET", "/foo", nil)
createdAt := time.Now()
var deletedAt time.Time
adm1 := &linkedca.Admin{
Id: "adminID1",
AuthorityId: "authorityID1",
Subject: "admin1",
ProvisionerId: "provID",
Type: linkedca.Admin_SUPER_ADMIN,
CreatedAt: timestamppb.New(createdAt),
DeletedAt: timestamppb.New(deletedAt),
}
adm2 := &linkedca.Admin{
Id: "adminID2",
AuthorityId: "authorityID",
Subject: "admin2",
ProvisionerId: "provID",
Type: linkedca.Admin_ADMIN,
CreatedAt: timestamppb.New(createdAt),
DeletedAt: timestamppb.New(deletedAt),
}
auth := &api.MockAuthority{
MockGetAdmins: func(cursor string, limit int) ([]*linkedca.Admin, string, error) {
assert.Equals(t, "", cursor)
assert.Equals(t, 0, limit)
return []*linkedca.Admin{
adm1,
adm2,
}, "nextCursorValue", nil
},
}
return test{
ctx: context.Background(),
req: req,
auth: auth,
statusCode: 200,
err: nil,
resp: GetAdminsResponse{
Admins: []*linkedca.Admin{
adm1,
adm2,
},
NextCursor: "nextCursorValue",
},
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := tc.req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.GetAdmins(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
if res.StatusCode >= 400 {
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
response := GetAdminsResponse{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &response))
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(tc.resp, response, opts...) {
t.Errorf("GetAdmins diff =\n%s", cmp.Diff(tc.resp, response, opts...))
}
})
}
}
func TestHandler_CreateAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth api.LinkedAuthority
body []byte
statusCode int
err *admin.Error
adm *linkedca.Admin
}
var tests = map[string]func(t *testing.T) test{
"fail/ReadJSON": func(t *testing.T) test {
body := []byte("{!?}")
return test{
ctx: context.Background(),
body: body,
statusCode: 400,
err: &admin.Error{
Type: admin.ErrorBadRequestType.String(),
Status: 400,
Detail: "bad request",
Message: "error reading request body: error decoding json: invalid character '!' looking for beginning of object key string",
},
}
},
"fail/validate": func(t *testing.T) test {
req := CreateAdminRequest{
Subject: "",
Provisioner: "",
Type: -1,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
return test{
ctx: context.Background(),
body: body,
statusCode: 400,
err: &admin.Error{
Type: admin.ErrorBadRequestType.String(),
Status: 400,
Detail: "bad request",
Message: "subject cannot be empty",
},
}
},
"fail/auth.LoadProvisionerByName": func(t *testing.T) test {
req := CreateAdminRequest{
Subject: "admin",
Provisioner: "prov",
Type: linkedca.Admin_SUPER_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
auth := &api.MockAuthority{
MockLoadProvisionerByName: func(name string) (provisioner.Interface, error) {
assert.Equals(t, "prov", name)
return nil, errors.New("force")
},
}
return test{
ctx: context.Background(),
body: body,
auth: auth,
statusCode: 500,
err: &admin.Error{
Type: admin.ErrorServerInternalType.String(),
Status: 500,
Detail: "the server experienced an internal error",
Message: "error loading provisioner prov: force",
},
}
},
"fail/auth.StoreAdmin": func(t *testing.T) test {
req := CreateAdminRequest{
Subject: "admin",
Provisioner: "prov",
Type: linkedca.Admin_SUPER_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
auth := &api.MockAuthority{
MockLoadProvisionerByName: func(name string) (provisioner.Interface, error) {
assert.Equals(t, "prov", name)
return &provisioner.ACME{
ID: "provID",
Name: "prov",
}, nil
},
MockStoreAdmin: func(ctx context.Context, adm *linkedca.Admin, prov provisioner.Interface) error {
assert.Equals(t, "admin", adm.Subject)
assert.Equals(t, "provID", prov.GetID())
return errors.New("force")
},
}
return test{
ctx: context.Background(),
body: body,
auth: auth,
statusCode: 500,
err: &admin.Error{
Type: admin.ErrorServerInternalType.String(),
Status: 500,
Detail: "the server experienced an internal error",
Message: "error storing admin: force",
},
}
},
"ok": func(t *testing.T) test {
req := CreateAdminRequest{
Subject: "admin",
Provisioner: "prov",
Type: linkedca.Admin_SUPER_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
auth := &api.MockAuthority{
MockLoadProvisionerByName: func(name string) (provisioner.Interface, error) {
assert.Equals(t, "prov", name)
return &provisioner.ACME{
ID: "provID",
Name: "prov",
}, nil
},
MockStoreAdmin: func(ctx context.Context, adm *linkedca.Admin, prov provisioner.Interface) error {
assert.Equals(t, "admin", adm.Subject)
assert.Equals(t, "provID", prov.GetID())
return nil
},
}
return test{
ctx: context.Background(),
body: body,
auth: auth,
statusCode: 201,
err: nil,
adm: &linkedca.Admin{
ProvisionerId: "provID",
Subject: "admin",
Type: linkedca.Admin_SUPER_ADMIN,
},
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("GET", "/foo", io.NopCloser(bytes.NewBuffer(tc.body)))
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.CreateAdmin(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
if res.StatusCode >= 400 {
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
adm := &linkedca.Admin{}
err := readProtoJSON(res.Body, adm)
assert.FatalError(t, err)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(tc.adm, adm, opts...) {
t.Errorf("h.CreateAdmin diff =\n%s", cmp.Diff(tc.adm, adm, opts...))
}
})
}
}
func TestHandler_DeleteAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth api.LinkedAuthority
statusCode int
err *admin.Error
}
var tests = map[string]func(t *testing.T) test{
"fail/auth.RemoveAdmin": func(t *testing.T) test {
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
auth := &api.MockAuthority{
MockRemoveAdmin: func(ctx context.Context, id string) error {
assert.Equals(t, "adminID", id)
return errors.New("force")
},
}
return test{
ctx: ctx,
auth: auth,
statusCode: 500,
err: &admin.Error{
Type: admin.ErrorServerInternalType.String(),
Status: 500,
Detail: "the server experienced an internal error",
Message: "error deleting admin adminID: force",
},
}
},
"ok": func(t *testing.T) test {
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
auth := &api.MockAuthority{
MockRemoveAdmin: func(ctx context.Context, id string) error {
assert.Equals(t, "adminID", id)
return nil
},
}
return test{
ctx: ctx,
auth: auth,
statusCode: 200,
err: nil,
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("DELETE", "/foo", nil) // chi routing is prepared in test setup
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.DeleteAdmin(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
if res.StatusCode >= 400 {
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.StatusCode(), res.StatusCode)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
response := DeleteResponse{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &response))
assert.Equals(t, "ok", response.Status)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
})
}
}
func TestHandler_UpdateAdmin(t *testing.T) {
type test struct {
ctx context.Context
auth api.LinkedAuthority
body []byte
statusCode int
err *admin.Error
adm *linkedca.Admin
}
var tests = map[string]func(t *testing.T) test{
"fail/ReadJSON": func(t *testing.T) test {
body := []byte("{!?}")
return test{
ctx: context.Background(),
body: body,
statusCode: 400,
err: &admin.Error{
Type: admin.ErrorBadRequestType.String(),
Status: 400,
Detail: "bad request",
Message: "error reading request body: error decoding json: invalid character '!' looking for beginning of object key string",
},
}
},
"fail/validate": func(t *testing.T) test {
req := UpdateAdminRequest{
Type: -1,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
return test{
ctx: context.Background(),
body: body,
statusCode: 400,
err: &admin.Error{
Type: admin.ErrorBadRequestType.String(),
Status: 400,
Detail: "bad request",
Message: "invalid value for admin type",
},
}
},
"fail/auth.UpdateAdmin": func(t *testing.T) test {
req := UpdateAdminRequest{
Type: linkedca.Admin_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
auth := &api.MockAuthority{
MockUpdateAdmin: func(ctx context.Context, id string, nu *linkedca.Admin) (*linkedca.Admin, error) {
assert.Equals(t, "adminID", id)
assert.Equals(t, linkedca.Admin_ADMIN, nu.Type)
return nil, errors.New("force")
},
}
return test{
ctx: ctx,
body: body,
auth: auth,
statusCode: 500,
err: &admin.Error{
Type: admin.ErrorServerInternalType.String(),
Status: 500,
Detail: "the server experienced an internal error",
Message: "error updating admin adminID: force",
},
}
},
"ok": func(t *testing.T) test {
req := UpdateAdminRequest{
Type: linkedca.Admin_ADMIN,
}
body, err := json.Marshal(req)
assert.FatalError(t, err)
chiCtx := chi.NewRouteContext()
chiCtx.URLParams.Add("id", "adminID")
ctx := context.WithValue(context.Background(), chi.RouteCtxKey, chiCtx)
adm := &linkedca.Admin{
Id: "adminID",
ProvisionerId: "provID",
Subject: "admin",
Type: linkedca.Admin_SUPER_ADMIN,
}
auth := &api.MockAuthority{
MockUpdateAdmin: func(ctx context.Context, id string, nu *linkedca.Admin) (*linkedca.Admin, error) {
assert.Equals(t, "adminID", id)
assert.Equals(t, linkedca.Admin_ADMIN, nu.Type)
return adm, nil
},
}
return test{
ctx: ctx,
body: body,
auth: auth,
statusCode: 200,
err: nil,
adm: adm,
}
},
}
for name, prep := range tests {
tc := prep(t)
t.Run(name, func(t *testing.T) {
h := &Handler{
auth: tc.auth,
}
req := httptest.NewRequest("GET", "/foo", io.NopCloser(bytes.NewBuffer(tc.body)))
req = req.WithContext(tc.ctx)
w := httptest.NewRecorder()
h.UpdateAdmin(w, req)
res := w.Result()
assert.Equals(t, tc.statusCode, res.StatusCode)
if res.StatusCode >= 400 {
body, err := io.ReadAll(res.Body)
res.Body.Close()
assert.FatalError(t, err)
adminErr := admin.Error{}
assert.FatalError(t, json.Unmarshal(bytes.TrimSpace(body), &adminErr))
assert.Equals(t, tc.err.Type, adminErr.Type)
assert.Equals(t, tc.err.Message, adminErr.Message)
assert.Equals(t, tc.err.Detail, adminErr.Detail)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
return
}
adm := &linkedca.Admin{}
err := readProtoJSON(res.Body, adm)
assert.FatalError(t, err)
assert.Equals(t, []string{"application/json"}, res.Header["Content-Type"])
opts := []cmp.Option{cmpopts.IgnoreUnexported(linkedca.Admin{}, timestamppb.Timestamp{})}
if !cmp.Equal(tc.adm, adm, opts...) {
t.Errorf("h.UpdateAdmin diff =\n%s", cmp.Diff(tc.adm, adm, opts...))
}
})
}
}