diff --git a/USERS.md b/USERS.md index 3f10eccbe9..2b558c9caa 100644 --- a/USERS.md +++ b/USERS.md @@ -78,6 +78,7 @@ Organizations below are **officially** using Argo Rollouts. Please send a PR wit 1. [Twilio SendGrid](https://sendgrid.com) 1. [Ubie](https://ubie.life/) 1. [UiPath](https://uipath.com) +1. [Unity](https://unity.com) 1. [Verkada](https://verkada.com) 1. [VGS](https://www.vgs.io) 1. [VISITS Technologies](https://visits.world/en) diff --git a/docs/dashboard.md b/docs/dashboard.md index 621b9d62a4..4e0bc0ea5a 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -12,3 +12,119 @@ Then visit `localhost:3100` to view the user interface. ## Individual Rollout view ![Rollouts List](dashboard/rollout-ui.png) + +## Authentication + +By default the dashboard does not authenticate anyone. Every visitor acts with the credentials of +the kubeconfig the dashboard itself was started with. That is fine for `kubectl argo rollouts +dashboard` on a laptop, but it means anyone who can reach the port has the dashboard's own +permissions. + +The `--auth-mode` flag selects between the two modes: + +| Mode | Behaviour | +|------|-----------| +| `server` (default) | No authentication. All requests use the credentials the dashboard was started with. | +| `client` | Each user supplies their own Kubernetes bearer token. The dashboard talks to the API server as that user, so Kubernetes RBAC decides what they can see and do. | + +```bash +kubectl argo rollouts dashboard --auth-mode client +``` + +In client mode the dashboard shows a login page. Paste a Kubernetes bearer token and the token is +stored in a session cookie scoped to the dashboard's path, which is sent with every API request +including the live-update streams. Closing the browser discards it; the **Logout** button in the +header clears it immediately. + +The token is never validated by the dashboard itself — it is forwarded to the Kubernetes API +server, which decides whether it is valid. A rejected token leaves you on the login page with an +error. + +### Obtaining a token + +Create a service account, give it the permissions you want the user to have, and mint a token for +it. Tokens created this way are short-lived, which is what you want for a UI login. + +```bash +kubectl create serviceaccount rollouts-viewer -n argo-rollouts + +# a token valid for 8 hours +kubectl create token rollouts-viewer -n argo-rollouts --duration=8h +``` + +On clusters older than v1.24, or when you want a token that does not expire, create a +service-account token Secret instead: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: rollouts-viewer-token + namespace: argo-rollouts + annotations: + kubernetes.io/service-account.name: rollouts-viewer +type: kubernetes.io/service-account-token +``` + +```bash +kubectl apply -f rollouts-viewer-token.yaml +kubectl get secret rollouts-viewer-token -n argo-rollouts -o jsonpath='{.data.token}' | base64 -d +``` + +A non-expiring token is a long-lived credential. Prefer `kubectl create token`. + +You can also paste the token your own user already has, if your cluster issues one. `kubectl +config view --raw -o jsonpath='{.users[?(@.name=="")].user.token}'` prints it when there is +one. Client certificates and exec-plugin credentials (EKS, GKE, OIDC via `kubectl oidc-login`) +cannot be pasted into the dashboard — those users need a service account token. + +### RBAC + +Read-only access to the dashboard: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: rollouts-viewer +rules: + - apiGroups: ["argoproj.io"] + resources: ["rollouts", "analysisruns", "analysistemplates", "experiments"] + verbs: ["get", "list", "watch"] + - apiGroups: ["apps"] + resources: ["replicasets"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] +``` + +A user bound only to the role above can browse Rollouts but gets a Kubernetes `403` if they try to +promote, abort, retry or restart one. To allow those actions, add: + +```yaml + - apiGroups: ["argoproj.io"] + resources: ["rollouts"] + verbs: ["update", "patch"] +``` + +Bind the role to the service account: + +```bash +kubectl create clusterrolebinding rollouts-viewer \ + --clusterrole=rollouts-viewer \ + --serviceaccount=argo-rollouts:rollouts-viewer +``` + +The namespace dropdown is populated by listing Rollouts across all namespaces. A user without +cluster-wide list permission still gets a working dashboard, limited to the namespace the +dashboard was started in. + +### Notes + +- Serve the dashboard over HTTPS if it is reachable by anyone other than you. The token is sent on + every request, and the cookie is only marked `Secure` when the page is loaded over HTTPS. +- The cookie is set with `SameSite=Strict`, so another site cannot make your browser issue + authenticated requests to the dashboard. +- API clients that are not the browser should send `Authorization: Bearer ` instead; both + are accepted. diff --git a/docs/generated/kubectl-argo-rollouts/kubectl-argo-rollouts_dashboard.md b/docs/generated/kubectl-argo-rollouts/kubectl-argo-rollouts_dashboard.md index ad99bfb5ca..3920d14700 100644 --- a/docs/generated/kubectl-argo-rollouts/kubectl-argo-rollouts_dashboard.md +++ b/docs/generated/kubectl-argo-rollouts/kubectl-argo-rollouts_dashboard.md @@ -18,11 +18,15 @@ kubectl argo rollouts dashboard # Start UI dashboard on a specific port kubectl argo rollouts dashboard --port 8080 + +# Start UI dashboard with client auth mode (requires bearer token) +kubectl argo rollouts dashboard --auth-mode client ``` ## Options ``` + --auth-mode string authentication mode: "server" (default, uses server credentials) or "client" (requires bearer token from users) (default "server") -h, --help help for dashboard -p, --port int port to listen on (default 3100) --root-path string changes the root path of the dashboard (default "rollouts") diff --git a/pkg/kubectl-argo-rollouts/cmd/dashboard/dashboard.go b/pkg/kubectl-argo-rollouts/cmd/dashboard/dashboard.go index f431a479ba..248794da95 100644 --- a/pkg/kubectl-argo-rollouts/cmd/dashboard/dashboard.go +++ b/pkg/kubectl-argo-rollouts/cmd/dashboard/dashboard.go @@ -2,6 +2,7 @@ package dashboard import ( "context" + "fmt" "github.com/spf13/cobra" @@ -15,17 +16,25 @@ var ( %[1]s dashboard # Start UI dashboard on a specific port - %[1]s dashboard --port 8080` + %[1]s dashboard --port 8080 + + # Start UI dashboard with client auth mode (requires bearer token) + %[1]s dashboard --auth-mode client` ) func NewCmdDashboard(o *options.ArgoRolloutsOptions) *cobra.Command { var rootPath string var port int + var authMode string var cmd = &cobra.Command{ Use: "dashboard", Short: "Start UI dashboard", Example: o.Example(dashBoardExample), RunE: func(c *cobra.Command, args []string) error { + if authMode != server.AuthModeServer && authMode != server.AuthModeClient { + return fmt.Errorf("invalid auth mode %q: must be %q or %q", authMode, server.AuthModeServer, server.AuthModeClient) + } + namespace := o.Namespace() kubeclientset := o.KubeClientset() rolloutclientset := o.RolloutsClientset() @@ -36,6 +45,18 @@ func NewCmdDashboard(o *options.ArgoRolloutsOptions) *cobra.Command { RolloutsClientset: rolloutclientset, DynamicClientset: o.DynamicClientset(), RootPath: rootPath, + AuthMode: authMode, + } + + if authMode == server.AuthModeClient { + restConfig, err := o.RESTClientGetter.ToRESTConfig() + if err != nil { + return fmt.Errorf("failed to get REST config: %w", err) + } + if restConfig == nil { + return fmt.Errorf("auth mode %q requires a Kubernetes REST config, but none was resolved", server.AuthModeClient) + } + opts.RESTConfig = restConfig } for { @@ -49,6 +70,7 @@ func NewCmdDashboard(o *options.ArgoRolloutsOptions) *cobra.Command { } cmd.Flags().StringVar(&rootPath, "root-path", "rollouts", "changes the root path of the dashboard") cmd.Flags().IntVarP(&port, "port", "p", 3100, "port to listen on") + cmd.Flags().StringVar(&authMode, "auth-mode", server.AuthModeServer, `authentication mode: "server" (default, uses server credentials) or "client" (requires bearer token from users)`) return cmd } diff --git a/pkg/kubectl-argo-rollouts/cmd/dashboard/dashboard_test.go b/pkg/kubectl-argo-rollouts/cmd/dashboard/dashboard_test.go new file mode 100644 index 0000000000..5ff7967d12 --- /dev/null +++ b/pkg/kubectl-argo-rollouts/cmd/dashboard/dashboard_test.go @@ -0,0 +1,83 @@ +package dashboard + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + options "github.com/argoproj/argo-rollouts/pkg/kubectl-argo-rollouts/options" + fakeoptions "github.com/argoproj/argo-rollouts/pkg/kubectl-argo-rollouts/options/fake" +) + +// failingRESTConfigGetter wraps a RESTClientGetter and overrides ToRESTConfig to return an error +type failingRESTConfigGetter struct { + delegate genericclioptions.RESTClientGetter +} + +func (f *failingRESTConfigGetter) ToRESTConfig() (*rest.Config, error) { + return nil, fmt.Errorf("mock REST config error") +} + +func (f *failingRESTConfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { + return f.delegate.ToRawKubeConfigLoader() +} + +func (f *failingRESTConfigGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + return f.delegate.ToDiscoveryClient() +} + +func (f *failingRESTConfigGetter) ToRESTMapper() (meta.RESTMapper, error) { + return f.delegate.ToRESTMapper() +} + +func TestNewCmdDashboard(t *testing.T) { + streams := genericclioptions.IOStreams{} + o := options.NewArgoRolloutsOptions(streams) + + t.Run("default auth mode is server", func(t *testing.T) { + cmd := NewCmdDashboard(o) + f := cmd.Flags().Lookup("auth-mode") + assert.NotNil(t, f) + assert.Equal(t, "server", f.DefValue) + }) + + t.Run("has port flag", func(t *testing.T) { + cmd := NewCmdDashboard(o) + f := cmd.Flags().Lookup("port") + assert.NotNil(t, f) + assert.Equal(t, "3100", f.DefValue) + }) + + t.Run("has root-path flag", func(t *testing.T) { + cmd := NewCmdDashboard(o) + f := cmd.Flags().Lookup("root-path") + assert.NotNil(t, f) + assert.Equal(t, "rollouts", f.DefValue) + }) + + t.Run("rejects invalid auth mode", func(t *testing.T) { + cmd := NewCmdDashboard(o) + cmd.Flags().Set("auth-mode", "invalid") + err := cmd.RunE(cmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid auth mode") + }) +} + +func TestDashboardClientAuthModeRESTConfigFailure(t *testing.T) { + tf, o := fakeoptions.NewFakeArgoRolloutsOptions() + defer tf.Cleanup() + // Wrap the RESTClientGetter so ToRESTConfig fails but other methods work + o.RESTClientGetter = &failingRESTConfigGetter{delegate: tf} + cmd := NewCmdDashboard(o) + cmd.Flags().Set("auth-mode", "client") + err := cmd.RunE(cmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get REST config") +} diff --git a/server/server.go b/server/server.go index bc24c51d2d..e0aecf8025 100644 --- a/server/server.go +++ b/server/server.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/http" + "net/url" "path" "strings" "time" @@ -14,8 +15,12 @@ import ( log "github.com/sirupsen/logrus" "github.com/soheilhy/cmux" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/wait" @@ -23,6 +28,7 @@ import ( kubeinformers "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" appslisters "k8s.io/client-go/listers/apps/v1" + "k8s.io/client-go/rest" "k8s.io/client-go/tools/cache" "github.com/argoproj/argo-rollouts/pkg/apiclient/rollout" @@ -51,12 +57,28 @@ var backoff = wait.Backoff{ Jitter: 0.1, } +const ( + // AuthModeServer uses the server's own kubeconfig credentials for all requests (default) + AuthModeServer = "server" + // AuthModeClient uses the client-provided bearer token to create per-request Kubernetes clients + AuthModeClient = "client" +) + type ServerOptions struct { KubeClientset kubernetes.Interface RolloutsClientset rolloutclientset.Interface DynamicClientset dynamic.Interface Namespace string RootPath string + AuthMode string + RESTConfig *rest.Config +} + +// serverClients groups the Kubernetes client interfaces used for API operations +type serverClients struct { + kubeClientset kubernetes.Interface + rolloutsClientset rolloutclientset.Interface + dynamicClientset dynamic.Interface } const ( @@ -80,6 +102,191 @@ const ( connectAddr = "localhost" ) +// AuthCookieName is the cookie the dashboard UI stores the bearer token in. EventSource/SSE +// cannot set custom headers, so the UI authenticates with a cookie rather than a query parameter. +const AuthCookieName = "authorization" + +// extractBearerToken extracts the token from an "Authorization: Bearer " header value +func extractBearerToken(authHeader string) string { + if !strings.HasPrefix(authHeader, "Bearer ") { + return "" + } + return strings.TrimPrefix(authHeader, "Bearer ") +} + +// tokenFromHTTPRequest extracts a bearer token from an HTTP request. +// It checks the Authorization header first, then falls back to the authorization cookie set by +// the UI. This mirrors Argo Workflows: API clients send a header, the browser sends a cookie. +func tokenFromHTTPRequest(r *http.Request) string { + if token := extractBearerToken(r.Header.Get("Authorization")); token != "" { + return token + } + cookie, err := r.Cookie(AuthCookieName) + if err != nil { + return "" + } + value, err := url.QueryUnescape(cookie.Value) + if err != nil { + value = cookie.Value + } + // tolerate a "Bearer " prefix so the cookie accepts the same value as the header + if token := extractBearerToken(value); token != "" { + return token + } + return value +} + +// tokenFromGRPCContext extracts a bearer token from gRPC request metadata. +func tokenFromGRPCContext(ctx context.Context) string { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "" + } + authHeaders := md.Get("authorization") + if len(authHeaders) == 0 { + return "" + } + return extractBearerToken(authHeaders[0]) +} + +// getClients returns the appropriate Kubernetes clients for the current request. +// In server mode, it returns the shared server clients. +// In client mode, it creates per-request clients using the user's bearer token. +func (s *ArgoRolloutsServer) getClients(ctx context.Context) (*serverClients, error) { + if s.Options.AuthMode != AuthModeClient { + return &serverClients{ + kubeClientset: s.Options.KubeClientset, + rolloutsClientset: s.Options.RolloutsClientset, + dynamicClientset: s.Options.DynamicClientset, + }, nil + } + // never fall back to the server's own credentials in client mode: that would silently + // downgrade a misconfigured server to running every request as the dashboard's identity + if s.Options.RESTConfig == nil { + return nil, status.Error(codes.Internal, "client auth mode requires a Kubernetes REST config") + } + token := tokenFromGRPCContext(ctx) + if token == "" { + return nil, status.Error(codes.Unauthenticated, "missing bearer token") + } + return s.clientsFromToken(token) +} + +// configForToken copies the server's REST config and swaps in the user's bearer token, stripping +// every other credential the server holds. Anything left behind here would let a request fall back +// to authenticating as the dashboard instead of as the user. +func (s *ArgoRolloutsServer) configForToken(token string) *rest.Config { + cfg := rest.CopyConfig(s.Options.RESTConfig) + cfg.BearerToken = token + cfg.BearerTokenFile = "" + cfg.Username = "" + cfg.Password = "" + cfg.CertData = nil + cfg.CertFile = "" + cfg.KeyData = nil + cfg.KeyFile = "" + cfg.AuthProvider = nil + cfg.ExecProvider = nil + return cfg +} + +// clientsFromToken creates new Kubernetes clients authenticated with the given bearer token. +func (s *ArgoRolloutsServer) clientsFromToken(token string) (*serverClients, error) { + cfg := s.configForToken(token) + + kubeClient, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create kube client: %w", err) + } + rolloutsClient, err := rolloutclientset.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create rollouts client: %w", err) + } + dynamicClient, err := dynamic.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + return &serverClients{ + kubeClientset: kubeClient, + rolloutsClientset: rolloutsClient, + dynamicClientset: dynamicClient, + }, nil +} + +// k8sError maps a Kubernetes API error onto the matching gRPC code. Without it every rejection +// reaches the dashboard as a generic 500, so a user who simply lacks RBAC to promote a rollout +// cannot tell that apart from the server being broken. +func k8sError(err error) error { + switch { + case err == nil: + return nil + case apierrors.IsUnauthorized(err): + return status.Error(codes.Unauthenticated, err.Error()) + case apierrors.IsForbidden(err): + return status.Error(codes.PermissionDenied, err.Error()) + case apierrors.IsNotFound(err): + return status.Error(codes.NotFound, err.Error()) + default: + return err + } +} + +// newClientAuthMiddleware returns HTTP middleware that requires a bearer token for API routes +// when running in client auth mode. +func (s *ArgoRolloutsServer) newClientAuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only require auth for API routes + if s.Options.AuthMode == AuthModeClient && isAPIRoute(r.URL.Path, s.Options.RootPath) { + token := tokenFromHTTPRequest(r) + if token == "" { + http.Error(w, "missing bearer token", http.StatusUnauthorized) + return + } + // normalize cookie-supplied tokens into the Authorization header so everything + // downstream of the gRPC gateway only has to look at one place + r.Header.Set("Authorization", "Bearer "+token) + } + next.ServeHTTP(w, r) + }) +} + +// isAPIRoute checks if the given path is an API route that requires authentication. +func isAPIRoute(urlPath string, rootPath string) bool { + if rootPath != "" { + apiPrefix := path.Join("/", rootPath, "api") + "/" + return strings.HasPrefix(urlPath, apiPrefix) + } + return strings.HasPrefix(urlPath, "/api/") +} + +// newAuthUnaryInterceptor returns a gRPC unary interceptor that requires a bearer token +// when running in client auth mode. +func (s *ArgoRolloutsServer) newAuthUnaryInterceptor() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + if s.Options.AuthMode == AuthModeClient { + token := tokenFromGRPCContext(ctx) + if token == "" { + return nil, status.Error(codes.Unauthenticated, "missing bearer token") + } + } + return handler(ctx, req) + } +} + +// newAuthStreamInterceptor returns a gRPC stream interceptor that requires a bearer token +// when running in client auth mode. +func (s *ArgoRolloutsServer) newAuthStreamInterceptor() grpc.StreamServerInterceptor { + return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + if s.Options.AuthMode == AuthModeClient { + token := tokenFromGRPCContext(ss.Context()) + if token == "" { + return status.Error(codes.Unauthenticated, "missing bearer token") + } + } + return handler(srv, ss) + } +} + func (s *ArgoRolloutsServer) newHTTPServer(ctx context.Context, port int) *http.Server { mux := http.NewServeMux() @@ -121,11 +328,23 @@ func (s *ArgoRolloutsServer) newHTTPServer(ctx context.Context, port int) *http. mux.Handle(apiPath, apiHandler) mux.HandleFunc("/", s.staticFileHttpHandler) + // Wrap the entire mux with auth middleware when in client mode + if s.Options.AuthMode == AuthModeClient { + httpS.Handler = s.newClientAuthMiddleware(mux) + } + return &httpS } func (s *ArgoRolloutsServer) newGRPCServer() *grpc.Server { - grpcS := grpc.NewServer() + var opts []grpc.ServerOption + if s.Options.AuthMode == AuthModeClient { + opts = append(opts, + grpc.UnaryInterceptor(s.newAuthUnaryInterceptor()), + grpc.StreamInterceptor(s.newAuthStreamInterceptor()), + ) + } + grpcS := grpc.NewServer(opts...) var rolloutsServer rollout.RolloutServiceServer = NewServer(s.Options) rollout.RegisterRolloutServiceServer(grpcS, rolloutsServer) return grpcS @@ -186,17 +405,17 @@ func (s *ArgoRolloutsServer) Run(ctx context.Context, port int, dashboard bool) errors.CheckError(conn.Close()) } -func (s *ArgoRolloutsServer) initRolloutViewController(namespace string, name string, ctx context.Context) *viewcontroller.RolloutViewController { - controller := viewcontroller.NewRolloutViewController(namespace, name, s.Options.KubeClientset, s.Options.RolloutsClientset) +func (s *ArgoRolloutsServer) initRolloutViewController(namespace string, name string, ctx context.Context, clients *serverClients) *viewcontroller.RolloutViewController { + controller := viewcontroller.NewRolloutViewController(namespace, name, clients.kubeClientset, clients.rolloutsClientset) controller.Start(ctx) return controller } -func (s *ArgoRolloutsServer) getRolloutInfo(namespace string, name string) (*rollout.RolloutInfo, error) { +func (s *ArgoRolloutsServer) getRolloutInfo(namespace string, name string, clients *serverClients) (*rollout.RolloutInfo, error) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - controller := s.initRolloutViewController(namespace, name, ctx) + controller := s.initRolloutViewController(namespace, name, ctx, clients) ri, err := controller.GetRolloutInfo() if err != nil { return nil, err @@ -205,14 +424,23 @@ func (s *ArgoRolloutsServer) getRolloutInfo(namespace string, name string) (*rol } // GetRolloutInfo returns a rollout -func (s *ArgoRolloutsServer) GetRolloutInfo(c context.Context, q *rollout.RolloutInfoQuery) (*rollout.RolloutInfo, error) { - return s.getRolloutInfo(q.GetNamespace(), q.GetName()) +func (s *ArgoRolloutsServer) GetRolloutInfo(ctx context.Context, q *rollout.RolloutInfoQuery) (*rollout.RolloutInfo, error) { + clients, err := s.getClients(ctx) + if err != nil { + return nil, err + } + ri, err := s.getRolloutInfo(q.GetNamespace(), q.GetName(), clients) + return ri, k8sError(err) } // WatchRolloutInfo returns a rollout stream func (s *ArgoRolloutsServer) WatchRolloutInfo(q *rollout.RolloutInfoQuery, ws rollout.RolloutService_WatchRolloutInfoServer) error { ctx := ws.Context() - controller := s.initRolloutViewController(q.GetNamespace(), q.GetName(), ctx) + clients, err := s.getClients(ctx) + if err != nil { + return err + } + controller := s.initRolloutViewController(q.GetNamespace(), q.GetName(), ctx, clients) rolloutUpdates := make(chan *rollout.RolloutInfo) controller.RegisterCallback(func(roInfo *rollout.RolloutInfo) { @@ -227,14 +455,14 @@ func (s *ArgoRolloutsServer) WatchRolloutInfo(q *rollout.RolloutInfoQuery, ws ro return nil } -func (s *ArgoRolloutsServer) ListReplicaSetsAndPods(ctx context.Context, namespace string) ([]*appsv1.ReplicaSet, []*corev1.Pod, error) { +func (s *ArgoRolloutsServer) ListReplicaSetsAndPods(ctx context.Context, namespace string, kubeClientset kubernetes.Interface) ([]*appsv1.ReplicaSet, []*corev1.Pod, error) { - allReplicaSets, err := s.Options.KubeClientset.AppsV1().ReplicaSets(namespace).List(ctx, v1.ListOptions{}) + allReplicaSets, err := kubeClientset.AppsV1().ReplicaSets(namespace).List(ctx, v1.ListOptions{}) if err != nil { return nil, nil, err } - allPods, err := s.Options.KubeClientset.CoreV1().Pods(namespace).List(ctx, v1.ListOptions{}) + allPods, err := kubeClientset.CoreV1().Pods(namespace).List(ctx, v1.ListOptions{}) if err != nil { return nil, nil, err } @@ -252,16 +480,20 @@ func (s *ArgoRolloutsServer) ListReplicaSetsAndPods(ctx context.Context, namespa // ListRolloutInfos returns a list of all rollouts func (s *ArgoRolloutsServer) ListRolloutInfos(ctx context.Context, q *rollout.RolloutInfoListQuery) (*rollout.RolloutInfoList, error) { - rolloutIf := s.Options.RolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) + clients, err := s.getClients(ctx) + if err != nil { + return nil, err + } + rolloutIf := clients.rolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) rolloutList, err := rolloutIf.List(ctx, v1.ListOptions{}) if err != nil { - return nil, err + return nil, k8sError(err) } - allReplicaSets, allPods, err := s.ListReplicaSetsAndPods(ctx, q.GetNamespace()) + allReplicaSets, allPods, err := s.ListReplicaSetsAndPods(ctx, q.GetNamespace(), clients.kubeClientset) if err != nil { - return nil, err + return nil, k8sError(err) } var riList []*rollout.RolloutInfo @@ -276,9 +508,14 @@ func (s *ArgoRolloutsServer) ListRolloutInfos(ctx context.Context, q *rollout.Ro } func (s *ArgoRolloutsServer) RestartRollout(ctx context.Context, q *rollout.RestartRolloutRequest) (*v1alpha1.Rollout, error) { - rolloutIf := s.Options.RolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) + clients, err := s.getClients(ctx) + if err != nil { + return nil, err + } + rolloutIf := clients.rolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) restartAt := time.Now().UTC() - return restart.RestartRollout(rolloutIf, q.GetName(), &restartAt) + ro, err := restart.RestartRollout(rolloutIf, q.GetName(), &restartAt) + return ro, k8sError(err) } // WatchRolloutInfos returns a stream of all rollouts @@ -293,12 +530,16 @@ func (s *ArgoRolloutsServer) WatchRolloutInfos(q *rollout.RolloutInfoListQuery, } } ctx := ws.Context() + clients, err := s.getClients(ctx) + if err != nil { + return err + } - rolloutsInformerFactory := rolloutinformers.NewSharedInformerFactoryWithOptions(s.Options.RolloutsClientset, 0, rolloutinformers.WithNamespace(q.Namespace)) + rolloutsInformerFactory := rolloutinformers.NewSharedInformerFactoryWithOptions(clients.rolloutsClientset, 0, rolloutinformers.WithNamespace(q.Namespace)) rolloutsLister := rolloutsInformerFactory.Argoproj().V1alpha1().Rollouts().Lister().Rollouts(q.Namespace) rolloutInformer := rolloutsInformerFactory.Argoproj().V1alpha1().Rollouts().Informer() - kubeInformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(s.Options.KubeClientset, 0, kubeinformers.WithNamespace(q.Namespace)) + kubeInformerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(clients.kubeClientset, 0, kubeinformers.WithNamespace(q.Namespace)) podsLister := kubeInformerFactory.Core().V1().Pods().Lister().Pods(q.GetNamespace()) rsLister := kubeInformerFactory.Apps().V1().ReplicaSets().Lister().ReplicaSets(q.GetNamespace()) kubeInformerFactory.Start(ws.Context().Done()) @@ -350,20 +591,20 @@ func (s *ArgoRolloutsServer) WatchRolloutInfos(q *rollout.RolloutInfoListQuery, } } -func (s *ArgoRolloutsServer) RolloutToRolloutInfo(ro *v1alpha1.Rollout) (*rollout.RolloutInfo, error) { - ctx := context.Background() - allReplicaSets, allPods, err := s.ListReplicaSetsAndPods(ctx, ro.Namespace) +func (s *ArgoRolloutsServer) GetNamespace(ctx context.Context, e *empty.Empty) (*rollout.NamespaceInfo, error) { + clients, err := s.getClients(ctx) if err != nil { return nil, err } - return info.NewRolloutInfo(ro, allReplicaSets, allPods, nil, nil, nil), nil -} - -func (s *ArgoRolloutsServer) GetNamespace(ctx context.Context, e *empty.Empty) (*rollout.NamespaceInfo, error) { var m = make(map[string]bool) var namespaces []string - rolloutList, err := s.Options.RolloutsClientset.ArgoprojV1alpha1().Rollouts("").List(ctx, v1.ListOptions{}) + rolloutList, err := clients.rolloutsClientset.ArgoprojV1alpha1().Rollouts("").List(ctx, v1.ListOptions{}) + // A user who is not allowed to list rollouts cluster-wide still gets a usable dashboard, so + // only a rejected *token* is fatal here. This is what makes the call usable as a login check. + if apierrors.IsUnauthorized(err) { + return nil, status.Error(codes.Unauthenticated, "the provided token was rejected by the Kubernetes API server") + } if err == nil { for _, r := range rolloutList.Items { ns := r.Namespace @@ -378,46 +619,68 @@ func (s *ArgoRolloutsServer) GetNamespace(ctx context.Context, e *empty.Empty) ( } func (s *ArgoRolloutsServer) PromoteRollout(ctx context.Context, q *rollout.PromoteRolloutRequest) (*v1alpha1.Rollout, error) { - rolloutIf := s.Options.RolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) - return promote.PromoteRollout(rolloutIf, q.GetName(), false, false, q.GetFull()) + clients, err := s.getClients(ctx) + if err != nil { + return nil, err + } + rolloutIf := clients.rolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) + ro, err := promote.PromoteRollout(rolloutIf, q.GetName(), false, false, q.GetFull()) + return ro, k8sError(err) } func (s *ArgoRolloutsServer) AbortRollout(ctx context.Context, q *rollout.AbortRolloutRequest) (*v1alpha1.Rollout, error) { - rolloutIf := s.Options.RolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) - return abort.AbortRollout(rolloutIf, q.GetName()) + clients, err := s.getClients(ctx) + if err != nil { + return nil, err + } + rolloutIf := clients.rolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) + ro, err := abort.AbortRollout(rolloutIf, q.GetName()) + return ro, k8sError(err) } -func (s *ArgoRolloutsServer) getRollout(namespace string, name string) (*v1alpha1.Rollout, error) { - rolloutsInformerFactory := rolloutinformers.NewSharedInformerFactoryWithOptions(s.Options.RolloutsClientset, 0, rolloutinformers.WithNamespace(namespace)) +func (s *ArgoRolloutsServer) getRollout(namespace string, name string, clients *serverClients) (*v1alpha1.Rollout, error) { + rolloutsInformerFactory := rolloutinformers.NewSharedInformerFactoryWithOptions(clients.rolloutsClientset, 0, rolloutinformers.WithNamespace(namespace)) cache.WaitForCacheSync(s.stopCh, rolloutsInformerFactory.Argoproj().V1alpha1().Rollouts().Informer().HasSynced) rolloutsLister := rolloutsInformerFactory.Argoproj().V1alpha1().Rollouts().Lister().Rollouts(namespace) return rolloutsLister.Get(name) } func (s *ArgoRolloutsServer) SetRolloutImage(ctx context.Context, q *rollout.SetImageRequest) (*v1alpha1.Rollout, error) { - imageString := fmt.Sprintf("%s:%s", q.GetImage(), q.GetTag()) - _, err := set.SetImage(s.Options.DynamicClientset, q.GetNamespace(), q.GetRollout(), q.GetContainer(), imageString) + clients, err := s.getClients(ctx) if err != nil { return nil, err } - return s.getRollout(q.GetNamespace(), q.GetRollout()) + imageString := fmt.Sprintf("%s:%s", q.GetImage(), q.GetTag()) + _, err = set.SetImage(clients.dynamicClientset, q.GetNamespace(), q.GetRollout(), q.GetContainer(), imageString) + if err != nil { + return nil, k8sError(err) + } + return s.getRollout(q.GetNamespace(), q.GetRollout(), clients) } func (s *ArgoRolloutsServer) UndoRollout(ctx context.Context, q *rollout.UndoRolloutRequest) (*v1alpha1.Rollout, error) { - rolloutIf := s.Options.DynamicClientset.Resource(v1alpha1.RolloutGVR).Namespace(q.GetNamespace()) - _, err := undo.RunUndoRollout(rolloutIf, s.Options.KubeClientset, q.GetRollout(), q.GetRevision()) + clients, err := s.getClients(ctx) if err != nil { return nil, err } - return s.getRollout(q.GetNamespace(), q.GetRollout()) + rolloutIf := clients.dynamicClientset.Resource(v1alpha1.RolloutGVR).Namespace(q.GetNamespace()) + _, err = undo.RunUndoRollout(rolloutIf, clients.kubeClientset, q.GetRollout(), q.GetRevision()) + if err != nil { + return nil, k8sError(err) + } + return s.getRollout(q.GetNamespace(), q.GetRollout(), clients) } func (s *ArgoRolloutsServer) RetryRollout(ctx context.Context, q *rollout.RetryRolloutRequest) (*v1alpha1.Rollout, error) { - rolloutIf := s.Options.RolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) - ro, err := retry.RetryRollout(rolloutIf, q.GetName()) + clients, err := s.getClients(ctx) if err != nil { return nil, err } + rolloutIf := clients.rolloutsClientset.ArgoprojV1alpha1().Rollouts(q.GetNamespace()) + ro, err := retry.RetryRollout(rolloutIf, q.GetName()) + if err != nil { + return nil, k8sError(err) + } return ro, nil } diff --git a/server/server_test.go b/server/server_test.go index 592b5a6b0f..5e19ac9832 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4,9 +4,31 @@ import ( "context" "net/http" "net/http/httptest" + "net/url" "testing" + "github.com/golang/protobuf/ptypes/empty" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8sfake "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/argoproj/argo-rollouts/pkg/apiclient/rollout" + "github.com/argoproj/argo-rollouts/pkg/apis/rollouts/v1alpha1" + fakeroclient "github.com/argoproj/argo-rollouts/pkg/client/clientset/versioned/fake" ) func TestNewHTTPServer(t *testing.T) { @@ -37,14 +59,11 @@ func TestNewHTTPServer(t *testing.T) { httpServer := s.newHTTPServer(ctx, port) - // Test that / route is registered req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() httpServer.Handler.ServeHTTP(w, req) - // The handler should be registered (will be handled by staticFileHttpHandler) - // The actual response will depend on static file configuration assert.NotNil(t, w.Code, "Root route should be registered") }) @@ -83,16 +102,776 @@ func TestNewHTTPServer(t *testing.T) { httpServer := s.newHTTPServer(ctx, port) - // Test that the expected API path is registered req := httptest.NewRequest(http.MethodGet, tc.expectedPath, nil) w := httptest.NewRecorder() httpServer.Handler.ServeHTTP(w, req) - // The handler should be registered (not 404) assert.NotEqual(t, http.StatusNotFound, w.Code, "API route should be registered at %s", tc.expectedPath) }) } }) + + t.Run("client auth mode wraps handler with middleware", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{ + RootPath: "", + AuthMode: AuthModeClient, + }, + } + ctx := context.Background() + httpServer := s.newHTTPServer(ctx, 8080) + + // API route without token should get 401 + req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) + w := httptest.NewRecorder() + httpServer.Handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + + // Static route without token should pass through + req = httptest.NewRequest(http.MethodGet, "/", nil) + w = httptest.NewRecorder() + httpServer.Handler.ServeHTTP(w, req) + assert.NotEqual(t, http.StatusUnauthorized, w.Code) + }) +} + +func TestNewGRPCServer(t *testing.T) { + t.Run("server mode creates server without interceptors", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeServer}, + } + grpcS := s.newGRPCServer() + assert.NotNil(t, grpcS) + }) + + t.Run("client mode creates server with interceptors", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + grpcS := s.newGRPCServer() + assert.NotNil(t, grpcS) + }) +} + +func TestExtractBearerToken(t *testing.T) { + tests := []struct { + name string + header string + expected string + }{ + {"valid bearer token", "Bearer my-token-123", "my-token-123"}, + {"empty header", "", ""}, + {"no bearer prefix", "my-token-123", ""}, + {"lowercase bearer", "bearer my-token-123", ""}, + {"bearer with no token", "Bearer ", ""}, + {"basic auth", "Basic dXNlcjpwYXNz", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractBearerToken(tt.header) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestTokenFromHTTPRequest(t *testing.T) { + t.Run("token from Authorization header", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) + req.Header.Set("Authorization", "Bearer header-token") + assert.Equal(t, "header-token", tokenFromHTTPRequest(req)) + }) + + t.Run("token from cookie", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/rollouts", nil) + req.AddCookie(&http.Cookie{Name: AuthCookieName, Value: "cookie-token"}) + assert.Equal(t, "cookie-token", tokenFromHTTPRequest(req)) + }) + + t.Run("cookie value may carry a Bearer prefix", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/rollouts", nil) + req.AddCookie(&http.Cookie{Name: AuthCookieName, Value: url.QueryEscape("Bearer cookie-token")}) + assert.Equal(t, "cookie-token", tokenFromHTTPRequest(req)) + }) + + t.Run("header takes precedence over cookie", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/rollouts", nil) + req.AddCookie(&http.Cookie{Name: AuthCookieName, Value: "cookie-token"}) + req.Header.Set("Authorization", "Bearer header-token") + assert.Equal(t, "header-token", tokenFromHTTPRequest(req)) + }) + + t.Run("token is never read from the query string", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/rollouts?token=query-token", nil) + assert.Equal(t, "", tokenFromHTTPRequest(req)) + }) + + t.Run("no token returns empty", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) + assert.Equal(t, "", tokenFromHTTPRequest(req)) + }) +} + +func TestTokenFromGRPCContext(t *testing.T) { + t.Run("token from gRPC metadata", func(t *testing.T) { + md := metadata.Pairs("authorization", "Bearer grpc-token") + ctx := metadata.NewIncomingContext(context.Background(), md) + assert.Equal(t, "grpc-token", tokenFromGRPCContext(ctx)) + }) + + t.Run("no metadata returns empty", func(t *testing.T) { + assert.Equal(t, "", tokenFromGRPCContext(context.Background())) + }) + + t.Run("no authorization header returns empty", func(t *testing.T) { + md := metadata.Pairs("content-type", "application/json") + ctx := metadata.NewIncomingContext(context.Background(), md) + assert.Equal(t, "", tokenFromGRPCContext(ctx)) + }) + + t.Run("invalid authorization format returns empty", func(t *testing.T) { + md := metadata.Pairs("authorization", "Basic dXNlcjpwYXNz") + ctx := metadata.NewIncomingContext(context.Background(), md) + assert.Equal(t, "", tokenFromGRPCContext(ctx)) + }) +} + +func TestIsAPIRoute(t *testing.T) { + tests := []struct { + name string + urlPath string + rootPath string + expected bool + }{ + {"API route no root", "/api/v1/version", "", true}, + {"API route with root", "/rollouts/api/v1/version", "rollouts", true}, + {"static file no root", "/index.html", "", false}, + {"static file with root", "/rollouts/index.html", "rollouts", false}, + {"root path", "/", "", false}, + {"root path with root", "/rollouts/", "rollouts", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, isAPIRoute(tt.urlPath, tt.rootPath)) + }) + } +} + +func TestClientAuthMiddleware(t *testing.T) { + okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + t.Run("server mode passes through without token", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeServer}, + } + handler := s.newClientAuthMiddleware(okHandler) + req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("client mode returns 401 for API route without token", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + handler := s.newClientAuthMiddleware(okHandler) + req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("client mode passes through for API route with header token", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + handler := s.newClientAuthMiddleware(okHandler) + req := httptest.NewRequest(http.MethodGet, "/api/v1/version", nil) + req.Header.Set("Authorization", "Bearer valid-token") + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + // EventSource cannot set headers, so SSE requests authenticate with the cookie. The + // middleware rewrites it into an Authorization header so the gRPC side only reads one place. + t.Run("client mode normalizes the cookie into an Authorization header", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + var seen string + handler := s.newClientAuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodGet, "/api/v1/rollouts/watch", nil) + req.AddCookie(&http.Cookie{Name: AuthCookieName, Value: "my-token"}) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "Bearer my-token", seen) + }) + + t.Run("client mode returns 401 for API route with only a query token", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + handler := s.newClientAuthMiddleware(okHandler) + req := httptest.NewRequest(http.MethodGet, "/api/v1/rollouts/watch?token=my-token", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("client mode passes through for static files without token", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + handler := s.newClientAuthMiddleware(okHandler) + req := httptest.NewRequest(http.MethodGet, "/index.html", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) + + t.Run("client mode with root path returns 401 for API route without token", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient, RootPath: "rollouts"}, + } + handler := s.newClientAuthMiddleware(okHandler) + req := httptest.NewRequest(http.MethodGet, "/rollouts/api/v1/version", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("client mode with root path passes through for static files", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient, RootPath: "rollouts"}, + } + handler := s.newClientAuthMiddleware(okHandler) + req := httptest.NewRequest(http.MethodGet, "/rollouts/index.html", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) +} + +func TestGetClients(t *testing.T) { + t.Run("server mode returns shared clients", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{ + AuthMode: AuthModeServer, + }, + } + clients, err := s.getClients(context.Background()) + assert.NoError(t, err) + assert.NotNil(t, clients) + assert.Equal(t, s.Options.KubeClientset, clients.kubeClientset) + assert.Equal(t, s.Options.RolloutsClientset, clients.rolloutsClientset) + assert.Equal(t, s.Options.DynamicClientset, clients.dynamicClientset) + }) + + t.Run("empty auth mode returns shared clients", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{}, + } + clients, err := s.getClients(context.Background()) + assert.NoError(t, err) + assert.NotNil(t, clients) + }) + + // a missing REST config must never silently downgrade client mode to the server's own + // credentials, which would run every request as the dashboard's identity + t.Run("client mode without RESTConfig fails instead of using server credentials", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{ + AuthMode: AuthModeClient, + KubeClientset: k8sfake.NewSimpleClientset(), + }, + } + md := metadata.Pairs("authorization", "Bearer test-token") + _, err := s.getClients(metadata.NewIncomingContext(context.Background(), md)) + assert.Error(t, err) + assert.Equal(t, codes.Internal, status.Code(err)) + }) + + t.Run("client mode without token returns error", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{ + AuthMode: AuthModeClient, + RESTConfig: &rest.Config{Host: "https://localhost:6443"}, + }, + } + _, err := s.getClients(context.Background()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing bearer token") + }) + + t.Run("client mode with token creates per-request clients", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{ + AuthMode: AuthModeClient, + RESTConfig: &rest.Config{Host: "https://localhost:6443"}, + }, + } + md := metadata.Pairs("authorization", "Bearer test-token") + ctx := metadata.NewIncomingContext(context.Background(), md) + clients, err := s.getClients(ctx) + assert.NoError(t, err) + assert.NotNil(t, clients) + assert.NotNil(t, clients.kubeClientset) + assert.NotNil(t, clients.rolloutsClientset) + assert.NotNil(t, clients.dynamicClientset) + // Ensure these are NOT the same as the server's shared clients + assert.NotEqual(t, s.Options.KubeClientset, clients.kubeClientset) + }) +} + +func TestConfigForToken(t *testing.T) { + // whatever credentials the dashboard itself was started with must not survive into the + // per-request config, or a user's request could be served with the dashboard's identity + s := &ArgoRolloutsServer{ + Options: ServerOptions{ + RESTConfig: &rest.Config{ + Host: "https://localhost:6443", + Username: "admin", + Password: "password", + BearerToken: "server-token", + BearerTokenFile: "/var/run/secrets/token", + TLSClientConfig: rest.TLSClientConfig{ + CertData: []byte("cert"), + CertFile: "/path/to/cert", + KeyData: []byte("key"), + KeyFile: "/path/to/key", + CAData: []byte("ca"), + }, + AuthProvider: &clientcmdapi.AuthProviderConfig{Name: "gcp"}, + ExecProvider: &clientcmdapi.ExecConfig{Command: "aws"}, + }, + }, + } + + cfg := s.configForToken("user-token") + + assert.Equal(t, "user-token", cfg.BearerToken) + assert.Empty(t, cfg.BearerTokenFile) + assert.Empty(t, cfg.Username) + assert.Empty(t, cfg.Password) + assert.Nil(t, cfg.CertData) + assert.Empty(t, cfg.CertFile) + assert.Nil(t, cfg.KeyData) + assert.Empty(t, cfg.KeyFile) + assert.Nil(t, cfg.AuthProvider) + assert.Nil(t, cfg.ExecProvider) + // the API server address and its CA still have to come from the server's config + assert.Equal(t, "https://localhost:6443", cfg.Host) + assert.Equal(t, []byte("ca"), cfg.CAData) + // and the server's own config must be left untouched + assert.Equal(t, "server-token", s.Options.RESTConfig.BearerToken) + assert.Equal(t, "admin", s.Options.RESTConfig.Username) +} + +func TestAuthUnaryInterceptor(t *testing.T) { + mockHandler := func(ctx context.Context, req any) (any, error) { + return "success", nil + } + + t.Run("server mode passes through", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeServer}, + } + interceptor := s.newAuthUnaryInterceptor() + resp, err := interceptor(context.Background(), nil, &grpc.UnaryServerInfo{}, mockHandler) + assert.NoError(t, err) + assert.Equal(t, "success", resp) + }) + + t.Run("client mode without token returns unauthenticated", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + interceptor := s.newAuthUnaryInterceptor() + _, err := interceptor(context.Background(), nil, &grpc.UnaryServerInfo{}, mockHandler) + assert.Error(t, err) + st, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) + + t.Run("client mode with token passes through", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + interceptor := s.newAuthUnaryInterceptor() + md := metadata.Pairs("authorization", "Bearer valid-token") + ctx := metadata.NewIncomingContext(context.Background(), md) + resp, err := interceptor(ctx, nil, &grpc.UnaryServerInfo{}, mockHandler) + assert.NoError(t, err) + assert.Equal(t, "success", resp) + }) +} + +// mockServerStream implements grpc.ServerStream for testing stream interceptors +type mockServerStream struct { + grpc.ServerStream + ctx context.Context +} + +func (m *mockServerStream) Context() context.Context { + return m.ctx +} + +func TestAuthStreamInterceptor(t *testing.T) { + mockHandler := func(srv any, ss grpc.ServerStream) error { + return nil + } + + t.Run("server mode passes through", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeServer}, + } + interceptor := s.newAuthStreamInterceptor() + stream := &mockServerStream{ctx: context.Background()} + err := interceptor(nil, stream, &grpc.StreamServerInfo{}, mockHandler) + assert.NoError(t, err) + }) + + t.Run("client mode without token returns unauthenticated", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + interceptor := s.newAuthStreamInterceptor() + stream := &mockServerStream{ctx: context.Background()} + err := interceptor(nil, stream, &grpc.StreamServerInfo{}, mockHandler) + assert.Error(t, err) + st, ok := status.FromError(err) + assert.True(t, ok) + assert.Equal(t, codes.Unauthenticated, st.Code()) + }) + + t.Run("client mode with token passes through", func(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{AuthMode: AuthModeClient}, + } + interceptor := s.newAuthStreamInterceptor() + md := metadata.Pairs("authorization", "Bearer valid-token") + ctx := metadata.NewIncomingContext(context.Background(), md) + stream := &mockServerStream{ctx: ctx} + err := interceptor(nil, stream, &grpc.StreamServerInfo{}, mockHandler) + assert.NoError(t, err) + }) +} + +// TestClientModeRequiresToken asserts that every handler goes through getClients, so that no +// endpoint can be reached in client mode without the caller presenting a token. +func TestClientModeRequiresToken(t *testing.T) { + s := &ArgoRolloutsServer{ + Options: ServerOptions{ + AuthMode: AuthModeClient, + RESTConfig: &rest.Config{Host: "https://localhost:6443"}, + }, + } + ctx := context.Background() + + calls := map[string]func() error{ + "GetRolloutInfo": func() error { + _, err := s.GetRolloutInfo(ctx, &rollout.RolloutInfoQuery{Name: "test", Namespace: "default"}) + return err + }, + "ListRolloutInfos": func() error { + _, err := s.ListRolloutInfos(ctx, &rollout.RolloutInfoListQuery{Namespace: "default"}) + return err + }, + "RestartRollout": func() error { + _, err := s.RestartRollout(ctx, &rollout.RestartRolloutRequest{Name: "test", Namespace: "default"}) + return err + }, + "PromoteRollout": func() error { + _, err := s.PromoteRollout(ctx, &rollout.PromoteRolloutRequest{Name: "test", Namespace: "default"}) + return err + }, + "AbortRollout": func() error { + _, err := s.AbortRollout(ctx, &rollout.AbortRolloutRequest{Name: "test", Namespace: "default"}) + return err + }, + "RetryRollout": func() error { + _, err := s.RetryRollout(ctx, &rollout.RetryRolloutRequest{Name: "test", Namespace: "default"}) + return err + }, + "SetRolloutImage": func() error { + _, err := s.SetRolloutImage(ctx, &rollout.SetImageRequest{Rollout: "test", Namespace: "default"}) + return err + }, + "UndoRollout": func() error { + _, err := s.UndoRollout(ctx, &rollout.UndoRolloutRequest{Rollout: "test", Namespace: "default"}) + return err + }, + "GetNamespace": func() error { + _, err := s.GetNamespace(ctx, &empty.Empty{}) + return err + }, + "WatchRolloutInfo": func() error { + return s.WatchRolloutInfo(&rollout.RolloutInfoQuery{Name: "test", Namespace: "default"}, &mockWatchRolloutInfoServer{ctx: ctx}) + }, + "WatchRolloutInfos": func() error { + return s.WatchRolloutInfos(&rollout.RolloutInfoListQuery{Namespace: "default"}, &mockWatchRolloutInfosServer{ctx: ctx}) + }, + } + + for name, call := range calls { + t.Run(name, func(t *testing.T) { + err := call() + assert.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) + }) + } +} + +// newFakeDynamicClient creates a dynamic fake client with the rollout scheme registered +func newFakeDynamicClient(objs ...runtime.Object) *dynamicfake.FakeDynamicClient { + _ = v1alpha1.AddToScheme(scheme.Scheme) + return dynamicfake.NewSimpleDynamicClient(scheme.Scheme, objs...) +} + +// newServerWithFakes returns an ArgoRolloutsServer in server auth mode with fake clients +func newServerWithFakes(roObjs []runtime.Object, kubeObjs []runtime.Object, dynamicObjs []runtime.Object) *ArgoRolloutsServer { + return &ArgoRolloutsServer{ + Options: ServerOptions{ + AuthMode: AuthModeServer, + Namespace: "default", + KubeClientset: k8sfake.NewSimpleClientset(kubeObjs...), + RolloutsClientset: fakeroclient.NewSimpleClientset(roObjs...), + DynamicClientset: newFakeDynamicClient(dynamicObjs...), + }, + } +} + +func TestListReplicaSetsAndPods(t *testing.T) { + t.Run("returns empty lists for empty namespace", func(t *testing.T) { + kubeClient := k8sfake.NewSimpleClientset() + s := newServerWithFakes(nil, nil, nil) + rs, pods, err := s.ListReplicaSetsAndPods(context.Background(), "default", kubeClient) + assert.NoError(t, err) + assert.Empty(t, rs) + assert.Empty(t, pods) + }) + + t.Run("returns replica sets and pods", func(t *testing.T) { + rs := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{Name: "rs-1", Namespace: "default"}, + } + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "pod-1", Namespace: "default"}, + } + kubeClient := k8sfake.NewSimpleClientset(rs, pod) + s := newServerWithFakes(nil, nil, nil) + rsList, podList, err := s.ListReplicaSetsAndPods(context.Background(), "default", kubeClient) + assert.NoError(t, err) + assert.Len(t, rsList, 1) + assert.Len(t, podList, 1) + assert.Equal(t, "rs-1", rsList[0].Name) + assert.Equal(t, "pod-1", podList[0].Name) + }) +} + +func TestListRolloutInfosServerMode(t *testing.T) { + t.Run("returns empty list when no rollouts exist", func(t *testing.T) { + s := newServerWithFakes(nil, nil, nil) + result, err := s.ListRolloutInfos(context.Background(), &rollout.RolloutInfoListQuery{Namespace: "default"}) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Empty(t, result.Rollouts) + }) + + t.Run("returns rollout infos with replica set info", func(t *testing.T) { + ro := &v1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "my-rollout", Namespace: "default", UID: "test-uid"}, + } + s := newServerWithFakes([]runtime.Object{ro}, nil, nil) + result, err := s.ListRolloutInfos(context.Background(), &rollout.RolloutInfoListQuery{Namespace: "default"}) + assert.NoError(t, err) + require.Len(t, result.Rollouts, 1) + assert.Equal(t, "my-rollout", result.Rollouts[0].ObjectMeta.Name) + }) +} + +func TestGetNamespaceServerMode(t *testing.T) { + t.Run("returns namespace info with no rollouts", func(t *testing.T) { + s := newServerWithFakes(nil, nil, nil) + ns, err := s.GetNamespace(context.Background(), &empty.Empty{}) + assert.NoError(t, err) + assert.Equal(t, "default", ns.Namespace) + assert.Empty(t, ns.AvailableNamespaces) + }) + + t.Run("returns available namespaces from rollouts", func(t *testing.T) { + ro1 := &v1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "r1", Namespace: "ns1"}, + } + ro2 := &v1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "r2", Namespace: "ns2"}, + } + ro3 := &v1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "r3", Namespace: "ns1"}, + } + s := newServerWithFakes([]runtime.Object{ro1, ro2, ro3}, nil, nil) + ns, err := s.GetNamespace(context.Background(), &empty.Empty{}) + assert.NoError(t, err) + assert.Equal(t, "default", ns.Namespace) + assert.Len(t, ns.AvailableNamespaces, 2) + assert.Contains(t, ns.AvailableNamespaces, "ns1") + assert.Contains(t, ns.AvailableNamespaces, "ns2") + }) +} + +// GetNamespace is what the UI calls to decide whether a token is usable, so it must not report +// success when the API server rejected the token, and must not fail a user who simply cannot list +// rollouts cluster-wide. +func TestGetNamespaceSurfacesRejectedToken(t *testing.T) { + newServerRejecting := func(err error) *ArgoRolloutsServer { + roClient := fakeroclient.NewSimpleClientset() + roClient.PrependReactor("list", "rollouts", func(action k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, err + }) + return &ArgoRolloutsServer{ + Options: ServerOptions{ + AuthMode: AuthModeServer, + Namespace: "default", + RolloutsClientset: roClient, + }, + } + } + + t.Run("rejected token returns unauthenticated", func(t *testing.T) { + s := newServerRejecting(apierrors.NewUnauthorized("token is invalid")) + _, err := s.GetNamespace(context.Background(), &empty.Empty{}) + assert.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) + }) + + t.Run("forbidden cluster-wide list still returns the default namespace", func(t *testing.T) { + s := newServerRejecting(apierrors.NewForbidden(v1alpha1.Resource("rollouts"), "", nil)) + ns, err := s.GetNamespace(context.Background(), &empty.Empty{}) + assert.NoError(t, err) + assert.Equal(t, "default", ns.Namespace) + assert.Empty(t, ns.AvailableNamespaces) + }) +} + +// Operating on a rollout that does not exist must reach the caller as NotFound rather than a +// generic error, so the dashboard can tell "you cannot do this" apart from "this is not there". +func TestServerModeMapsNotFound(t *testing.T) { + s := newServerWithFakes(nil, nil, nil) + ctx := context.Background() + + calls := map[string]func() error{ + "RestartRollout": func() error { + _, err := s.RestartRollout(ctx, &rollout.RestartRolloutRequest{Name: "nonexistent", Namespace: "default"}) + return err + }, + "PromoteRollout": func() error { + _, err := s.PromoteRollout(ctx, &rollout.PromoteRolloutRequest{Name: "nonexistent", Namespace: "default"}) + return err + }, + "AbortRollout": func() error { + _, err := s.AbortRollout(ctx, &rollout.AbortRolloutRequest{Name: "nonexistent", Namespace: "default"}) + return err + }, + "RetryRollout": func() error { + _, err := s.RetryRollout(ctx, &rollout.RetryRolloutRequest{Name: "nonexistent", Namespace: "default"}) + return err + }, + "SetRolloutImage": func() error { + _, err := s.SetRolloutImage(ctx, &rollout.SetImageRequest{Rollout: "nonexistent", Namespace: "default", Image: "nginx", Tag: "latest", Container: "main"}) + return err + }, + "UndoRollout": func() error { + _, err := s.UndoRollout(ctx, &rollout.UndoRolloutRequest{Rollout: "nonexistent", Namespace: "default", Revision: 0}) + return err + }, + } + + for name, call := range calls { + t.Run(name, func(t *testing.T) { + err := call() + assert.Error(t, err) + assert.Equal(t, codes.NotFound, status.Code(err)) + }) + } +} + +func TestGetRolloutInfoServerMode(t *testing.T) { + ro := &v1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "my-rollout", Namespace: "default"}, + } + s := newServerWithFakes([]runtime.Object{ro}, nil, nil) + ri, err := s.GetRolloutInfo(context.Background(), &rollout.RolloutInfoQuery{Name: "my-rollout", Namespace: "default"}) + assert.NoError(t, err) + assert.NotNil(t, ri) + assert.Equal(t, "my-rollout", ri.ObjectMeta.Name) +} + +// mockWatchRolloutInfoServer implements rollout.RolloutService_WatchRolloutInfoServer +type mockWatchRolloutInfoServer struct { + grpc.ServerStream + ctx context.Context + sent []*rollout.RolloutInfo +} + +func (m *mockWatchRolloutInfoServer) Context() context.Context { return m.ctx } +func (m *mockWatchRolloutInfoServer) Send(ri *rollout.RolloutInfo) error { + m.sent = append(m.sent, ri) + return nil +} +func (m *mockWatchRolloutInfoServer) SendMsg(msg any) error { return nil } +func (m *mockWatchRolloutInfoServer) RecvMsg(msg any) error { return nil } +func (m *mockWatchRolloutInfoServer) SetHeader(metadata.MD) error { return nil } +func (m *mockWatchRolloutInfoServer) SendHeader(metadata.MD) error { return nil } +func (m *mockWatchRolloutInfoServer) SetTrailer(metadata.MD) {} + +// mockWatchRolloutInfosServer implements rollout.RolloutService_WatchRolloutInfosServer +type mockWatchRolloutInfosServer struct { + grpc.ServerStream + ctx context.Context + sent []*rollout.RolloutWatchEvent +} + +func (m *mockWatchRolloutInfosServer) Context() context.Context { return m.ctx } +func (m *mockWatchRolloutInfosServer) Send(ev *rollout.RolloutWatchEvent) error { + m.sent = append(m.sent, ev) + return nil +} +func (m *mockWatchRolloutInfosServer) SendMsg(msg any) error { return nil } +func (m *mockWatchRolloutInfosServer) RecvMsg(msg any) error { return nil } +func (m *mockWatchRolloutInfosServer) SetHeader(metadata.MD) error { return nil } +func (m *mockWatchRolloutInfosServer) SendHeader(metadata.MD) error { return nil } +func (m *mockWatchRolloutInfosServer) SetTrailer(metadata.MD) {} + +func TestWatchRolloutInfoServerMode(t *testing.T) { + ro := &v1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{Name: "my-rollout", Namespace: "default"}, + } + s := newServerWithFakes([]runtime.Object{ro}, nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + // Cancel immediately so the watch returns quickly + cancel() + ws := &mockWatchRolloutInfoServer{ctx: ctx} + err := s.WatchRolloutInfo(&rollout.RolloutInfoQuery{Name: "my-rollout", Namespace: "default"}, ws) + assert.NoError(t, err) +} + +func TestWatchRolloutInfosServerMode(t *testing.T) { + s := newServerWithFakes(nil, nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + ws := &mockWatchRolloutInfosServer{ctx: ctx} + err := s.WatchRolloutInfos(&rollout.RolloutInfoListQuery{Namespace: "default"}, ws) + assert.NoError(t, err) } diff --git a/ui/jest.config.js b/ui/jest.config.js index 7548de4101..bbc999d17c 100644 --- a/ui/jest.config.js +++ b/ui/jest.config.js @@ -5,4 +5,13 @@ module.exports = { '^.+\\.(ts|tsx)$': 'ts-jest', }, modulePathIgnorePatterns: ['generated'], + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/src/setup-tests.ts'], + moduleNameMapper: { + '\\.(css|scss)$': 'identity-obj-proxy', + '\\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$': '/src/file-mock.js', + // pnpm gives nested packages their own React, which breaks hooks; pin everyone to one copy + '^react$': '/node_modules/react', + '^react-dom$': '/node_modules/react-dom', + }, }; diff --git a/ui/package.json b/ui/package.json index ecf320f6f0..bfeab4b187 100644 --- a/ui/package.json +++ b/ui/package.json @@ -71,6 +71,8 @@ "@fortawesome/fontawesome-free": "^6.5.1", "copy-webpack-plugin": "^6.3.2", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "identity-obj-proxy": "^3.0.0", "mini-css-extract-plugin": "^1.3.9", "css-loader": "^4.3.0", "sass-loader": "^10.2.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 84817955d6..665be545cd 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -140,9 +140,15 @@ importers: history: specifier: ^4.10.1 version: 4.10.1 + identity-obj-proxy: + specifier: ^3.0.0 + version: 3.0.0 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@12.20.13)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) + jest-environment-jsdom: + specifier: ^29.7.0 + version: 29.7.0 mini-css-extract-plugin: specifier: ^1.3.9 version: 1.6.0(webpack@5.106.2) @@ -1885,6 +1891,10 @@ packages: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + '@types/anymatch@1.3.1': resolution: {integrity: sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==} @@ -1996,6 +2006,9 @@ packages: '@types/jest@29.5.10': resolution: {integrity: sha512-tE4yxKEphEyxj9s4inideLHktW/x6DwesIwWZ9NN1FKf9zbJYsnhBoA9vrHA/IuIOKwPa5PcFBNV4lpMIOEzyQ==} + '@types/jsdom@20.0.1': + resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2086,6 +2099,9 @@ packages: '@types/testing-library__jest-dom@5.9.5': resolution: {integrity: sha512-ggn3ws+yRbOHog9GxnXiEZ/35Mow6YtPZpd7Z5mKDeZS/o7zx3yAle0ov/wjhVB5QT4N2Dt+GNoGCdqkBGCajQ==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2255,6 +2271,10 @@ packages: resolution: {integrity: sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==} deprecated: Use your platform's native atob() and btoa() methods instead + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -2262,6 +2282,9 @@ packages: acorn-globals@6.0.0: resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==} + acorn-globals@7.0.1: + resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -3098,6 +3121,9 @@ packages: cssom@0.4.4: resolution: {integrity: sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==} + cssom@0.5.0: + resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==} + cssstyle@2.3.0: resolution: {integrity: sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==} engines: {node: '>=8'} @@ -3159,6 +3185,10 @@ packages: resolution: {integrity: sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==} engines: {node: '>=10'} + data-urls@3.0.2: + resolution: {integrity: sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==} + engines: {node: '>=12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -3214,6 +3244,9 @@ packages: decimal.js@10.2.1: resolution: {integrity: sha512-KaL7+6Fw6i5A2XSnsbhm/6B+NuEA7TZ4vqxnd5tXz9sbKtrN9Srj8ab4vKVdK8YAqZO9P1kg45Y6YLoduPf+kw==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-uri-component@0.2.0: resolution: {integrity: sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==} engines: {node: '>=0.10'} @@ -3350,6 +3383,11 @@ packages: engines: {node: '>=8'} deprecated: Use your platform's native DOMException instead + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + domhandler@4.3.1: resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} engines: {node: '>= 4'} @@ -3432,6 +3470,10 @@ packages: entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + envinfo@7.8.1: resolution: {integrity: sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==} engines: {node: '>=4'} @@ -3660,10 +3702,6 @@ packages: resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} engines: {node: '>=4.0'} - estraverse@5.2.0: - resolution: {integrity: sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -3839,6 +3877,10 @@ packages: resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==} engines: {node: '>= 6'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -4039,6 +4081,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -4063,6 +4109,10 @@ packages: resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==} engines: {node: '>=10'} + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -4107,6 +4157,10 @@ packages: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + http-proxy-middleware@2.0.9: resolution: {integrity: sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==} engines: {node: '>=12.0.0'} @@ -4564,6 +4618,15 @@ packages: resolution: {integrity: sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + jest-environment-jsdom@29.7.0: + resolution: {integrity: sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jest-environment-node@27.5.1: resolution: {integrity: sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} @@ -4790,6 +4853,15 @@ packages: canvas: optional: true + jsdom@20.0.3: + resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==} + engines: {node: '>=14'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@0.5.0: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true @@ -5203,6 +5275,9 @@ packages: nwsapi@2.2.0: resolution: {integrity: sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -5338,6 +5413,9 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -6680,6 +6758,10 @@ packages: resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} engines: {node: '>=10'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.20.2: resolution: {integrity: sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==} @@ -7193,6 +7275,10 @@ packages: resolution: {integrity: sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==} engines: {node: '>=6'} + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -7203,6 +7289,10 @@ packages: resolution: {integrity: sha512-3n1qG+/5kg+jrbTzwAykB5yRYtQCTqOGKq5U5PE3b0a1/mzo6snDhjGS0zJVJunO0NrT3Dg1MLy5TjWP/UJppg==} engines: {node: '>=8'} + tr46@3.0.0: + resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==} + engines: {node: '>=12'} + tryer@1.0.1: resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==} @@ -7366,6 +7456,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + universalify@2.0.0: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} engines: {node: '>= 10.0.0'} @@ -7397,6 +7491,9 @@ packages: url-parse@1.5.1: resolution: {integrity: sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} @@ -7453,6 +7550,10 @@ packages: resolution: {integrity: sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==} engines: {node: '>=10'} + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -7483,6 +7584,10 @@ packages: resolution: {integrity: sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==} engines: {node: '>=10.4'} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-bundle-analyzer@4.4.1: resolution: {integrity: sha512-j5m7WgytCkiVBoOGavzNokBOqxe6Mma13X1asfVYtKWM3wxBiRRu1u1iG0Iol5+qp9WgyhkMmBAcvjEfJ2bdDw==} engines: {node: '>= 10.13.0'} @@ -7573,12 +7678,25 @@ packages: resolution: {integrity: sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-fetch@3.6.2: resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} whatwg-mimetype@2.3.0: resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==} + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url@11.0.0: + resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} + engines: {node: '>=12'} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -7734,6 +7852,10 @@ packages: xml-name-validator@3.0.0: resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==} + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -7941,7 +8063,7 @@ snapshots: '@babel/helper-module-imports': 7.22.15 '@babel/helper-plugin-utils': 7.22.5 '@babel/traverse': 7.29.0 - debug: 4.3.1 + debug: 4.4.3 lodash.debounce: 4.0.8 resolve: 1.22.8 semver: 6.3.1 @@ -10071,6 +10193,8 @@ snapshots: '@tootallnate/once@1.1.2': {} + '@tootallnate/once@2.0.1': {} + '@types/anymatch@1.3.1': optional: true @@ -10210,6 +10334,12 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/jsdom@20.0.1': + dependencies: + '@types/node': 12.20.13 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -10309,6 +10439,8 @@ snapshots: dependencies: '@types/jest': 29.5.10 + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': {} '@types/uglify-js@3.13.0': @@ -10543,6 +10675,8 @@ snapshots: abab@2.0.5: {} + abab@2.0.6: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -10553,6 +10687,11 @@ snapshots: acorn: 7.4.1 acorn-walk: 7.2.0 + acorn-globals@7.0.1: + dependencies: + acorn: 8.16.0 + acorn-walk: 8.1.0 + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -10578,7 +10717,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.3.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -11593,6 +11732,8 @@ snapshots: cssom@0.4.4: {} + cssom@0.5.0: {} + cssstyle@2.3.0: dependencies: cssom: 0.3.8 @@ -11647,6 +11788,12 @@ snapshots: whatwg-mimetype: 2.3.0 whatwg-url: 8.5.0 + data-urls@3.0.2: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -11687,6 +11834,8 @@ snapshots: decimal.js@10.2.1: {} + decimal.js@10.6.0: {} + decode-uri-component@0.2.0: {} dedent@0.7.0: {} @@ -11799,6 +11948,10 @@ snapshots: dependencies: webidl-conversions: 5.0.0 + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + domhandler@4.3.1: dependencies: domelementtype: 2.2.0 @@ -11874,6 +12027,8 @@ snapshots: entities@2.2.0: {} + entities@6.0.1: {} + envinfo@7.8.1: {} errno@0.1.8: @@ -12033,7 +12188,7 @@ snapshots: escodegen@2.0.0: dependencies: esprima: 4.0.1 - estraverse: 5.2.0 + estraverse: 5.3.0 esutils: 2.0.3 optionator: 0.8.3 optionalDependencies: @@ -12270,8 +12425,6 @@ snapshots: estraverse@4.3.0: {} - estraverse@5.2.0: {} - estraverse@5.3.0: {} estree-walker@1.0.1: {} @@ -12496,6 +12649,14 @@ snapshots: hasown: 2.0.3 mime-types: 2.1.35 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + forwarded@0.2.0: {} foundation-sites@6.6.3(jquery@4.0.0)(what-input@5.2.12): @@ -12696,6 +12857,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + he@1.2.0: {} history@4.10.1: @@ -12726,6 +12891,10 @@ snapshots: dependencies: whatwg-encoding: 1.0.5 + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + html-entities@2.6.0: {} html-escaper@2.0.2: {} @@ -12780,7 +12949,15 @@ snapshots: dependencies: '@tootallnate/once': 1.1.2 agent-base: 6.0.2 - debug: 4.3.1 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12807,7 +12984,7 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.3.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -13361,6 +13538,21 @@ snapshots: - supports-color - utf-8-validate + jest-environment-jsdom@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/jsdom': 20.0.1 + '@types/node': 12.20.13 + jest-mock: 29.7.0 + jest-util: 29.7.0 + jsdom: 20.0.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jest-environment-node@27.5.1: dependencies: '@jest/environment': 27.5.1 @@ -13915,6 +14107,39 @@ snapshots: - supports-color - utf-8-validate + jsdom@20.0.3: + dependencies: + abab: 2.0.6 + acorn: 8.16.0 + acorn-globals: 7.0.1 + cssom: 0.5.0 + cssstyle: 2.3.0 + data-urls: 3.0.2 + decimal.js: 10.6.0 + domexception: 4.0.0 + escodegen: 2.0.0 + form-data: 4.0.6 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 11.0.0 + ws: 8.20.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@0.5.0: {} jsesc@3.1.0: {} @@ -14276,6 +14501,8 @@ snapshots: nwsapi@2.2.0: {} + nwsapi@2.2.24: {} + object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -14442,6 +14669,10 @@ snapshots: parse5@6.0.1: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} pascal-case@3.1.2: @@ -15049,8 +15280,7 @@ snapshots: dependencies: side-channel: 1.1.0 - querystringify@2.2.0: - optional: true + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -16017,6 +16247,10 @@ snapshots: dependencies: xmlchars: 2.2.0 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.20.2: dependencies: loose-envify: 1.4.0 @@ -16655,6 +16889,13 @@ snapshots: punycode: 2.1.1 universalify: 0.1.2 + tough-cookie@4.1.4: + dependencies: + psl: 1.8.0 + punycode: 2.1.1 + universalify: 0.2.0 + url-parse: 1.5.10 + tr46@0.0.3: {} tr46@1.0.1: @@ -16665,6 +16906,10 @@ snapshots: dependencies: punycode: 2.1.1 + tr46@3.0.0: + dependencies: + punycode: 2.1.1 + tryer@1.0.1: {} ts-interface-checker@0.1.13: {} @@ -16826,6 +17071,8 @@ snapshots: universalify@0.1.2: {} + universalify@0.2.0: {} + universalify@2.0.0: {} unpipe@1.0.0: {} @@ -16853,6 +17100,11 @@ snapshots: requires-port: 1.0.0 optional: true + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + url@0.11.4: dependencies: punycode: 1.4.1 @@ -16918,6 +17170,10 @@ snapshots: dependencies: xml-name-validator: 3.0.0 + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -16945,6 +17201,8 @@ snapshots: webidl-conversions@6.1.0: {} + webidl-conversions@7.0.0: {} + webpack-bundle-analyzer@4.4.1: dependencies: acorn: 8.16.0 @@ -17099,10 +17357,21 @@ snapshots: dependencies: iconv-lite: 0.4.24 + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-fetch@3.6.2: {} whatwg-mimetype@2.3.0: {} + whatwg-mimetype@3.0.0: {} + + whatwg-url@11.0.0: + dependencies: + tr46: 3.0.0 + webidl-conversions: 7.0.0 + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -17334,6 +17603,8 @@ snapshots: xml-name-validator@3.0.0: {} + xml-name-validator@4.0.0: {} + xmlchars@2.2.0: {} xterm-addon-fit@0.5.0(xterm@4.19.0): diff --git a/ui/src/app/App.scss b/ui/src/app/App.scss index 0254cb4ac1..211d570386 100644 --- a/ui/src/app/App.scss +++ b/ui/src/app/App.scss @@ -45,3 +45,12 @@ a { background-color: $midnight-sky; } } + +.app-status { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + font-family: system-ui, sans-serif; + background-color: $argo-color-gray-3; +} diff --git a/ui/src/app/App.test.tsx b/ui/src/app/App.test.tsx new file mode 100644 index 0000000000..8d6e7f51b3 --- /dev/null +++ b/ui/src/app/App.test.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import App from './App'; +import {AUTH_COOKIE} from './shared/context/auth'; + +// The route components open EventSource streams and draw charts; neither is part of the auth flow. +jest.mock('./components/rollouts-home/rollouts-home', () => ({ + RolloutsHome: () =>
rollouts home
, +})); +jest.mock('./components/rollout/rollout', () => ({ + Rollout: () =>
rollout
, +})); + +const VALID_TOKEN = 'valid-token'; + +const json = (body: any) => new Response(JSON.stringify(body), {status: 200, headers: {'Content-Type': 'application/json'}}); +const unauthorized = () => new Response('missing bearer token', {status: 401}); + +// A stand-in for a dashboard in client auth mode: every API route needs a token that Kubernetes +// accepts, and the UI must send one on the namespace call it makes at start-up. +const clientModeServer = (accepted: string | null) => + jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const auth = new Headers(init?.headers).get('Authorization'); + const token = auth?.startsWith('Bearer ') ? auth.substring(7) : null; + if (!token) { + return unauthorized(); + } + if (accepted !== null && token !== accepted) { + return new Response('the provided token was rejected by the Kubernetes API server', {status: 401}); + } + if (String(input).endsWith('/api/v1/namespace')) { + return json({namespace: 'default', availableNamespaces: ['default']}); + } + return json({rolloutsVersion: 'v1.0.0'}); + }); + +const clearCookies = () => { + document.cookie = `${AUTH_COOKIE}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT`; +}; + +describe('App client auth mode', () => { + beforeEach(() => { + clearCookies(); + window.localStorage.clear(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + clearCookies(); + }); + + it('shows the login page when the server requires a token', async () => { + global.fetch = clientModeServer(VALID_TOKEN) as any; + + render(); + + expect(await screen.findByRole('button', {name: 'Login'})).toBeTruthy(); + }); + + it('loads the dashboard after a valid token is entered', async () => { + global.fetch = clientModeServer(VALID_TOKEN) as any; + + render(); + await userEvent.type(await screen.findByLabelText('Bearer token'), VALID_TOKEN); + await userEvent.click(screen.getByRole('button', {name: 'Login'})); + + // this is the regression: the namespace call has to carry the token, or the app renders + // a blank page forever + expect(await screen.findByText('rollouts home')).toBeTruthy(); + expect(document.cookie).toContain(`${AUTH_COOKIE}=${VALID_TOKEN}`); + }); + + it('reports an error and stays on the login page when the token is rejected', async () => { + global.fetch = clientModeServer(VALID_TOKEN) as any; + + render(); + await userEvent.type(await screen.findByLabelText('Bearer token'), 'not-a-real-token'); + await userEvent.click(screen.getByRole('button', {name: 'Login'})); + + expect(await screen.findByText(/token was rejected/i)).toBeTruthy(); + expect(screen.getByRole('button', {name: 'Login'})).toBeTruthy(); + }); + + it('returns to the login page when a stored token stops being accepted', async () => { + document.cookie = `${AUTH_COOKIE}=expired-token; Path=/`; + global.fetch = clientModeServer(VALID_TOKEN) as any; + + render(); + + expect(await screen.findByText(/token was rejected/i)).toBeTruthy(); + }); + + it('sends the token on every API request once logged in', async () => { + const fetchMock = clientModeServer(VALID_TOKEN); + document.cookie = `${AUTH_COOKIE}=${VALID_TOKEN}; Path=/`; + global.fetch = fetchMock as any; + + render(); + await screen.findByText('rollouts home'); + + expect(fetchMock).toHaveBeenCalled(); + for (const [, init] of fetchMock.mock.calls) { + expect(new Headers(init?.headers).get('Authorization')).toBe(`Bearer ${VALID_TOKEN}`); + } + }); + + it('logging out clears the cookie and returns to the login page', async () => { + document.cookie = `${AUTH_COOKIE}=${VALID_TOKEN}; Path=/`; + global.fetch = clientModeServer(VALID_TOKEN) as any; + + render(); + await screen.findByText('rollouts home'); + + await userEvent.click(screen.getByRole('button', {name: /logout/i})); + + expect(await screen.findByRole('button', {name: 'Login'})).toBeTruthy(); + expect(document.cookie).not.toContain(VALID_TOKEN); + }); +}); + +describe('App server auth mode', () => { + // the default mode is unauthenticated: no login page should ever appear + it('loads the dashboard without a token', async () => { + global.fetch = jest.fn(async (input: RequestInfo | URL) => { + if (String(input).endsWith('/api/v1/namespace')) { + return json({namespace: 'default', availableNamespaces: ['default']}); + } + return json({rolloutsVersion: 'v1.0.0'}); + }) as any; + + render(); + + expect(await screen.findByText('rollouts home')).toBeTruthy(); + expect(screen.queryByRole('button', {name: 'Login'})).toBeNull(); + }); + + it('shows the error instead of a blank page when the namespace call fails', async () => { + global.fetch = jest.fn(async () => new Response('connection refused', {status: 500})) as any; + + render(); + + expect(await screen.findByText('Could not load the dashboard')).toBeTruthy(); + expect(screen.getByText(/connection refused/)).toBeTruthy(); + }); +}); diff --git a/ui/src/app/App.tsx b/ui/src/app/App.tsx index 2fcbce4a87..86354e595f 100644 --- a/ui/src/app/App.tsx +++ b/ui/src/app/App.tsx @@ -1,15 +1,19 @@ import {Header} from './components/header/header'; +import {Login} from './components/login/login'; import {createBrowserHistory} from 'history'; import * as React from 'react'; import {KeybindingProvider} from 'react-keyhooks'; import {Route, Router, Switch} from 'react-router-dom'; import './App.scss'; -import {NamespaceContext, RolloutAPI} from './shared/context/api'; +import {NamespaceContext, RolloutAPIContext} from './shared/context/api'; +import {AuthAwareAPIProvider} from './shared/context/api'; +import {AuthContext, AuthProvider, isUnauthorized} from './shared/context/auth'; +import {describeApiError} from './shared/utils/api-error'; import {Modal} from './components/modal/modal'; import {Rollout} from './components/rollout/rollout'; import {RolloutsHome} from './components/rollouts-home/rollouts-home'; import {Shortcut, Shortcuts} from './components/shortcuts/shortcuts'; -import {ConfigProvider, notification} from 'antd'; +import {Button, ConfigProvider, Result, Spin} from 'antd'; import {theme} from '../config/theme'; const bases = document.getElementsByTagName('base'); @@ -53,64 +57,123 @@ const Page = (props: {path: string; component: React.ReactNode; exact?: boolean; export const NAMESPACE_KEY = 'namespace'; const init = window.localStorage.getItem(NAMESPACE_KEY); -const App = () => { +type LoadState = 'loading' | 'ready' | 'unauthenticated' | 'error'; + +const AppContent = () => { + const {token} = React.useContext(AuthContext); + // the API client from context carries the bearer token; the bare RolloutAPI singleton does not + const api = React.useContext(RolloutAPIContext); const [namespace, setNamespace] = React.useState(init); const [availableNamespaces, setAvailableNamespaces] = React.useState([]); + const [state, setState] = React.useState('loading'); + const [errorMessage, setErrorMessage] = React.useState(null); + const [retryCount, setRetryCount] = React.useState(0); + React.useEffect(() => { - try { - RolloutAPI.rolloutServiceGetNamespace() - .then((info) => { - if (!info) { - throw new Error(); - } - if (!namespace) { - setNamespace(info.namespace); - } - setAvailableNamespaces(info.availableNamespaces); - }) - .catch((e) => { - setAvailableNamespaces([namespace]); - }); - } catch (e) { - setAvailableNamespaces([namespace]); - console.error('Error fetching namespaces:', e); - notification.error({ - message: 'Error fetching namespaces', - description: e.message || 'An unexpected error occurred while fetching namespaces.', - duration: 8, - placement: 'bottomRight', + let cancelled = false; + setState('loading'); + setErrorMessage(null); + + api.rolloutServiceGetNamespace() + .then((info) => { + if (cancelled) { + return; + } + if (!info) { + throw new Error('The server returned an empty namespace response.'); + } + setNamespace((current) => current || info.namespace); + setAvailableNamespaces(info.availableNamespaces || []); + setState('ready'); + }) + .catch(async (e) => { + const message = await describeApiError(e); + if (cancelled) { + return; + } + if (isUnauthorized(e)) { + // a token that was present and still got a 401 was rejected by Kubernetes + setErrorMessage(token ? 'The token was rejected: it is invalid, expired, or not accepted by the Kubernetes API server.' : null); + setState('unauthenticated'); + return; + } + console.error('Error fetching namespaces:', e); + setErrorMessage(message); + setState('error'); }); - } - }, []); + + return () => { + cancelled = true; + }; + }, [api, token, retryCount]); + const changeNamespace = (val: string) => { setNamespace(val); window.localStorage.setItem(NAMESPACE_KEY, val); }; + if (state === 'unauthenticated') { + return ; + } + + if (state === 'loading') { + return ( +
+ +
+ ); + } + + // never render nothing: a blank page is indistinguishable from a hung dashboard + if (state === 'error' || !namespace) { + return ( +
+ setRetryCount((c) => c + 1)}> + Retry + + } + /> +
+ ); + } + + return ( + + + + + } + shortcuts={[ + {key: '/', description: 'Search'}, + {key: 'TAB', description: 'Search, navigate search items'}, + {key: ['fa-arrow-left', 'fa-arrow-right', 'fa-arrow-up', 'fa-arrow-down'], description: 'Navigate rollouts list', icon: true}, + {key: ['SHIFT', 'H'], description: 'Show help menu', combo: true}, + ]} + changeNamespace={changeNamespace} + /> + } changeNamespace={changeNamespace} /> + + + + + ); +}; + +const App = () => { return ( - namespace && ( - - - - - } - shortcuts={[ - {key: '/', description: 'Search'}, - {key: 'TAB', description: 'Search, navigate search items'}, - {key: ['fa-arrow-left', 'fa-arrow-right', 'fa-arrow-up', 'fa-arrow-down'], description: 'Navigate rollouts list', icon: true}, - {key: ['SHIFT', 'H'], description: 'Show help menu', combo: true}, - ]} - changeNamespace={changeNamespace} - /> - } changeNamespace={changeNamespace} /> - - - - - ) + + + + + ); }; diff --git a/ui/src/app/components/header/header.tsx b/ui/src/app/components/header/header.tsx index f1299f59d2..e4ce21cce7 100644 --- a/ui/src/app/components/header/header.tsx +++ b/ui/src/app/components/header/header.tsx @@ -3,12 +3,13 @@ import * as React from 'react'; import {useParams} from 'react-router'; import {Key, KeybindingContext} from 'react-keyhooks'; import {NamespaceContext, RolloutAPIContext} from '../../shared/context/api'; +import {AuthContext} from '../../shared/context/auth'; import './header.scss'; import {Link, useHistory} from 'react-router-dom'; import {AutoComplete, Button, Input, notification, Tooltip} from 'antd'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faBook, faKeyboard} from '@fortawesome/free-solid-svg-icons'; +import {faBook, faKeyboard, faSignOutAlt} from '@fortawesome/free-solid-svg-icons'; const Logo = () => Argo Logo; @@ -17,6 +18,7 @@ export const Header = (props: {pageHasShortcuts: boolean; changeNamespace: (val: const namespaceInfo = React.useContext(NamespaceContext); const {namespace} = useParams<{namespace: string}>(); const api = React.useContext(RolloutAPIContext); + const {token, logout} = React.useContext(AuthContext); const [version, setVersion] = React.useState('v?'); const [nsInput, setNsInput] = React.useState(namespaceInfo.namespace); const {useKeybinding} = React.useContext(KeybindingContext); @@ -81,6 +83,11 @@ export const Header = (props: {pageHasShortcuts: boolean; changeNamespace: (val: +

+ Run kubectl create token <service-account> to get a token. See the{' '} + + dashboard documentation + + . +

+ + + ); +}; diff --git a/ui/src/app/components/rollout-actions/rollout-actions.tsx b/ui/src/app/components/rollout-actions/rollout-actions.tsx index 18d0b51eeb..3946f4d163 100644 --- a/ui/src/app/components/rollout-actions/rollout-actions.tsx +++ b/ui/src/app/components/rollout-actions/rollout-actions.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import {RolloutInfo} from '../../../models/rollout/rollout'; import {NamespaceContext, RolloutAPIContext} from '../../shared/context/api'; import {formatTimestamp} from '../../shared/utils/utils'; +import {describeApiError} from '../../shared/utils/api-error'; import {RolloutStatus} from '../status-icon/status-icon'; import {ConfirmButton} from '../confirm-button/confirm-button'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; @@ -92,22 +93,14 @@ export const RolloutActionButton = (props: {action: RolloutAction; rollout: Roll const [loading, setLoading] = React.useState(false); - const handleActionError = (error: any, actionName: string) => { + const handleActionError = async (error: any, actionName: string) => { console.error(`Error executing ${actionName}:`, error); - - let errorTitle = `Failed to ${actionName.toLowerCase()} rollout`; - let errorContent = ''; - - if (error?.response?.status === 403) { - errorTitle = 'Permission Denied'; - errorContent = `You don't have permission to ${actionName.toLowerCase()} this rollout. Please check your RBAC permissions.`; - } else if (error?.response?.data?.message) { - errorContent = error.response.data.message; - } else if (error?.message) { - errorContent = error.message; - } else { - errorContent = 'An unexpected error occurred. Please try again.'; - } + + // the generated API client rejects with the raw Response, so the status and the message + // Kubernetes sent back both have to be read off it + const status = error?.status ?? error?.response?.status; + const errorTitle = status === 403 ? 'Permission Denied' : `Failed to ${actionName.toLowerCase()} rollout`; + const errorContent = await describeApiError(error); notification.error({ message: errorTitle, @@ -130,7 +123,7 @@ export const RolloutActionButton = (props: {action: RolloutAction; rollout: Roll await props.callback(); } } catch (error) { - handleActionError(error, props.action); + await handleActionError(error, props.action); } finally { setLoading(false); } diff --git a/ui/src/app/shared/context/api.tsx b/ui/src/app/shared/context/api.tsx index 671cacbca1..4bebfd4761 100644 --- a/ui/src/app/shared/context/api.tsx +++ b/ui/src/app/shared/context/api.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import {RolloutNamespaceInfo, RolloutServiceApi, Configuration} from '../../../models/rollout/generated'; +import {AuthContext, createAuthFetch} from './auth'; // Get the base path from document.baseURI // The generated API client already includes /api in its paths, so we just need the base @@ -25,4 +26,15 @@ export const APIProvider = (props: {children: React.ReactNode}) => { return {props.children}; }; +// AuthAwareAPIProvider creates an API client that injects the bearer token into all requests. +export const AuthAwareAPIProvider = (props: {children: React.ReactNode}) => { + const {token} = React.useContext(AuthContext); + const api = React.useMemo(() => { + const authFetch = createAuthFetch(token); + return new RolloutServiceApi(new Configuration({basePath: getApiBasePath()}), getApiBasePath(), authFetch); + }, [token]); + + return {props.children}; +}; + export const NamespaceContext = React.createContext({namespace: '', availableNamespaces: []}); diff --git a/ui/src/app/shared/context/auth.tsx b/ui/src/app/shared/context/auth.tsx new file mode 100644 index 0000000000..c50bcd7722 --- /dev/null +++ b/ui/src/app/shared/context/auth.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; + +// The token is kept in a cookie rather than local storage so that EventSource/SSE requests are +// authenticated too: EventSource cannot set an Authorization header, and putting the token in the +// query string leaks it into logs and browser history. This mirrors Argo Workflows +// (https://github.com/argoproj/argo-workflows/pull/2058). +export const AUTH_COOKIE = 'authorization'; + +// Scope the cookie to the dashboard's base path so a --root-path dashboard does not clobber the +// cookie of another app served from the same host. +const cookiePath = (): string => { + const path = new URL(document.baseURI).pathname; + return path === '' ? '/' : path; +}; + +export const getAuthToken = (): string | null => { + const prefix = `${AUTH_COOKIE}=`; + for (const cookie of document.cookie.split(';')) { + const trimmed = cookie.trim(); + if (trimmed.startsWith(prefix)) { + return decodeURIComponent(trimmed.substring(prefix.length)) || null; + } + } + return null; +}; + +const writeAuthCookie = (token: string | null) => { + const attrs = [`Path=${cookiePath()}`, 'SameSite=Strict']; + if (window.location.protocol === 'https:') { + attrs.push('Secure'); + } + if (!token) { + attrs.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT'); + } + // session cookie: the token is gone once the browser closes + document.cookie = `${AUTH_COOKIE}=${token ? encodeURIComponent(token) : ''}; ${attrs.join('; ')}`; +}; + +interface AuthContextType { + token: string | null; + login: (token: string) => void; + logout: () => void; +} + +export const AuthContext = React.createContext({ + token: null, + login: () => {}, + logout: () => {}, +}); + +export const AuthProvider = (props: {children: React.ReactNode}) => { + const [token, setTokenState] = React.useState(getAuthToken()); + + const setToken = React.useCallback((newToken: string | null) => { + writeAuthCookie(newToken); + setTokenState(newToken); + }, []); + + const value = React.useMemo( + () => ({ + token, + login: (newToken: string) => setToken(newToken), + logout: () => setToken(null), + }), + [token, setToken] + ); + + return {props.children}; +}; + +// createAuthFetch returns a fetch function that adds the Authorization header with the bearer +// token. The cookie alone would authenticate the request, but sending the header keeps the API +// usable from clients that do not carry cookies. +export const createAuthFetch = (token: string | null): typeof fetch => { + return (input: RequestInfo | URL, init?: RequestInit) => { + if (!token) { + return fetch(input, init); + } + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${token}`); + return fetch(input, {...init, headers}); + }; +}; + +// isUnauthorized reports whether a rejected API call failed because the request was not +// authenticated. The generated client rejects with the raw Response. +export const isUnauthorized = (e: any): boolean => e?.status === 401; diff --git a/ui/src/app/shared/services/rollout.ts b/ui/src/app/shared/services/rollout.ts index 70d45ac565..decd567010 100644 --- a/ui/src/app/shared/services/rollout.ts +++ b/ui/src/app/shared/services/rollout.ts @@ -4,6 +4,7 @@ import {RolloutInfo} from '../../../models/rollout/rollout'; import * as React from 'react'; import {NamespaceContext, RolloutAPIContext, getApiBasePath} from '../context/api'; import { notification } from 'antd'; +import {describeApiError} from '../utils/api-error'; export const useRollouts = (): RolloutInfo[] => { const api = React.useContext(RolloutAPIContext); @@ -19,7 +20,7 @@ export const useRollouts = (): RolloutInfo[] => { console.error('Error fetching rollouts:', error); notification.error({ message: 'Error fetching rollouts', - description: error.message || 'An unexpected error occurred while fetching rollouts.', + description: await describeApiError(error), duration: 8, placement: 'bottomRight', }); diff --git a/ui/src/app/shared/utils/api-error.ts b/ui/src/app/shared/utils/api-error.ts new file mode 100644 index 0000000000..ac3bd8bffe --- /dev/null +++ b/ui/src/app/shared/utils/api-error.ts @@ -0,0 +1,22 @@ +// describeApiError turns whatever the generated API client rejected with into a message that is +// worth showing a user. The generated client rejects with the raw Response, which has no `message`, +// so without this every failure renders as "undefined". +export const describeApiError = async (e: any): Promise => { + if (e instanceof Response || typeof e?.status === 'number') { + let body = ''; + try { + body = (await e.clone().text()).trim(); + } catch { + body = ''; + } + try { + const parsed = JSON.parse(body); + body = parsed.message || parsed.error || body; + } catch { + // body was not JSON, use it as-is + } + const status = `${e.status}${e.statusText ? ` ${e.statusText}` : ''}`; + return body ? `${status}: ${body}` : status; + } + return e?.message || 'An unexpected error occurred.'; +}; diff --git a/ui/src/file-mock.js b/ui/src/file-mock.js new file mode 100644 index 0000000000..86059f3629 --- /dev/null +++ b/ui/src/file-mock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/ui/src/setup-tests.ts b/ui/src/setup-tests.ts new file mode 100644 index 0000000000..9e90b1a2cc --- /dev/null +++ b/ui/src/setup-tests.ts @@ -0,0 +1,20 @@ +// jsdom implements neither of these, and antd reads both while rendering. +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null as any, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); + +(global as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} +};