Files
cozystack/internal/backupcontroller/credentials_projector_test.go
2026-05-28 13:49:59 +04:00

667 lines
26 KiB
Go

package backupcontroller
import (
"context"
"encoding/json"
"errors"
"fmt"
"testing"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)
func newFakeClient(objs ...client.Object) client.Client {
scheme := runtime.NewScheme()
_ = corev1.AddToScheme(scheme)
return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build()
}
func defaultCfg() BackupCredentialsConfig {
return BackupCredentialsConfig{
SourceNamespace: "cozy-backup-controller",
SourceSecretName: "bucket-cozy-backups-system-credentials",
TargetSecretName: "cozy-backups-creds",
Endpoint: "http://seaweedfs-s3.tenant-root.svc:8333",
Region: "us-east-1",
}
}
func flatSourceSecret() *corev1.Secret {
return &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "cozy-backup-controller",
Name: "bucket-cozy-backups-system-credentials",
},
Data: map[string][]byte{
"accessKey": []byte("AK"),
"secretKey": []byte("SK"),
"endpoint": []byte("seaweedfs-s3.tenant-root.svc:8333"),
"bucketName": []byte("cozy-backups"),
},
}
}
// TestIsEnabled covers each field-empty case driving the configured/
// disabled split — strategies that need cozy-backups-creds rely on this
// gate so a partial config does not silently project anything.
func TestIsEnabled(t *testing.T) {
cases := []struct {
name string
cfg BackupCredentialsConfig
want bool
}{
{"all empty", BackupCredentialsConfig{}, false},
{"source ns missing", BackupCredentialsConfig{SourceSecretName: "s", TargetSecretName: "t"}, false},
{"source name missing", BackupCredentialsConfig{SourceNamespace: "ns", TargetSecretName: "t"}, false},
{"target missing", BackupCredentialsConfig{SourceNamespace: "ns", SourceSecretName: "s"}, false},
{"complete", BackupCredentialsConfig{SourceNamespace: "ns", SourceSecretName: "s", TargetSecretName: "t"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.cfg.IsEnabled(); got != tc.want {
t.Fatalf("got %v want %v", got, tc.want)
}
})
}
}
// TestProject_HappyPath_FlatKeys covers the human-friendly Secret format
// rendered by packages/system/bucket/templates/user-credentials.yaml.
// Asserts the projected Secret carries every key the supported drivers
// consume.
func TestProject_HappyPath_FlatKeys(t *testing.T) {
src := flatSourceSecret()
c := newFakeClient(src)
ctx := context.Background()
if err := ProjectBackupCredentials(ctx, c, defaultCfg(), "tenant-acme"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(ctx, types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("projected Secret not found: %v", err)
}
if got.Labels[managedByLabel] != managedByValue {
t.Fatalf("managed-by label missing or wrong: %q", got.Labels[managedByLabel])
}
for k, want := range map[string][]byte{
"AWS_ACCESS_KEY_ID": []byte("AK"),
"AWS_SECRET_ACCESS_KEY": []byte("SK"),
"accessKey": []byte("AK"),
"secretKey": []byte("SK"),
"endpoint": []byte("seaweedfs-s3.tenant-root.svc:8333"),
"bucketName": []byte("cozy-backups"),
"region": []byte("us-east-1"),
} {
if string(got.Data[k]) != string(want) {
t.Errorf("key %s: got %q want %q", k, got.Data[k], want)
}
}
if cloud := string(got.Data["cloud"]); cloud != "[default]\naws_access_key_id=AK\naws_secret_access_key=SK\n" {
t.Errorf("cloud key malformed: %q", cloud)
}
var blob struct {
Accounts map[string]struct {
APIKey string `json:"api_key"`
Secret string `json:"secret"`
} `json:"accounts"`
}
if err := json.Unmarshal(got.Data["blob_credentials.json"], &blob); err != nil {
t.Fatalf("blob_credentials.json invalid: %v", err)
}
host := "seaweedfs-s3.tenant-root.svc:8333"
if blob.Accounts[host].APIKey != "AK" || blob.Accounts[host].Secret != "SK" {
t.Fatalf("blob host record wrong: %+v", blob.Accounts)
}
}
// TestProject_EndpointFallback_StripsScheme covers the external-S3 path
// where the source Secret omits endpoint and the projector falls back to
// cfg.Endpoint (raw BACKUP_STORAGE_ENDPOINT, which carries a scheme). The
// projected `endpoint` key must be scheme-stripped, because the ClickHouse
// sidecar reads it verbatim as S3_ENDPOINT (a bare host:port).
func TestProject_EndpointFallback_StripsScheme(t *testing.T) {
src := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "cozy-backup-controller",
Name: "bucket-cozy-backups-system-credentials",
},
Data: map[string][]byte{
"accessKey": []byte("AK"),
"secretKey": []byte("SK"),
// No endpoint key: the projector must fall back to cfg.Endpoint.
"bucketName": []byte("cozy-backups"),
},
}
c := newFakeClient(src)
ctx := context.Background()
// defaultCfg().Endpoint carries an http:// scheme.
if err := ProjectBackupCredentials(ctx, c, defaultCfg(), "tenant-acme"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(ctx, types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("projected Secret not found: %v", err)
}
if ep := string(got.Data["endpoint"]); ep != "seaweedfs-s3.tenant-root.svc:8333" {
t.Errorf("projected endpoint not scheme-stripped: got %q want %q", ep, "seaweedfs-s3.tenant-root.svc:8333")
}
}
// TestProject_WritesForcePathStyle asserts the platform forcePathStyle knob
// is projected into the credentials Secret. It is sourced from the config
// (not the bucket Secret) so the ClickHouse sidecar — which cannot read
// backupStorage.* — can consume it as S3_FORCE_PATH_STYLE via secretKeyRef.
func TestProject_WritesForcePathStyle(t *testing.T) {
src := flatSourceSecret()
c := newFakeClient(src)
ctx := context.Background()
cfg := defaultCfg()
cfg.ForcePathStyle = "false"
if err := ProjectBackupCredentials(ctx, c, cfg, "tenant-acme"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(ctx, types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("projected Secret not found: %v", err)
}
if v := string(got.Data["forcePathStyle"]); v != "false" {
t.Errorf("forcePathStyle key: got %q want %q", v, "false")
}
}
// TestProject_HappyPath_BucketInfo covers the raw COSI Secret format
// (single BucketInfo JSON blob) — the fallback that lets the cluster
// keep working when the user-credentials renderer has not run yet, or
// when an admin wires an external S3 Secret manually.
func TestProject_HappyPath_BucketInfo(t *testing.T) {
bucketInfo := []byte(`{
"spec": {
"bucketName": "external-backups",
"secretS3": {
"accessKeyID": "ROOT",
"accessSecretKey": "SHHH",
"endpoint": "https://s3.example.com",
"region": "eu-west-1"
}
}
}`)
src := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "cozy-backup-controller",
Name: "bucket-cozy-backups-system-credentials",
},
Data: map[string][]byte{"BucketInfo": bucketInfo},
}
c := newFakeClient(src)
ctx := context.Background()
if err := ProjectBackupCredentials(ctx, c, defaultCfg(), "tenant-acme"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(ctx, types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("projected Secret not found: %v", err)
}
if string(got.Data["accessKey"]) != "ROOT" || string(got.Data["secretKey"]) != "SHHH" {
t.Fatalf("bucketInfo flat keys not projected: %s/%s", got.Data["accessKey"], got.Data["secretKey"])
}
if string(got.Data["endpoint"]) != "s3.example.com" {
t.Errorf("endpoint scheme not stripped: %s", got.Data["endpoint"])
}
if string(got.Data["region"]) != "eu-west-1" {
t.Errorf("region from BucketInfo lost: %s", got.Data["region"])
}
if string(got.Data["bucketName"]) != "external-backups" {
t.Errorf("bucketName from BucketInfo lost: %s", got.Data["bucketName"])
}
}
// TestProject_SourceMissing surfaces a transient ProjectionError so
// reconcilers can requeue rather than terminally fail BackupJobs while
// the bucket controller catches up.
func TestProject_SourceMissing(t *testing.T) {
c := newFakeClient()
err := ProjectBackupCredentials(context.Background(), c, defaultCfg(), "tenant-acme")
var perr *ProjectionError
if !errors.As(err, &perr) {
t.Fatalf("expected ProjectionError, got %T: %v", err, err)
}
if perr.Reason != ReasonSourceMissing {
t.Fatalf("expected reason %s, got %s", ReasonSourceMissing, perr.Reason)
}
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err == nil {
t.Fatalf("target should remain absent on transient failure")
}
}
// TestProject_SourceMalformed_NoKeys exercises a Secret that exists but
// carries neither flat keys nor a BucketInfo blob. Terminal — reconcilers
// must surface the operator misconfiguration rather than retry forever.
func TestProject_SourceMalformed_NoKeys(t *testing.T) {
src := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "cozy-backup-controller",
Name: "bucket-cozy-backups-system-credentials",
},
Data: map[string][]byte{"endpoint": []byte("x")},
}
c := newFakeClient(src)
err := ProjectBackupCredentials(context.Background(), c, defaultCfg(), "tenant-acme")
var perr *ProjectionError
if !errors.As(err, &perr) {
t.Fatalf("expected ProjectionError, got %T: %v", err, err)
}
if perr.Reason != ReasonSourceMalformed {
t.Fatalf("expected reason %s, got %s", ReasonSourceMalformed, perr.Reason)
}
}
// TestProject_RefusesUnownedTarget is the core ownership guard:
// CreateOrUpdate must not stomp on a Secret that some other actor wrote
// (manually-created, restored from snapshot, etc.). Drives review fix #3.
func TestProject_RefusesUnownedTarget(t *testing.T) {
src := flatSourceSecret()
existing := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant-acme",
Name: "cozy-backups-creds",
// No managed-by label.
},
Data: map[string][]byte{"some-key": []byte("user-data")},
}
c := newFakeClient(src, existing)
err := ProjectBackupCredentials(context.Background(), c, defaultCfg(), "tenant-acme")
var perr *ProjectionError
if !errors.As(err, &perr) {
t.Fatalf("expected ProjectionError, got %T: %v", err, err)
}
if perr.Reason != ReasonTargetNotOwned {
t.Fatalf("expected reason %s, got %s", ReasonTargetNotOwned, perr.Reason)
}
// Verify the existing Secret has not been mutated.
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got.Data["some-key"]) != "user-data" {
t.Fatalf("existing Secret was overwritten: %v", got.Data)
}
if _, leaked := got.Data["AWS_ACCESS_KEY_ID"]; leaked {
t.Fatalf("projector leaked credentials into unowned Secret")
}
}
// TestProject_RewritesOwnedTarget confirms idempotency: a Secret that
// already carries the managed-by label is updated in place, not refused.
func TestProject_RewritesOwnedTarget(t *testing.T) {
src := flatSourceSecret()
existing := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "tenant-acme",
Name: "cozy-backups-creds",
Labels: map[string]string{managedByLabel: managedByValue},
},
Data: map[string][]byte{"stale": []byte("old")},
}
c := newFakeClient(src, existing)
if err := ProjectBackupCredentials(context.Background(), c, defaultCfg(), "tenant-acme"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got.Data["AWS_ACCESS_KEY_ID"]) != "AK" {
t.Fatalf("owned target not refreshed: %v", got.Data)
}
}
// TestBuildFDBBlobCredentials_EndpointForms locks in stripScheme behaviour
// for both URL-form and bare host:port endpoints, plus the empty-endpoint
// guard that prevents writing a JSON document keyed by "".
func TestBuildFDBBlobCredentials_EndpointForms(t *testing.T) {
for _, tc := range []struct {
name string
endpoint string
wantHost string
wantErr bool
}{
{"url with scheme", "http://s3.example.com:8333", "s3.example.com:8333", false},
{"https url", "https://s3.example.com", "s3.example.com", false},
{"bare host:port", "s3.example.com:8333", "s3.example.com:8333", false},
{"empty", "", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := buildFDBBlobCredentials(tc.endpoint, "AK", "SK")
if tc.wantErr {
if err == nil {
t.Fatal("expected error for empty endpoint")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var doc struct {
Accounts map[string]struct {
APIKey string `json:"api_key"`
} `json:"accounts"`
}
if err := json.Unmarshal(data, &doc); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := doc.Accounts[tc.wantHost]; !ok {
t.Fatalf("account %q not in %+v", tc.wantHost, doc.Accounts)
}
})
}
}
// TestProject_Disabled is a sanity check: with an empty config, the
// projector must be a no-op (used by Phase-1 clusters still on the
// legacy chart-managed flow).
func TestProject_Disabled(t *testing.T) {
c := newFakeClient()
if err := ProjectBackupCredentials(context.Background(), c, BackupCredentialsConfig{}, "tenant-acme"); err != nil {
t.Fatalf("disabled projector must not error: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err == nil {
t.Fatalf("disabled projector wrote a Secret")
}
}
// TestIsTransient pins the transient-vs-terminal classification used by
// both reconcilers' handleProjectionError implementations. A regression
// here (e.g. moving ReasonAPIError into the terminal bucket) would make
// every apiserver hiccup terminally fail tenant BackupJobs.
func TestIsTransient(t *testing.T) {
cases := []struct {
err error
want bool
}{
{&ProjectionError{Reason: ReasonSourceMissing}, true},
{&ProjectionError{Reason: ReasonAPIError}, true},
{&ProjectionError{Reason: ReasonSourceMalformed}, false},
{&ProjectionError{Reason: ReasonTargetNotOwned}, false},
{nil, false},
{fmt.Errorf("plain error"), false},
}
for _, tc := range cases {
got := IsTransient(tc.err)
if got != tc.want {
t.Errorf("IsTransient(%v): got %v want %v", tc.err, got, tc.want)
}
}
}
// TestProject_PartialFlatKeys_FallsBackToBucketInfoFields covers review
// finding #8: when the flat-key Secret carries access/secret but not
// bucket/endpoint/region (the user-credentials renderer doesn't emit
// region today), the BucketInfo fallback fills the gaps. Before the fix
// the JSON was only consulted when accessKey/secretKey were absent,
// silently dropping the operator-provided coordinates.
func TestProject_PartialFlatKeys_FallsBackToBucketInfoFields(t *testing.T) {
src := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Namespace: "cozy-backup-controller",
Name: "bucket-cozy-backups-system-credentials",
},
Data: map[string][]byte{
"accessKey": []byte("AK"),
"secretKey": []byte("SK"),
"BucketInfo": []byte(`{
"spec": {
"bucketName": "from-bucketinfo",
"secretS3": {
"accessKeyID": "ignored",
"accessSecretKey": "ignored",
"endpoint": "https://nested.example.com",
"region": "eu-central-1"
}
}
}`),
},
}
c := newFakeClient(src)
cfg := defaultCfg()
cfg.Endpoint = "" // ensure fallback to BucketInfo, not config
cfg.Region = ""
if err := ProjectBackupCredentials(context.Background(), c, cfg, "tenant-acme"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("projected Secret not found: %v", err)
}
// Flat keys win: the JSON access/secret were ignored.
if string(got.Data["accessKey"]) != "AK" || string(got.Data["secretKey"]) != "SK" {
t.Errorf("flat keys overridden by JSON: %q/%q", got.Data["accessKey"], got.Data["secretKey"])
}
// Missing flat fields backfilled from BucketInfo.
if string(got.Data["endpoint"]) != "nested.example.com" {
t.Errorf("endpoint not backfilled from BucketInfo (scheme stripped): %q", got.Data["endpoint"])
}
if string(got.Data["region"]) != "eu-central-1" {
t.Errorf("region not backfilled from BucketInfo: %q", got.Data["region"])
}
if string(got.Data["bucketName"]) != "from-bucketinfo" {
t.Errorf("bucketName not backfilled from BucketInfo: %q", got.Data["bucketName"])
}
}
// TestProject_RemovesStaleOptionalKeys covers blocker #4 from round 4
// review: when the source Secret loses an optional field (region,
// endpoint, bucketName) on a re-projection, the projector MUST delete
// the stale value from the target, not silently keep the previous one.
// Consumers that read those keys via secretKeyRef would otherwise pick
// up half-rotated coordinates after the bucket-controller renders without
// a region.
func TestProject_RemovesStaleOptionalKeys(t *testing.T) {
src := flatSourceSecret()
src.Data["region"] = []byte("eu-west-1")
c := newFakeClient(src)
cfg := defaultCfg()
cfg.Region = ""
// First projection writes region.
if err := ProjectBackupCredentials(context.Background(), c, cfg, "tenant-acme"); err != nil {
t.Fatalf("initial projection: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("get projected: %v", err)
}
if string(got.Data["region"]) != "eu-west-1" {
t.Fatalf("initial region not written: %q", got.Data["region"])
}
// Source rotates without region; re-project.
src2 := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: src.Namespace, Name: src.Name}, src2); err != nil {
t.Fatalf("get source: %v", err)
}
delete(src2.Data, "region")
src2.Data["bucketName"] = []byte("") // also exercise empty-string semantics
if err := c.Update(context.Background(), src2); err != nil {
t.Fatalf("rotate source: %v", err)
}
if err := ProjectBackupCredentials(context.Background(), c, cfg, "tenant-acme"); err != nil {
t.Fatalf("re-projection: %v", err)
}
got2 := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got2); err != nil {
t.Fatalf("get re-projected: %v", err)
}
if _, present := got2.Data["region"]; present {
t.Errorf("stale region survived re-projection: %q", got2.Data["region"])
}
if _, present := got2.Data["bucketName"]; present {
t.Errorf("stale bucketName survived re-projection: %q", got2.Data["bucketName"])
}
// Access/secret keys remain (they are required, never dropped).
if string(got2.Data["AWS_ACCESS_KEY_ID"]) != "AK" {
t.Errorf("access key got dropped on re-projection: %q", got2.Data["AWS_ACCESS_KEY_ID"])
}
}
// TestProject_FailsWhenEndpointMissingEverywhere covers round-7 blocker #6:
// if both the source Secret's endpoint key and BackupCredentialsConfig.Endpoint
// are empty, the projector MUST surface ReasonSourceMalformed instead of
// writing a half-broken Secret (Velero BSL with s3Url="" silently rejects
// Backups, FDB blob_credentials cannot be rendered without a host).
func TestProject_FailsWhenEndpointMissingEverywhere(t *testing.T) {
src := flatSourceSecret()
delete(src.Data, "endpoint")
c := newFakeClient(src)
cfg := defaultCfg()
cfg.Endpoint = ""
err := ProjectBackupCredentials(context.Background(), c, cfg, "tenant-acme")
var perr *ProjectionError
if !errors.As(err, &perr) {
t.Fatalf("expected ProjectionError, got %T: %v", err, err)
}
if perr.Reason != ReasonSourceMalformed {
t.Fatalf("expected %s, got %s", ReasonSourceMalformed, perr.Reason)
}
// Target must NOT exist — half-broken Secret would be worse than no Secret.
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err == nil {
t.Fatalf("target Secret was created despite missing endpoint")
}
}
// TestSystemCredentialsProjector_NeedsLeaderElection covers round-7
// blocker #7: with replicas=2 on the controller deployment, the periodic
// projector must run on the elected leader only — otherwise both replicas
// race to project the same Secret every minute, doubling apiserver load
// and inflating the success counter with duplicate increments.
func TestSystemCredentialsProjector_NeedsLeaderElection(t *testing.T) {
p := NewSystemCredentialsProjector(nil, BackupCredentialsConfig{}, "", 0)
if !p.NeedLeaderElection() {
t.Fatal("SystemCredentialsProjector must opt into leader election")
}
}
// TestProject_Idempotent_NoSecondaryWrite covers round-9 blocker #2:
// re-projection of an unchanged source must not bump the target's
// ResourceVersion, otherwise every reconcile pass would burn an
// apiserver Update against etcd. controller-runtime's CreateOrUpdate
// already does an equality.Semantic.DeepEqual short-circuit; the test
// makes that contract explicit so a future regression in the mutator
// (e.g. someone adding a random label) fails loudly.
func TestProject_Idempotent_NoSecondaryWrite(t *testing.T) {
src := flatSourceSecret()
c := newFakeClient(src)
if err := ProjectBackupCredentials(context.Background(), c, defaultCfg(), "tenant-acme"); err != nil {
t.Fatalf("initial projection: %v", err)
}
first := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, first); err != nil {
t.Fatalf("get first: %v", err)
}
if err := ProjectBackupCredentials(context.Background(), c, defaultCfg(), "tenant-acme"); err != nil {
t.Fatalf("re-projection: %v", err)
}
second := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, second); err != nil {
t.Fatalf("get second: %v", err)
}
if first.ResourceVersion != second.ResourceVersion {
t.Errorf("re-projection bumped ResourceVersion: first=%s second=%s — projector is not idempotent and burns apiserver Updates", first.ResourceVersion, second.ResourceVersion)
}
}
// TestStripScheme_Forms covers round-9 blocker #5: url.Parse on a bare
// "host:port" treats the port as a URL scheme and returns an empty
// u.Host, which the pre-fix code silently round-tripped — fine, but it
// also stripped paths from full URLs without preserving them. Pin both
// modes here.
func TestStripScheme_Forms(t *testing.T) {
cases := []struct {
in string
want string
}{
{"http://host:8333", "host:8333"},
{"https://host:8333", "host:8333"},
{"host:8333", "host:8333"},
{"seaweedfs-s3.tenant-root.svc:8333", "seaweedfs-s3.tenant-root.svc:8333"},
{"https://example.com/path/", "example.com/path/"},
{"https://example.com", "example.com"},
{"", ""},
}
for _, tc := range cases {
got := stripScheme(tc.in)
if got != tc.want {
t.Errorf("stripScheme(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
// TestProject_GarbledBucketInfo_DoesNotFailWhenFlatKeysValid covers
// round-9 blocker #8: an unrelated stray BucketInfo blob (e.g. left over
// from a copy-paste of an old COSI Secret) should not terminally fail
// the projection when the flat keys are already authoritative. Pre-fix
// the JSON unmarshal error was returned unconditionally.
func TestProject_GarbledBucketInfo_DoesNotFailWhenFlatKeysValid(t *testing.T) {
src := flatSourceSecret()
src.Data["BucketInfo"] = []byte("not-json-at-all{")
c := newFakeClient(src)
if err := ProjectBackupCredentials(context.Background(), c, defaultCfg(), "tenant-acme"); err != nil {
t.Fatalf("flat-key path must succeed despite garbled BucketInfo, got %v", err)
}
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("projected Secret missing: %v", err)
}
if string(got.Data["accessKey"]) != "AK" {
t.Errorf("flat keys not projected: %q", got.Data["accessKey"])
}
}
// TestProject_HappyPath_HTTPEndpoint covers review finding #9: the
// bucket chart now strips both http:// and https:// from the endpoint
// it materialises. Test the projector's stripScheme path explicitly for
// an http://-prefixed value coming through the flat-key path so a future
// regression in either layer fails loudly.
func TestProject_HappyPath_HTTPEndpoint(t *testing.T) {
src := flatSourceSecret()
src.Data["endpoint"] = []byte("http://seaweedfs-s3.tenant-root.svc:8333")
c := newFakeClient(src)
if err := ProjectBackupCredentials(context.Background(), c, defaultCfg(), "tenant-acme"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := &corev1.Secret{}
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "tenant-acme", Name: "cozy-backups-creds"}, got); err != nil {
t.Fatalf("projected Secret not found: %v", err)
}
if string(got.Data["endpoint"]) != "seaweedfs-s3.tenant-root.svc:8333" {
t.Errorf("http scheme not stripped: %q", got.Data["endpoint"])
}
// blob_credentials.json account key must also be the bare host.
var blob struct {
Accounts map[string]any `json:"accounts"`
}
if err := json.Unmarshal(got.Data["blob_credentials.json"], &blob); err != nil {
t.Fatalf("invalid blob_credentials.json: %v", err)
}
if _, ok := blob.Accounts["seaweedfs-s3.tenant-root.svc:8333"]; !ok {
t.Errorf("blob account host not stripped: %+v", blob.Accounts)
}
}