Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions api/v1alpha1/supabaseproject_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,11 @@ type SecretNamesStatus struct {
// +optional
AuthAdmin string `json:"authAdmin,omitempty"`

// EmailHook is the name of the send-email webhook signing secret.
// The secret contains the fixed key 'secret'.
// +optional
EmailHook string `json:"emailHook,omitempty"`

// PowersyncStoragePassword is the name of the powersync_storage role password secret
// +optional
PowersyncStoragePassword string `json:"powersyncStoragePassword,omitempty"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,11 @@ spec:
description: Authenticator is the name of the authenticator password
secret
type: string
emailHook:
description: |-
EmailHook is the name of the send-email webhook signing secret.
The secret contains the fixed key 'secret'.
type: string
jwt:
description: JWT is the name of the JWT secret
type: string
Expand Down
5 changes: 5 additions & 0 deletions config/crd/bases/supabase.guion.dev_supabaseprojects.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1544,6 +1544,11 @@ spec:
description: Authenticator is the name of the authenticator password
secret
type: string
emailHook:
description: |-
EmailHook is the name of the send-email webhook signing secret.
The secret contains the fixed key 'secret'.
type: string
jwt:
description: JWT is the name of the JWT secret
type: string
Expand Down
228 changes: 228 additions & 0 deletions internal/controller/email_hook_secret_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
package controller

import (
"bytes"
"context"
"encoding/base64"
"strings"
"testing"

appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

supabasev1alpha1 "github.com/GuionAI/cloudnative-supabase/api/v1alpha1"
)

func TestReconcileSecretsCreatesAndPreservesEmailHookSecret(t *testing.T) {
t.Parallel()

scheme := newIdempotencyTestScheme(t)
project := &supabasev1alpha1.SupabaseProject{
TypeMeta: metav1.TypeMeta{APIVersion: supabasev1alpha1.GroupVersion.String(), Kind: "SupabaseProject"},
ObjectMeta: metav1.ObjectMeta{
Name: "app", Namespace: "default", UID: "project-uid",
},
Spec: supabasev1alpha1.SupabaseProjectSpec{Auth: supabasev1alpha1.AuthSpec{
EmailHook: &supabasev1alpha1.EmailHookSpec{Enabled: true, URI: "https://email.example.com/auth"},
}},
}
objects := []client.Object{project}
for _, name := range []string{"app-jwt", "app-supabase-admin-password", "app-authenticator-password", "app-auth-admin-password"} {
objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}})
}
kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(objects...).Build()
reconciler := &SupabaseProjectReconciler{Client: kubeClient, Scheme: scheme}

if err := reconciler.reconcileSecrets(context.Background(), project); err != nil {
t.Fatal(err)
}

created := &corev1.Secret{}
key := client.ObjectKey{Name: "app-email-hook", Namespace: "default"}
if err := kubeClient.Get(context.Background(), key, created); err != nil {
t.Fatalf("get generated email hook secret: %v", err)
}
value := string(created.Data["secret"])
if !strings.HasPrefix(value, "v1,whsec_") {
t.Fatal("generated secret does not use the Standard Webhooks format")
}
if project.Status.SecretNames.EmailHook != "app-email-hook" {
t.Fatalf("status emailHook = %q", project.Status.SecretNames.EmailHook)
}

if err := reconciler.reconcileSecrets(context.Background(), project); err != nil {
t.Fatal(err)
}
preserved := &corev1.Secret{}
if err := kubeClient.Get(context.Background(), key, preserved); err != nil {
t.Fatal(err)
}
preservedValue := string(preserved.Data["secret"])
if preservedValue != value {
t.Fatal("email hook secret rotated during reconciliation")
}
}

func TestBaseSecretReconciliationPreservesEmailHookStatus(t *testing.T) {
t.Parallel()

for _, mode := range []string{"auto", "user-specified"} {
t.Run(mode, func(t *testing.T) {
t.Parallel()

scheme := newIdempotencyTestScheme(t)
project := &supabasev1alpha1.SupabaseProject{
TypeMeta: metav1.TypeMeta{APIVersion: supabasev1alpha1.GroupVersion.String(), Kind: "SupabaseProject"},
ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"},
Status: supabasev1alpha1.SupabaseProjectStatus{SecretNames: supabasev1alpha1.SecretNamesStatus{
EmailHook: "app-email-hook",
}},
}
objects := []client.Object{project}

if mode == "user-specified" {
project.Spec.Secrets = &supabasev1alpha1.SecretsSpec{
AutoGenerate: false,
JWT: "jwt", SupabaseAdmin: "supabase-admin", Authenticator: "authenticator", AuthAdmin: "auth-admin",
}
objects = append(objects,
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jwt", Namespace: "default"}, Data: map[string][]byte{"secret": {}, "anonKey": {}, "serviceKey": {}}},
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "supabase-admin", Namespace: "default"}, Data: map[string][]byte{"username": {}, "password": {}}},
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "authenticator", Namespace: "default"}, Data: map[string][]byte{"username": {}, "password": {}}},
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "auth-admin", Namespace: "default"}, Data: map[string][]byte{"username": {}, "password": {}}},
)
} else {
for _, name := range []string{"app-jwt", "app-supabase-admin-password", "app-authenticator-password", "app-auth-admin-password"} {
objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}})
}
}

kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(objects...).Build()
reconciler := &SupabaseProjectReconciler{Client: kubeClient, Scheme: scheme}
var err error
if mode == "user-specified" {
err = reconciler.reconcileUserSpecifiedSecrets(context.Background(), project)
} else {
err = reconciler.reconcileAutoGeneratedSecrets(context.Background(), project)
}
if err != nil {
t.Fatal(err)
}
if project.Status.SecretNames.EmailHook != "app-email-hook" {
t.Fatalf("status emailHook = %q, want app-email-hook", project.Status.SecretNames.EmailHook)
}
})
}
}

func TestReconcileAuthRollsDeploymentWhenEmailHookSecretChanges(t *testing.T) {
t.Parallel()

scheme := newIdempotencyTestScheme(t)
project := &supabasev1alpha1.SupabaseProject{
TypeMeta: metav1.TypeMeta{APIVersion: supabasev1alpha1.GroupVersion.String(), Kind: "SupabaseProject"},
ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"},
Spec: supabasev1alpha1.SupabaseProjectSpec{Auth: supabasev1alpha1.AuthSpec{
EmailHook: &supabasev1alpha1.EmailHookSpec{Enabled: true, URI: "https://email.example.com/auth"},
}},
}
secretNames := &supabasev1alpha1.SecretNamesStatus{
JWT: "jwt", SupabaseAdmin: "supabase-admin", Authenticator: "authenticator", AuthAdmin: "auth-admin", EmailHook: "email-hook",
}
hookSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "email-hook", Namespace: "default"},
Data: map[string][]byte{"secret": emailHookSecretValue(1)},
}
kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(project, hookSecret).Build()
reconciler := &SupabaseProjectReconciler{Client: kubeClient, Scheme: scheme}
ctx := context.Background()

if err := reconciler.reconcileAuth(ctx, project, secretNames); err != nil {
t.Fatal(err)
}
deployment := &appsv1.Deployment{}
key := client.ObjectKey{Name: "app-auth", Namespace: "default"}
if err := kubeClient.Get(ctx, key, deployment); err != nil {
t.Fatal(err)
}
firstHash := deployment.Spec.Template.Annotations["supabase.guion.dev/email-hook-secret-hash"]
if firstHash == "" {
t.Fatal("auth pod template is missing the email-hook secret hash annotation")
}

if err := kubeClient.Get(ctx, client.ObjectKeyFromObject(hookSecret), hookSecret); err != nil {
t.Fatal(err)
}
hookSecret.Data["secret"] = emailHookSecretValue(2)
if err := kubeClient.Update(ctx, hookSecret); err != nil {
t.Fatal(err)
}
if err := reconciler.reconcileAuth(ctx, project, secretNames); err != nil {
t.Fatal(err)
}
if err := kubeClient.Get(ctx, key, deployment); err != nil {
t.Fatal(err)
}
secondHash := deployment.Spec.Template.Annotations["supabase.guion.dev/email-hook-secret-hash"]
if secondHash == firstHash {
t.Fatal("auth pod template hash did not change after the email-hook secret rotated")
}
}

func TestReconcileSecretsReportsInvalidEmailHookSecretAndRecovers(t *testing.T) {
t.Parallel()

scheme := newIdempotencyTestScheme(t)
project := &supabasev1alpha1.SupabaseProject{
TypeMeta: metav1.TypeMeta{APIVersion: supabasev1alpha1.GroupVersion.String(), Kind: "SupabaseProject"},
ObjectMeta: metav1.ObjectMeta{Name: "app", Namespace: "default", UID: "project-uid"},
Spec: supabasev1alpha1.SupabaseProjectSpec{Auth: supabasev1alpha1.AuthSpec{
EmailHook: &supabasev1alpha1.EmailHookSpec{Enabled: true, URI: "https://email.example.com/auth"},
}},
Status: supabasev1alpha1.SupabaseProjectStatus{SecretNames: supabasev1alpha1.SecretNamesStatus{
EmailHook: "app-email-hook",
}},
}
objects := []client.Object{
project,
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "app-email-hook", Namespace: "default"}, Data: map[string][]byte{"secret": []byte("v1,whsec_invalid")}},
}
for _, name := range []string{"app-jwt", "app-supabase-admin-password", "app-authenticator-password", "app-auth-admin-password"} {
objects = append(objects, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}})
}
kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(project).WithObjects(objects...).Build()
reconciler := &SupabaseProjectReconciler{Client: kubeClient, Scheme: scheme}
ctx := context.Background()

if err := reconciler.reconcileSecrets(ctx, project); err == nil {
t.Fatal("reconcileSecrets() accepted an invalid email-hook secret")
}
condition := meta.FindStatusCondition(project.Status.Conditions, supabasev1alpha1.ConditionTypeSecretsReady)
if condition == nil || condition.Status != metav1.ConditionFalse {
t.Fatalf("SecretsReady condition after invalid secret = %#v, want False", condition)
}

hookSecret := &corev1.Secret{}
if err := kubeClient.Get(ctx, client.ObjectKey{Name: "app-email-hook", Namespace: "default"}, hookSecret); err != nil {
t.Fatal(err)
}
hookSecret.Data["secret"] = emailHookSecretValue(3)
if err := kubeClient.Update(ctx, hookSecret); err != nil {
t.Fatal(err)
}
if err := reconciler.reconcileSecrets(ctx, project); err != nil {
t.Fatal(err)
}
condition = meta.FindStatusCondition(project.Status.Conditions, supabasev1alpha1.ConditionTypeSecretsReady)
if condition == nil || condition.Status != metav1.ConditionTrue {
t.Fatalf("SecretsReady condition after recovery = %#v, want True", condition)
}
}

func emailHookSecretValue(fill byte) []byte {
return []byte("v1,whsec_" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{fill}, 32)))
}
Loading
Loading