notification-controller/internal/server/receiver_handler_test.go

830 lines
21 KiB
Go

/*
Copyright 2022 The Flux authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package server
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/go-github/v53/github"
"github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"github.com/fluxcd/pkg/apis/meta"
"github.com/fluxcd/pkg/runtime/logger"
apiv1 "github.com/fluxcd/notification-controller/api/v1"
)
func Test_handlePayload(t *testing.T) {
type hashOpts struct {
calculate bool
header string
}
tests := []struct {
name string
hashOpts hashOpts
headers map[string]string
payload map[string]interface{}
receiver *apiv1.Receiver
receiverType string
secret *corev1.Secret
resources []client.Object
expectedResourcesAnnotated int
expectedResponseCode int
}{
{
name: "Generic receiver",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "test-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusOK,
},
{
name: "gitlab receiver",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "gitlab-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GitLabReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
headers: map[string]string{
"X-Gitlab-Token": "token",
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusOK,
},
{
name: "cdevents receiver",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "cdevents-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.CDEventsReceiver,
Events: []string{"cd.change.merged.v1"},
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
headers: map[string]string{
"Ce-Type": "cd.change.merged.v1",
},
payload: map[string]interface{}{
"context": map[string]string{
"gitRepository": "adamkenihan/notification-controller",
"gitRevision": "5555",
"version": "0.3.0",
"id": "5555",
"source": "github",
"timestamp": "2023-12-07T14:51:29.908479495Z",
"type": "dev.cdevents.change.merged.0.1.2",
},
"subject": map[string]string{
"type": "change",
"id": "5555",
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusOK,
},
{
name: "cdevents receiver wrong event type",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "cdevents-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.CDEventsReceiver,
Events: []string{"cd.environment.modified.v1"},
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
headers: map[string]string{
"Ce-Type": "cd.change.merged.v1",
},
payload: map[string]interface{}{
"context": map[string]string{
"gitRepository": "adamkenihan/notification-controller",
"gitRevision": "5555",
"version": "0.3.0",
"id": "5555",
"source": "github",
"timestamp": "2023-12-07T14:51:29.908479495Z",
"type": "dev.cdevents.change.merged.0.1.2",
},
"subject": map[string]string{
"type": "change",
"id": "5555",
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusBadRequest,
},
{
name: "cdevents receiver no event type specified",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "cdevents-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.CDEventsReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
headers: map[string]string{
"Ce-Type": "cd.change.merged.v1",
},
payload: map[string]interface{}{
"context": map[string]string{
"gitRepository": "adamkenihan/notification-controller",
"gitRevision": "5555",
"version": "0.3.0",
"id": "5555",
"source": "github",
"timestamp": "2023-12-07T14:51:29.908479495Z",
"type": "dev.cdevents.change.merged.0.1.2",
},
"subject": map[string]string{
"type": "change",
"id": "5555",
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusOK,
},
{
name: "github receiver",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "test-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GitHubReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
hashOpts: hashOpts{
calculate: true,
header: github.SHA256SignatureHeader,
},
headers: map[string]string{
"Content-Type": "application/json",
},
payload: map[string]interface{}{
"action": "push",
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusOK,
},
{
name: "generic hmac receiver",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "generic-hmac-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericHMACReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
hashOpts: hashOpts{
calculate: true,
header: "X-Signature",
},
headers: map[string]string{
"Content-Type": "application/json",
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusOK,
},
{
name: "bitbucket receiver",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "bitbucket-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.BitbucketReceiver,
Events: []string{"push"},
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
hashOpts: hashOpts{
calculate: true,
header: github.SHA256SignatureHeader,
},
headers: map[string]string{
"Content-Type": "application/json",
"X-Event-Key": "push",
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusOK,
},
{
name: "quay receiver",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "quay-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.QuayReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
payload: map[string]interface{}{
"docker_url": "docker.io",
"updated_tags": []string{
"v0.0.1",
},
},
expectedResponseCode: http.StatusOK,
},
{
name: "harbor receiver",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "harbor-receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.HarborReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
headers: map[string]string{
"Authorization": "token",
},
expectedResponseCode: http.StatusOK,
},
{
name: "missing secret",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "missing-secret",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericReceiver,
SecretRef: meta.LocalObjectReference{
Name: "non-existing",
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
expectedResponseCode: http.StatusBadRequest,
},
{
name: "no receiver configured",
expectedResponseCode: http.StatusNotFound,
},
{
name: "not ready receiver is ignored",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "notready-receiver",
},
Spec: apiv1.ReceiverSpec{},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.StalledCondition, Status: metav1.ConditionFalse}},
},
},
expectedResponseCode: http.StatusServiceUnavailable,
},
{
name: "suspended receiver ignored",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "suspended-receiver",
},
Spec: apiv1.ReceiverSpec{
Suspend: true,
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
expectedResponseCode: http.StatusServiceUnavailable,
},
{
name: "missing apiVersion in resource",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
Resources: []apiv1.CrossNamespaceObjectReference{
{
Kind: apiv1.ReceiverKind,
MatchLabels: map[string]string{
"label": "match",
},
},
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusInternalServerError,
},
{
name: "resource by name not found",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
Resources: []apiv1.CrossNamespaceObjectReference{
{
APIVersion: apiv1.GroupVersion.String(),
Kind: apiv1.ReceiverKind,
Name: "does-not-exists",
},
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusInternalServerError,
},
{
name: "annotating resources by label match",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
Resources: []apiv1.CrossNamespaceObjectReference{
{
APIVersion: apiv1.GroupVersion.String(),
Kind: apiv1.ReceiverKind,
Name: "*",
MatchLabels: map[string]string{
"label": "match",
},
},
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
resources: []client.Object{
&apiv1.Receiver{
TypeMeta: metav1.TypeMeta{
Kind: apiv1.ReceiverKind,
APIVersion: apiv1.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "dummy-resource-2",
Labels: map[string]string{
"label": "does-not-match",
},
},
},
&apiv1.Receiver{
TypeMeta: metav1.TypeMeta{
Kind: apiv1.ReceiverKind,
APIVersion: apiv1.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "dummy-resource",
Labels: map[string]string{
"label": "match",
},
},
},
},
expectedResourcesAnnotated: 1,
expectedResponseCode: http.StatusOK,
},
{
name: "annotating resource by name",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
Resources: []apiv1.CrossNamespaceObjectReference{
{
APIVersion: apiv1.GroupVersion.String(),
Kind: apiv1.ReceiverKind,
Name: "dummy-resource",
},
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
resources: []client.Object{
&apiv1.Receiver{
TypeMeta: metav1.TypeMeta{
Kind: apiv1.ReceiverKind,
APIVersion: apiv1.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "dummy-resource-2",
},
},
&apiv1.Receiver{
TypeMeta: metav1.TypeMeta{
Kind: apiv1.ReceiverKind,
APIVersion: apiv1.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "dummy-resource",
},
},
},
expectedResourcesAnnotated: 1,
expectedResponseCode: http.StatusOK,
},
{
name: "annotating all resources if name is *",
receiver: &apiv1.Receiver{
TypeMeta: metav1.TypeMeta{
Kind: apiv1.ReceiverKind,
APIVersion: apiv1.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
Resources: []apiv1.CrossNamespaceObjectReference{
{
APIVersion: apiv1.GroupVersion.String(),
Kind: apiv1.ReceiverKind,
Name: "*",
},
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
expectedResponseCode: http.StatusInternalServerError,
},
{
name: "resource matchLabels is ignored if name is not *",
receiver: &apiv1.Receiver{
ObjectMeta: metav1.ObjectMeta{
Name: "receiver",
},
Spec: apiv1.ReceiverSpec{
Type: apiv1.GenericReceiver,
SecretRef: meta.LocalObjectReference{
Name: "token",
},
Resources: []apiv1.CrossNamespaceObjectReference{
{
APIVersion: apiv1.GroupVersion.String(),
Kind: apiv1.ReceiverKind,
Name: "dummy-resource",
MatchLabels: map[string]string{
"label": "match",
},
},
},
},
Status: apiv1.ReceiverStatus{
WebhookPath: apiv1.ReceiverWebhookPath,
Conditions: []metav1.Condition{{Type: meta.ReadyCondition, Status: metav1.ConditionTrue}},
},
},
secret: &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: "token",
},
Data: map[string][]byte{
"token": []byte("token"),
},
},
resources: []client.Object{
&apiv1.Receiver{
TypeMeta: metav1.TypeMeta{
Kind: apiv1.ReceiverKind,
APIVersion: apiv1.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "dummy-resource-2",
},
},
&apiv1.Receiver{
TypeMeta: metav1.TypeMeta{
Kind: apiv1.ReceiverKind,
APIVersion: apiv1.GroupVersion.String(),
},
ObjectMeta: metav1.ObjectMeta{
Name: "dummy-resource",
},
},
},
expectedResourcesAnnotated: 1,
expectedResponseCode: http.StatusOK,
},
}
scheme := runtime.NewScheme()
apiv1.AddToScheme(scheme)
corev1.AddToScheme(scheme)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
g := gomega.NewGomegaWithT(t)
builder := fake.NewClientBuilder()
builder.WithScheme(scheme)
if tt.receiver != nil {
builder.WithObjects(tt.receiver)
}
builder.WithObjects(tt.resources...)
builder.WithIndex(&apiv1.Receiver{}, WebhookPathIndexKey, IndexReceiverWebhookPath)
if tt.secret != nil {
builder.WithObjects(tt.secret)
}
client := builder.Build()
s := ReceiverServer{
port: "",
logger: logger.NewLogger(logger.Options{}),
kubeClient: client,
}
data, err := json.Marshal(tt.payload)
if err != nil {
t.Errorf("error marshalling test payload: '%s'", err)
}
req := httptest.NewRequest("POST", "/hook/", bytes.NewBuffer(data))
for key, val := range tt.headers {
req.Header.Set(key, val)
}
if tt.hashOpts.calculate {
mac := hmac.New(sha256.New, tt.secret.Data["token"])
_, err := mac.Write(data)
if err != nil {
t.Errorf("error writing hmac: '%s'", err)
}
req.Header.Set(tt.hashOpts.header, "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
rr := httptest.NewRecorder()
handler := s.handlePayload()
handler(rr, req)
g.Expect(rr.Result().StatusCode).To(gomega.Equal(tt.expectedResponseCode))
var allReceivers apiv1.ReceiverList
g.Expect(client.List(context.TODO(), &allReceivers)).To(gomega.Succeed())
var annotatedResources int
for _, obj := range allReceivers.Items {
if _, ok := obj.GetAnnotations()[meta.ReconcileRequestAnnotation]; ok {
annotatedResources++
}
}
g.Expect(annotatedResources).To(gomega.Equal(tt.expectedResourcesAnnotated))
})
}
}