zitadel/internal/integration/integration.go
2023-10-19 12:34:00 +02:00

412 lines
12 KiB
Go

// Package integration provides helpers for integration testing.
package integration
import (
"bytes"
"context"
"database/sql"
_ "embed"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"sync"
"time"
"github.com/spf13/viper"
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v3/pkg/client"
"github.com/zitadel/oidc/v3/pkg/oidc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"github.com/zitadel/zitadel/cmd"
"github.com/zitadel/zitadel/cmd/start"
"github.com/zitadel/zitadel/internal/api/authz"
http_util "github.com/zitadel/zitadel/internal/api/http"
z_oidc "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/net"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/webauthn"
"github.com/zitadel/zitadel/pkg/grpc/admin"
)
var (
//go:embed config/zitadel.yaml
zitadelYAML []byte
//go:embed config/cockroach.yaml
cockroachYAML []byte
//go:embed config/postgres.yaml
postgresYAML []byte
//go:embed config/system-user-key.pem
systemUserKey []byte
)
// NotEmpty can be used as placeholder, when the returned values is unknown.
// It can be used in tests to assert whether a value should be empty or not.
const NotEmpty = "not empty"
// UserType provides constants that give
// a short explinanation with the purpose
// a serverice user.
// This allows to pre-create users with
// different permissions and reuse them.
type UserType int
//go:generate stringer -type=UserType
const (
Unspecified UserType = iota
OrgOwner
Login
IAMOwner
SystemUser // SystemUser is a user with access to the system service.
)
const (
FirstInstanceUsersKey = "first"
UserPassword = "VeryS3cret!"
)
const (
PortMilestoneServer = "8081"
PortQuotaServer = "8082"
)
// User information with a Personal Access Token.
type User struct {
*query.User
Token string
}
type InstanceUserMap map[string]map[UserType]*User
func (m InstanceUserMap) Set(instanceID string, typ UserType, user *User) {
if m[instanceID] == nil {
m[instanceID] = make(map[UserType]*User)
}
m[instanceID][typ] = user
}
func (m InstanceUserMap) Get(instanceID string, typ UserType) *User {
if users, ok := m[instanceID]; ok {
return users[typ]
}
return nil
}
// Tester is a Zitadel server and client with all resources available for testing.
type Tester struct {
*start.Server
Instance authz.Instance
Organisation *query.Org
Users InstanceUserMap
MilestoneChan chan []byte
milestoneServer *httptest.Server
QuotaNotificationChan chan []byte
quotaNotificationServer *httptest.Server
Client Client
WebAuthN *webauthn.Client
wg sync.WaitGroup // used for shutdown
}
const commandLine = `start --masterkeyFromEnv`
func (s *Tester) Host() string {
return fmt.Sprintf("%s:%d", s.Config.ExternalDomain, s.Config.Port)
}
func (s *Tester) createClientConn(ctx context.Context, opts ...grpc.DialOption) {
target := s.Host()
cc, err := grpc.DialContext(ctx, target, append(opts,
grpc.WithBlock(),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)...)
if err != nil {
s.Shutdown <- os.Interrupt
s.wg.Wait()
}
logging.OnError(err).Fatal("integration tester client dial")
logging.New().WithField("target", target).Info("finished dialing grpc client conn")
s.Client = newClient(cc)
err = s.pollHealth(ctx)
logging.OnError(err).Fatal("integration tester health")
}
// pollHealth waits until a healthy status is reported.
// TODO: remove when we make the setup blocking on all
// projections completed.
func (s *Tester) pollHealth(ctx context.Context) (err error) {
for {
err = func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, err := s.Client.Admin.Healthz(ctx, &admin.HealthzRequest{})
return err
}(ctx)
if err == nil {
return nil
}
logging.WithError(err).Info("poll healthz")
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
continue
}
}
}
const (
LoginUser = "loginClient"
MachineUserOrgOwner = "integrationOrgOwner"
MachineUserInstanceOwner = "integrationInstanceOwner"
)
func (s *Tester) createMachineUserOrgOwner(ctx context.Context) {
var err error
ctx, user := s.createMachineUser(ctx, MachineUserOrgOwner, OrgOwner)
_, err = s.Commands.AddOrgMember(ctx, user.ResourceOwner, user.ID, "ORG_OWNER")
target := new(caos_errs.AlreadyExistsError)
if !errors.As(err, &target) {
logging.OnError(err).Fatal("add org member")
}
}
func (s *Tester) createMachineUserInstanceOwner(ctx context.Context) {
var err error
ctx, user := s.createMachineUser(ctx, MachineUserInstanceOwner, IAMOwner)
_, err = s.Commands.AddInstanceMember(ctx, user.ID, "IAM_OWNER")
target := new(caos_errs.AlreadyExistsError)
if !errors.As(err, &target) {
logging.OnError(err).Fatal("add instance member")
}
}
func (s *Tester) createLoginClient(ctx context.Context) {
s.createMachineUser(ctx, LoginUser, Login)
}
func (s *Tester) createMachineUser(ctx context.Context, username string, userType UserType) (context.Context, *query.User) {
var err error
s.Instance, err = s.Queries.InstanceByHost(ctx, s.Host())
logging.OnError(err).Fatal("query instance")
ctx = authz.WithInstance(ctx, s.Instance)
s.Organisation, err = s.Queries.OrgByID(ctx, true, s.Instance.DefaultOrganisationID())
logging.OnError(err).Fatal("query organisation")
usernameQuery, err := query.NewUserUsernameSearchQuery(username, query.TextEquals)
logging.OnError(err).Fatal("user query")
user, err := s.Queries.GetUser(ctx, true, true, usernameQuery)
if errors.Is(err, sql.ErrNoRows) {
_, err = s.Commands.AddMachine(ctx, &command.Machine{
ObjectRoot: models.ObjectRoot{
ResourceOwner: s.Organisation.ID,
},
Username: username,
Name: username,
Description: "who cares?",
AccessTokenType: domain.OIDCTokenTypeJWT,
})
logging.WithFields("username", username).OnError(err).Fatal("add machine user")
user, err = s.Queries.GetUser(ctx, true, true, usernameQuery)
}
logging.WithFields("username", username).OnError(err).Fatal("get user")
scopes := []string{oidc.ScopeOpenID, oidc.ScopeProfile, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner}
pat := command.NewPersonalAccessToken(user.ResourceOwner, user.ID, time.Now().Add(time.Hour), scopes, domain.UserTypeMachine)
_, err = s.Commands.AddPersonalAccessToken(ctx, pat)
logging.WithFields("username", SystemUser).OnError(err).Fatal("add pat")
s.Users.Set(FirstInstanceUsersKey, userType, &User{
User: user,
Token: pat.Token,
})
return ctx, user
}
func (s *Tester) WithAuthorization(ctx context.Context, u UserType) context.Context {
return s.WithInstanceAuthorization(ctx, u, FirstInstanceUsersKey)
}
func (s *Tester) WithInstanceAuthorization(ctx context.Context, u UserType, instanceID string) context.Context {
if u == SystemUser {
s.ensureSystemUser()
}
return s.WithAuthorizationToken(ctx, s.Users.Get(instanceID, u).Token)
}
func (s *Tester) WithAuthorizationToken(ctx context.Context, token string) context.Context {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
md = make(metadata.MD)
}
md.Set("Authorization", fmt.Sprintf("Bearer %s", token))
return metadata.NewOutgoingContext(ctx, md)
}
func (s *Tester) ensureSystemUser() {
const ISSUER = "tester"
if s.Users.Get(FirstInstanceUsersKey, SystemUser) != nil {
return
}
audience := http_util.BuildOrigin(s.Host(), s.Server.Config.ExternalSecure)
signer, err := client.NewSignerFromPrivateKeyByte(systemUserKey, "")
logging.OnError(err).Fatal("system key signer")
jwt, err := client.SignedJWTProfileAssertion(ISSUER, []string{audience}, time.Hour, signer)
logging.OnError(err).Fatal("system key jwt")
s.Users.Set(FirstInstanceUsersKey, SystemUser, &User{Token: jwt})
}
func (s *Tester) WithSystemAuthorizationHTTP(u UserType) map[string]string {
return map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.Users.Get(FirstInstanceUsersKey, u).Token)}
}
// Done send an interrupt signal to cleanly shutdown the server.
func (s *Tester) Done() {
err := s.Client.CC.Close()
logging.OnError(err).Error("integration tester client close")
s.Shutdown <- os.Interrupt
s.wg.Wait()
s.milestoneServer.Close()
s.quotaNotificationServer.Close()
}
// NewTester start a new Zitadel server by passing the default commandline.
// The server will listen on the configured port.
// The database configuration that will be used can be set by the
// INTEGRATION_DB_FLAVOR environment variable and can have the values "cockroach"
// or "postgres". Defaults to "cockroach".
//
// The default Instance and Organisation are read from the DB and system
// users are created as needed.
//
// After the server is started, a [grpc.ClientConn] will be created and
// the server is polled for it's health status.
//
// Note: the database must already be setup and initialized before
// using NewTester. See the CONTRIBUTING.md document for details.
func NewTester(ctx context.Context) *Tester {
args := strings.Split(commandLine, " ")
sc := make(chan *start.Server)
//nolint:contextcheck
cmd := cmd.New(os.Stdout, os.Stdin, args, sc)
cmd.SetArgs(args)
err := viper.MergeConfig(bytes.NewBuffer(zitadelYAML))
logging.OnError(err).Fatal()
flavor := os.Getenv("INTEGRATION_DB_FLAVOR")
switch flavor {
case "cockroach", "":
err = viper.MergeConfig(bytes.NewBuffer(cockroachYAML))
case "postgres":
err = viper.MergeConfig(bytes.NewBuffer(postgresYAML))
default:
logging.New().WithField("flavor", flavor).Fatal("unknown db flavor set in INTEGRATION_DB_FLAVOR")
}
logging.OnError(err).Fatal()
tester := Tester{
Users: make(InstanceUserMap),
}
tester.MilestoneChan = make(chan []byte, 100)
tester.milestoneServer, err = runMilestoneServer(ctx, tester.MilestoneChan)
logging.OnError(err).Fatal()
tester.QuotaNotificationChan = make(chan []byte, 100)
tester.quotaNotificationServer, err = runQuotaServer(ctx, tester.QuotaNotificationChan)
logging.OnError(err).Fatal()
tester.wg.Add(1)
go func(wg *sync.WaitGroup) {
logging.OnError(cmd.Execute()).Fatal()
wg.Done()
}(&tester.wg)
select {
case tester.Server = <-sc:
case <-ctx.Done():
logging.OnError(ctx.Err()).Fatal("waiting for integration tester server")
}
tester.createClientConn(ctx)
tester.createLoginClient(ctx)
tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, http_util.BuildOrigin(tester.Host(), tester.Config.ExternalSecure))
tester.createMachineUserOrgOwner(ctx)
tester.createMachineUserInstanceOwner(ctx)
tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, "https://"+tester.Host())
return &tester
}
func Contexts(timeout time.Duration) (ctx, errCtx context.Context, cancel context.CancelFunc) {
errCtx, cancel = context.WithCancel(context.Background())
cancel()
ctx, cancel = context.WithTimeout(context.Background(), timeout)
return ctx, errCtx, cancel
}
func runMilestoneServer(ctx context.Context, bodies chan []byte) (*httptest.Server, error) {
mockServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if r.Header.Get("single-value") != "single-value" {
http.Error(w, "single-value header not set", http.StatusInternalServerError)
return
}
if reflect.DeepEqual(r.Header.Get("multi-value"), "multi-value-1,multi-value-2") {
http.Error(w, "single-value header not set", http.StatusInternalServerError)
return
}
bodies <- body
w.WriteHeader(http.StatusOK)
}))
config := net.ListenConfig()
listener, err := config.Listen(ctx, "tcp", ":"+PortMilestoneServer)
if err != nil {
return nil, err
}
mockServer.Listener = listener
mockServer.Start()
return mockServer, nil
}
func runQuotaServer(ctx context.Context, bodies chan []byte) (*httptest.Server, error) {
mockServer := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
bodies <- body
w.WriteHeader(http.StatusOK)
}))
config := net.ListenConfig()
listener, err := config.Listen(ctx, "tcp", ":"+PortQuotaServer)
if err != nil {
return nil, err
}
mockServer.Listener = listener
mockServer.Start()
return mockServer, nil
}