diff --git a/modules/keycloak/cmd/keycloak-provider/moved_test.go b/modules/keycloak/cmd/keycloak-provider/moved_test.go index 7077324e..f69aa363 100644 --- a/modules/keycloak/cmd/keycloak-provider/moved_test.go +++ b/modules/keycloak/cmd/keycloak-provider/moved_test.go @@ -1,13 +1,17 @@ package main import ( + "context" + "encoding/json" "os" "path/filepath" "reflect" + "sync" "testing" "time" "git.novox.be/novox/mesh-sdk/go/provider" + "git.novox.be/novox/mesh-sdk/go/provider/harness" ) const testIssuer = "https://id.example.org/realms/Novox" @@ -69,3 +73,121 @@ func TestTheGrantPathLeavesWhenTheConsumersFileIsFilled(t *testing.T) { t.Fatal("did not leave when the file was filled") } } + +// bucketBus is the helper's bus over in-memory buckets. +type bucketBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (b *bucketBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + b.mu.Lock() + defer b.mu.Unlock() + for k, v := range b.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (b *bucketBus) Put(_ context.Context, bucket, key string, value []byte) error { + b.mu.Lock() + defer b.mu.Unlock() + if b.buckets[bucket] == nil { + b.buckets[bucket] = map[string][]byte{} + } + b.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (b *bucketBus) Delete(_ context.Context, bucket, key string) error { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.buckets[bucket], key) + return nil +} + +// grafanaContributes and grafanaNames are what grafana's plan entry carries for keycloak: its contribution, and the +// names the controller composed for it (issues 776, 792). +var grafanaContributes = map[string]any{"label": "grafana", "endpoint": "web", "callback": "/login/generic_oauth"} +var grafanaNames = map[string]string{"name": "grafana.example.org", "internal-name": "grafana.home.internal"} + +// Against a Keycloak, both paths leave the same client: the grant file through the harness, and the consumers' file +// through the SDK's moved path with keycloak's spec (issue 849). +func TestBothPathsLeaveTheSameClientInKeycloak(t *testing.T) { + grafana := provider.ConsumerIdentity("home", "grafana") + values := map[string]any{"name": grafanaNames["name"], "internal-name": grafanaNames["internal-name"]} + for k, v := range grafanaContributes { + values[k] = v + } + now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + made := func(through func(p provisioner)) Rep { + f, o := oidcWorld(t) + through(provisioner{clients: o, announce: func(string, map[string]any) {}, log: func(string, ...any) {}}) + return only(t, f, grafana) + } + viaGrant := made(func(p provisioner) { + dir := t.TempDir() + secret := filepath.Join(dir, grafana+".secret") + if err := os.WriteFile(secret, []byte("pw-"+grafana+"\n"), 0o600); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]any{"requirement": ProvisionName, "given": []map[string]any{ + {"as": grafana, "node": "home", "values": values, "secret": secret}}}) + receives := filepath.Join(dir, "mesh.json") + if err := os.WriteFile(receives, raw, 0o600); err != nil { + t.Fatal(err) + } + h := &harness.Harness{Resource: ProvisionName, Receives: receives, Adapter: p, + Now: func() time.Time { return now }, Log: func(string, ...any) {}} + h.Reconcile(ctx) + }) + viaFile := made(func(p provisioner) { + list, _ := json.Marshal([]provider.ConsumerCredential{{Consumer: "home", Module: "grafana", + Credential: provider.PairName("novox", ProvisionName, "home", "grafana", ""), Value: "pw-" + grafana}}) + file := filepath.Join(t.TempDir(), "consumers.json") + if err := os.WriteFile(file, list, 0o600); err != nil { + t.Fatal(err) + } + h := &harness.Harness{Resource: ProvisionName, Adapter: p, Node: "novox", + Now: func() time.Time { return now }, Log: func(string, ...any) {}} + run, _, _, err := moved(file, h, testIssuer) + if err != nil { + t.Fatal(err) + } + bus := &bucketBus{buckets: map[string]map[string][]byte{}} + plan, _ := json.Marshal(map[string]any{"node": "home", "module": "grafana", "provisioning": map[string]any{ + "provisions": []any{map[string]any{"provision": ProvisionName, "provider": "novox", + "provider-module": "keycloak", "contributes": grafanaContributes, "names": grafanaNames}}}}) + _ = bus.Put(ctx, provider.AssignmentsBucket, "home.grafana", plan) + pr := provider.New(provider.Options{Node: "novox", Module: "keycloak", Bus: bus, + Vault: provider.ConsumersFile{Paths: []string{file}}, + Offers: map[string]provider.Offer{ProvisionName: {Credential: provider.PerConsumer, + Serves: servedValues(testIssuer)}}, + Make: run.Make, Gone: run.Gone, Logf: func(string, ...any) {}}) + out, err := pr.Pass(ctx) + if err != nil || len(out) != 1 || out[0].Status != provider.Answered { + t.Fatalf("%+v %v", out, err) + } + }) + // Keycloak's own ids differ between two runs; nothing the mesh sets does. + for _, c := range []Rep{viaGrant, viaFile} { + delete(c, "id") + raw, _ := json.Marshal(c) + var again Rep + _ = json.Unmarshal(raw, &again) + for k := range c { + delete(c, k) + } + for k, v := range again { + c[k] = v + } + if mappers, ok := c["protocolMappers"].([]any); ok { + for _, m := range mappers { + delete(m.(map[string]any), "id") + } + } + } + if !reflect.DeepEqual(viaGrant, viaFile) { + t.Fatalf("through the file %v, through the grant %v", viaFile, viaGrant) + } +}