Files
cozystack/pkg/registry/apps/application/rest_write_options_test.go
Yan Bondarenko 29351bd7fa fix(registry): report aggregated resource identity in errors
Backing resource names are not part of the aggregated API contract.
Clients need the requested resource identity and the original status
code and reason to handle rejected writes consistently.

Signed-off-by: Yan Bondarenko <202671653+yankawai@users.noreply.github.com>
Assisted-by: LLM
2026-09-18 17:30:33 +04:00

316 lines
12 KiB
Go

/*
Copyright 2026 The Cozystack 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 application
import (
"context"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
helmv2 "github.com/fluxcd/helm-controller/api/v2"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apiserver/pkg/endpoints/handlers/responsewriters"
"k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/apiserver/pkg/registry/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
appsv1alpha1 "github.com/cozystack/cozystack/pkg/apis/apps/v1alpha1"
"github.com/cozystack/cozystack/pkg/config"
)
const optionsTestNamespace = "tenant-options"
func newOptionsTestREST(t *testing.T, funcs interceptor.Funcs, objects ...client.Object) *REST {
t.Helper()
scheme := runtime.NewScheme()
if err := cozyv1alpha1.AddToScheme(scheme); err != nil {
t.Fatalf("register Cozystack API scheme: %v", err)
}
if err := helmv2.AddToScheme(scheme); err != nil {
t.Fatalf("register HelmRelease scheme: %v", err)
}
c := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(objects...).
WithInterceptorFuncs(funcs).
Build()
return NewREST(c, nil, &config.Resource{
Application: config.ApplicationConfig{
Kind: "PostgreSQL",
Plural: "postgresqls",
Singular: "postgresql",
},
Release: config.ReleaseConfig{Prefix: "postgresql-"},
})
}
func optionsTestApplication() *appsv1alpha1.Application {
return &appsv1alpha1.Application{
TypeMeta: metav1.TypeMeta{APIVersion: "apps.cozystack.io/v1alpha1", Kind: "PostgreSQL"},
ObjectMeta: metav1.ObjectMeta{
Name: "example",
Namespace: optionsTestNamespace,
},
}
}
func optionsTestHelmRelease() *helmv2.HelmRelease {
return &helmv2.HelmRelease{ObjectMeta: metav1.ObjectMeta{
Name: "postgresql-example",
Namespace: optionsTestNamespace,
Labels: map[string]string{
ApplicationKindLabel: "PostgreSQL",
ApplicationGroupLabel: appsv1alpha1.GroupName,
ApplicationNameLabel: "example",
},
}}
}
func captureCreateOptions(opts ...client.CreateOption) *metav1.CreateOptions {
converted := (&client.CreateOptions{}).ApplyOptions(opts)
return converted.AsCreateOptions().DeepCopy()
}
func captureUpdateOptions(opts ...client.UpdateOption) *metav1.UpdateOptions {
converted := (&client.UpdateOptions{}).ApplyOptions(opts)
return converted.AsUpdateOptions().DeepCopy()
}
func captureDeleteOptions(opts ...client.DeleteOption) *metav1.DeleteOptions {
converted := &client.DeleteOptions{}
for _, option := range opts {
option.ApplyToDelete(converted)
}
return converted.AsDeleteOptions().DeepCopy()
}
func TestCreatePropagatesWriteOptions(t *testing.T) {
want := &metav1.CreateOptions{
DryRun: []string{metav1.DryRunAll},
FieldManager: "create-manager",
FieldValidation: "Strict",
}
expected := want.DeepCopy()
var got *metav1.CreateOptions
r := newOptionsTestREST(t, interceptor.Funcs{
Create: func(_ context.Context, _ client.WithWatch, _ client.Object, opts ...client.CreateOption) error {
got = captureCreateOptions(opts...)
return nil
},
})
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
if _, err := r.Create(ctx, optionsTestApplication(), nil, want); err != nil {
t.Fatalf("Create returned error: %v", err)
}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("Create options lost in controller-runtime conversion:\n got: %#v\nwant: %#v", got, expected)
}
}
func TestUpdatePropagatesWriteOptions(t *testing.T) {
want := &metav1.UpdateOptions{
DryRun: []string{metav1.DryRunAll},
FieldManager: "update-manager",
FieldValidation: "Warn",
}
expected := want.DeepCopy()
var got *metav1.UpdateOptions
r := newOptionsTestREST(t, interceptor.Funcs{
Update: func(_ context.Context, _ client.WithWatch, _ client.Object, opts ...client.UpdateOption) error {
got = captureUpdateOptions(opts...)
return nil
},
}, optionsTestHelmRelease())
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
if _, _, err := r.Update(ctx, "example", rest.DefaultUpdatedObjectInfo(optionsTestApplication()), nil, nil, false, want); err != nil {
t.Fatalf("Update returned error: %v", err)
}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("Update options lost in controller-runtime conversion:\n got: %#v\nwant: %#v", got, expected)
}
}
func TestUpdateForceCreatePropagatesWriteOptions(t *testing.T) {
update := &metav1.UpdateOptions{
DryRun: []string{metav1.DryRunAll},
FieldManager: "apply-manager",
FieldValidation: "Strict",
}
want := &metav1.CreateOptions{
DryRun: update.DryRun,
FieldManager: update.FieldManager,
FieldValidation: update.FieldValidation,
}
var got *metav1.CreateOptions
r := newOptionsTestREST(t, interceptor.Funcs{
Create: func(_ context.Context, _ client.WithWatch, _ client.Object, opts ...client.CreateOption) error {
got = captureCreateOptions(opts...)
return nil
},
})
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
if _, _, err := r.Update(ctx, "example", rest.DefaultUpdatedObjectInfo(optionsTestApplication()), nil, nil, true, update); err != nil {
t.Fatalf("force-create Update returned error: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("force-create options lost in controller-runtime conversion:\n got: %#v\nwant: %#v", got, want)
}
}
func TestDeletePropagatesWriteOptions(t *testing.T) {
grace := int64(3)
propagation := metav1.DeletePropagationForeground
uid := types.UID("expected-uid")
rv := "17"
want := &metav1.DeleteOptions{
GracePeriodSeconds: &grace,
Preconditions: &metav1.Preconditions{UID: &uid, ResourceVersion: &rv},
PropagationPolicy: &propagation,
DryRun: []string{metav1.DryRunAll},
}
expected := want.DeepCopy()
var got *metav1.DeleteOptions
r := newOptionsTestREST(t, interceptor.Funcs{
Delete: func(_ context.Context, _ client.WithWatch, _ client.Object, opts ...client.DeleteOption) error {
got = captureDeleteOptions(opts...)
return nil
},
}, optionsTestHelmRelease())
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
if _, _, err := r.Delete(ctx, "example", nil, want); err != nil {
t.Fatalf("Delete returned error: %v", err)
}
if !reflect.DeepEqual(got, expected) {
t.Fatalf("Delete options lost in controller-runtime conversion:\n got: %#v\nwant: %#v", got, expected)
}
}
// assertWireStatus checks the answer the aggregated endpoint would put on the
// wire: ErrorToAPIStatus type-switches on the APIStatus interface and never
// unwraps, so a merely fmt.Errorf-wrapped backend rejection collapses to a
// generic 500 with an empty reason even while errors.Is/As still succeed.
func assertWireStatus(t *testing.T, err error, wantCode int32, wantReason metav1.StatusReason, wantContext string) {
t.Helper()
status := responsewriters.ErrorToAPIStatus(err)
if status.Code != wantCode || status.Reason != wantReason {
t.Fatalf("wire status = %d %q, want %d %q (err: %v)", status.Code, status.Reason, wantCode, wantReason, err)
}
if !strings.Contains(status.Message, wantContext) {
t.Fatalf("wire message %q lost the %q context", status.Message, wantContext)
}
if status.Details == nil || status.Details.Group != appsv1alpha1.GroupName || status.Details.Kind != "postgresqls" || status.Details.Name != "example" {
t.Fatalf("wire details = %#v, want apps.cozystack.io/postgresqls example", status.Details)
}
if !strings.HasPrefix(status.Message, `postgresqls.apps.cozystack.io "example": `) {
t.Fatalf("wire message %q does not identify the requested Application", status.Message)
}
}
func TestCreatePreservesBackendStatus(t *testing.T) {
alreadyExists := apierrors.NewAlreadyExists(
schema.GroupResource{Group: helmv2.GroupVersion.Group, Resource: "helmreleases"},
"postgresql-example",
)
r := newOptionsTestREST(t, interceptor.Funcs{
Create: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.CreateOption) error {
return alreadyExists
},
})
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
_, err := r.Create(ctx, optionsTestApplication(), nil, &metav1.CreateOptions{})
if !apierrors.IsAlreadyExists(err) {
t.Fatalf("Create returned %v, want a preserved AlreadyExists", err)
}
assertWireStatus(t, err, http.StatusConflict, metav1.StatusReasonAlreadyExists, "failed to create HelmRelease")
}
func TestUpdatePreservesBackendStatus(t *testing.T) {
forbidden := apierrors.NewForbidden(
schema.GroupResource{Group: helmv2.GroupVersion.Group, Resource: "helmreleases"},
"postgresql-example",
fmt.Errorf("rejected by admission policy"),
)
r := newOptionsTestREST(t, interceptor.Funcs{
Update: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.UpdateOption) error {
return forbidden
},
}, optionsTestHelmRelease())
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
_, _, err := r.Update(ctx, "example", rest.DefaultUpdatedObjectInfo(optionsTestApplication()), nil, nil, false, &metav1.UpdateOptions{})
if !apierrors.IsForbidden(err) {
t.Fatalf("Update returned %v, want a preserved Forbidden", err)
}
assertWireStatus(t, err, http.StatusForbidden, metav1.StatusReasonForbidden, "failed to update HelmRelease")
}
func TestUpdatePreservesStatusWhenCurrentHelmReleaseDisappears(t *testing.T) {
getCalls := 0
r := newOptionsTestREST(t, interceptor.Funcs{
Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error {
if _, ok := obj.(*helmv2.HelmRelease); ok {
getCalls++
if getCalls == 2 {
return apierrors.NewNotFound(helmv2.GroupVersion.WithResource("helmreleases").GroupResource(), key.Name)
}
}
return c.Get(ctx, key, obj, opts...)
},
Update: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.UpdateOption) error {
t.Fatal("Update wrote a HelmRelease after its current state could not be read")
return nil
},
}, optionsTestHelmRelease())
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
_, _, err := r.Update(ctx, "example", rest.DefaultUpdatedObjectInfo(optionsTestApplication()), nil, nil, false, &metav1.UpdateOptions{})
if getCalls != 2 {
t.Fatalf("HelmRelease reads = %d, want the initial read and the current-state read", getCalls)
}
if !apierrors.IsNotFound(err) {
t.Fatalf("Update returned %v, want a preserved NotFound", err)
}
assertWireStatus(t, err, http.StatusNotFound, metav1.StatusReasonNotFound, "failed to fetch current HelmRelease")
}
func TestDeletePreservesConflictError(t *testing.T) {
conflict := apierrors.NewConflict(
schema.GroupResource{Group: helmv2.GroupVersion.Group, Resource: "helmreleases"},
"postgresql-example",
fmt.Errorf("precondition failed"),
)
r := newOptionsTestREST(t, interceptor.Funcs{
Delete: func(_ context.Context, _ client.WithWatch, _ client.Object, _ ...client.DeleteOption) error {
return conflict
},
}, optionsTestHelmRelease())
ctx := request.WithNamespace(context.Background(), optionsTestNamespace)
_, _, err := r.Delete(ctx, "example", nil, &metav1.DeleteOptions{})
if !apierrors.IsConflict(err) {
t.Fatalf("Delete returned %v, want a preserved Conflict", err)
}
assertWireStatus(t, err, http.StatusConflict, metav1.StatusReasonConflict, "failed to delete HelmRelease")
}