From 7394216352563b4a8b29712d2df1ca5c9301bdb2 Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Wed, 29 Jul 2026 14:35:23 +0200 Subject: [PATCH 1/4] feat: add `ory get opl` Ory Network does not inline the Ory Permission Language file in the project config, it stores a pointer to it. `ory get permission-config` therefore returns `{"namespaces":{"location":"https://...bin"}}` rather than the namespaces themselves, and there was no way to read the file back: `ory update opl` could write it, but nothing could retrieve it, so the config was writable as code but not retrievable as code. `ory get opl` resolves that location and writes the file to stdout, so it can be redirected to disk and checked into version control. Reading local files is disabled when resolving, because the location comes from the API and must never make the CLI read from the developer's disk. Projects still using legacy namespace definitions get a message pointing at `ory get permission-config` instead of an unhelpful type error. Also corrects the `get permission-config` example, which advertised inline namespaces that the command never returns for an OPL-based project, and carried a stray character in the sample JSON. Closes #321 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu --- cmd/cloudx/get.go | 1 + cmd/cloudx/project/get_namespace_config.go | 110 ++++++++++++++++++ .../project/get_namespace_config_test.go | 88 ++++++++++++++ cmd/cloudx/project/get_permission_config.go | 18 +-- 4 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 cmd/cloudx/project/get_namespace_config.go create mode 100644 cmd/cloudx/project/get_namespace_config_test.go diff --git a/cmd/cloudx/get.go b/cmd/cloudx/get.go index 7eded967..74545fdf 100644 --- a/cmd/cloudx/get.go +++ b/cmd/cloudx/get.go @@ -25,6 +25,7 @@ func NewGetCmd() *cobra.Command { project.NewGetProjectCmd(), project.NewGetKratosConfigCmd(), project.NewGetKetoConfigCmd(), + project.NewGetOPLCmd(), project.NewGetOAuth2ConfigCmd(), workspace.NewGetCmd(), identity.NewGetIdentityCmd(), diff --git a/cmd/cloudx/project/get_namespace_config.go b/cmd/cloudx/project/get_namespace_config.go new file mode 100644 index 00000000..b8eaa785 --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config.go @@ -0,0 +1,110 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/ory/cli/cmd/cloudx/client" + "github.com/ory/x/cmdx" + "github.com/ory/x/osx" +) + +// ErrNoOPLConfigured is returned when the project has no Ory Permission +// Language file, so there is nothing to print. +var ErrNoOPLConfigured = fmt.Errorf("no Ory Permission Language file is configured for this project") + +// ErrLegacyNamespaces is returned for projects still using the legacy list of +// namespace definitions instead of an Ory Permission Language file. +var ErrLegacyNamespaces = fmt.Errorf("this project uses legacy namespace definitions instead of an Ory Permission Language file, use `ory get permission-config` to read them") + +// oplLocation extracts the Ory Permission Language file location from an Ory +// Permissions config. +// +// Ory Network does not inline the OPL source in the project config; it stores a +// pointer to it, which is why `ory get permission-config` shows +// `{"namespaces":{"location":"..."}}` rather than the file itself. +func oplLocation(config map[string]interface{}) (string, error) { + namespaces, ok := config["namespaces"] + if !ok || namespaces == nil { + return "", ErrNoOPLConfigured + } + + switch n := namespaces.(type) { + case map[string]interface{}: + location, ok := n["location"].(string) + if !ok || location == "" { + return "", ErrNoOPLConfigured + } + return location, nil + case []interface{}: + return "", ErrLegacyNamespaces + default: + return "", fmt.Errorf("unexpected type %T for the namespaces configuration", namespaces) + } +} + +func NewGetOPLCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "opl", + Aliases: []string{ + "namespaces-config", + }, + Args: cobra.NoArgs, + Short: "Get the Ory Permission Language file from Ory Network", + Long: `Get the Ory Permission Language file of an Ory Network project. + +The file is written to stdout as-is, so it can be redirected to disk: + + ory get opl > namespace_config.ts + +This is the counterpart of ` + "`ory update opl`" + `. Use it to check the +configured Ory Permission Language file into version control. + +Note that ` + "`ory get permission-config`" + ` returns the location of this file +rather than its contents, because that is how Ory Network stores it.`, + Example: `$ {{ .CommandPath }} --project ecaaa3cb-0730-4ee8-a6df-9553cdfeef89 + +class Example implements Namespace {} +`, + RunE: func(cmd *cobra.Command, _ []string) error { + h, err := client.NewCobraCommandHelper(cmd) + if err != nil { + return err + } + + pID, err := h.ProjectID() + if err != nil { + return err + } + + project, err := h.GetProject(cmd.Context(), pID, nil) + if err != nil { + return cmdx.PrintOpenAPIError(cmd, err) + } + + location, err := oplLocation(project.Services.Permission.Config) + if err != nil { + return err + } + + // The location is either a remote URL or an inlined base64 payload. + // Reading local files is disabled: the location comes from the API + // and must never make the CLI read from the developer's disk. + opl, err := osx.ReadFileFromAllSources(location, osx.WithDisabledFileLoader()) + if err != nil { + return fmt.Errorf("unable to read the Ory Permission Language file from %q: %w", location, err) + } + + _, err = cmd.OutOrStdout().Write(opl) + return err + }, + } + + client.RegisterProjectFlag(cmd.Flags()) + client.RegisterWorkspaceFlag(cmd.Flags()) + return cmd +} diff --git a/cmd/cloudx/project/get_namespace_config_test.go b/cmd/cloudx/project/get_namespace_config_test.go new file mode 100644 index 00000000..f77c0797 --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config_test.go @@ -0,0 +1,88 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/x/osx" +) + +// TestOPLLocation covers https://github.com/ory/cli/issues/321: the permission +// config reports where the Ory Permission Language file lives rather than its +// contents, and `ory get opl` has to resolve that pointer. +func TestOPLLocation(t *testing.T) { + for _, tc := range []struct { + name string + config map[string]interface{} + want string + wantErr error + }{ + { + name: "case=reads the location of an OPL file", + config: map[string]interface{}{"namespaces": map[string]interface{}{"location": "https://example.com/opl.bin"}}, + want: "https://example.com/opl.bin", + }, + { + name: "case=legacy namespace definitions are reported as such", + config: map[string]interface{}{"namespaces": []interface{}{map[string]interface{}{"name": "files", "id": 1}}}, + wantErr: ErrLegacyNamespaces, + }, + { + name: "case=missing namespaces key", + config: map[string]interface{}{"limit": map[string]interface{}{}}, + wantErr: ErrNoOPLConfigured, + }, + { + name: "case=null namespaces", + config: map[string]interface{}{"namespaces": nil}, + wantErr: ErrNoOPLConfigured, + }, + { + name: "case=namespaces without a location", + config: map[string]interface{}{"namespaces": map[string]interface{}{}}, + wantErr: ErrNoOPLConfigured, + }, + { + name: "case=empty location", + config: map[string]interface{}{"namespaces": map[string]interface{}{"location": ""}}, + wantErr: ErrNoOPLConfigured, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := oplLocation(tc.config) + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestOPLLocationIsReadable pins the loader contract the command relies on: +// `ory update opl` writes the file as a base64:// payload, and Ory Network +// hands back an https:// location, so both have to resolve — while file:// +// must not, since the location comes from the API. +func TestOPLLocationIsReadable(t *testing.T) { + const opl = "class Example implements Namespace {}" + + t.Run("case=base64 payloads are read", func(t *testing.T) { + location := "base64://" + base64.StdEncoding.EncodeToString([]byte(opl)) + + got, err := osx.ReadFileFromAllSources(location, osx.WithDisabledFileLoader()) + require.NoError(t, err) + assert.Equal(t, opl, string(got)) + }) + + t.Run("case=local files are refused", func(t *testing.T) { + _, err := osx.ReadFileFromAllSources("file://"+t.TempDir()+"/opl.ts", osx.WithDisabledFileLoader()) + assert.Error(t, err, "a location from the API must never read from the local disk") + }) +} diff --git a/cmd/cloudx/project/get_permission_config.go b/cmd/cloudx/project/get_permission_config.go index 8635dbac..fcce5880 100644 --- a/cmd/cloudx/project/get_permission_config.go +++ b/cmd/cloudx/project/get_permission_config.go @@ -16,19 +16,21 @@ func NewGetKetoConfigCmd() *cobra.Command { Aliases: []string{"pc", "keto-config"}, Args: cobra.NoArgs, Short: "Get Ory Permissions configuration.", - Long: "Get the Ory Permissions configuration for an Ory Network project.", + Long: `Get the Ory Permissions configuration for an Ory Network project. + +Ory Network stores the Ory Permission Language file separately and reports its +location here rather than its contents. To read the file itself, use ` + "`ory get opl`" + `.`, Example: `$ ory get permission-config --project ecaaa3cb-0730-4ee8-a6df-9553cdfeef89 --format yaml > permission-config.yaml $ ory get permission-config --format json # uses currently selected project { - "namespaces": [ - { - "name": "files", - "id": 1 - },1 - // ... - ] + "limit": { + "max_read_depth": 3 + }, + "namespaces": { + "location": "https://storage.googleapis.com/bac-gcs-production/..." + } }`, RunE: func(cmd *cobra.Command, _ []string) error { h, err := client.NewCobraCommandHelper(cmd) From 860945c9571b72313324d555394df41f48e4d637 Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Wed, 29 Jul 2026 15:00:48 +0200 Subject: [PATCH 2/4] fix: harden `ory get opl` after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the location with ory/x/fetcher restricted to http, https and base64 instead of osx with the file loader disabled. The fetcher checks the HTTP status code, so an expired storage link can no longer be written to stdout as if it were the OPL file; it honours the command context, so the download is cancellable; it redacts base64 payloads from error messages rather than dumping the whole file; and the scheme allowlist states the no-local-files rule positively. Read the permission service through the generated nil-safe accessor. `ProjectServices.Permission` is a pointer with omitempty, so a project without a permission service panicked — in `get permission-config` as well, which this branch already touched. Report the new error paths through cmdx.FailSilently so cobra does not append a usage dump and Execute does not print the message twice. An empty legacy namespace list is now reported as "nothing configured" rather than pointing at a command that would show an empty list. Tests: the local-file case now writes a real file first, so it fails if the scheme allowlist is removed; added remote-read and HTTP-error cases; and the live round-trip in TestUpdateNamespaceConfig asserts `get opl` returns byte-identical content to what `update opl` uploaded. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu --- cmd/cloudx/project/get_namespace_config.go | 43 ++++++++---- .../project/get_namespace_config_test.go | 67 ++++++++++++++++--- cmd/cloudx/project/get_permission_config.go | 2 +- .../project/update_namespace_config_test.go | 6 ++ 4 files changed, 93 insertions(+), 25 deletions(-) diff --git a/cmd/cloudx/project/get_namespace_config.go b/cmd/cloudx/project/get_namespace_config.go index b8eaa785..1eee4dfa 100644 --- a/cmd/cloudx/project/get_namespace_config.go +++ b/cmd/cloudx/project/get_namespace_config.go @@ -4,22 +4,28 @@ package project import ( + "errors" "fmt" "github.com/spf13/cobra" "github.com/ory/cli/cmd/cloudx/client" "github.com/ory/x/cmdx" - "github.com/ory/x/osx" + "github.com/ory/x/fetcher" ) -// ErrNoOPLConfigured is returned when the project has no Ory Permission +// errNoOPLConfigured is returned when the project has no Ory Permission // Language file, so there is nothing to print. -var ErrNoOPLConfigured = fmt.Errorf("no Ory Permission Language file is configured for this project") +var errNoOPLConfigured = errors.New("no Ory Permission Language file is configured for this project") -// ErrLegacyNamespaces is returned for projects still using the legacy list of +// errLegacyNamespaces is returned for projects still using the legacy list of // namespace definitions instead of an Ory Permission Language file. -var ErrLegacyNamespaces = fmt.Errorf("this project uses legacy namespace definitions instead of an Ory Permission Language file, use `ory get permission-config` to read them") +var errLegacyNamespaces = errors.New("this project uses legacy namespace definitions instead of an Ory Permission Language file, use `ory get permission-config` to read them") + +// oplSchemes are the locations `ory get opl` is willing to resolve. Local files +// are deliberately not included: the location comes from the API and must never +// make the CLI read from the developer's disk. +var oplSchemes = []string{"http", "https", "base64"} // oplLocation extracts the Ory Permission Language file location from an Ory // Permissions config. @@ -30,18 +36,23 @@ var ErrLegacyNamespaces = fmt.Errorf("this project uses legacy namespace definit func oplLocation(config map[string]interface{}) (string, error) { namespaces, ok := config["namespaces"] if !ok || namespaces == nil { - return "", ErrNoOPLConfigured + return "", errNoOPLConfigured } switch n := namespaces.(type) { case map[string]interface{}: location, ok := n["location"].(string) if !ok || location == "" { - return "", ErrNoOPLConfigured + return "", errNoOPLConfigured } return location, nil case []interface{}: - return "", ErrLegacyNamespaces + if len(n) == 0 { + // An empty legacy list is not a legacy configuration, there simply + // is nothing configured. + return "", errNoOPLConfigured + } + return "", errLegacyNamespaces default: return "", fmt.Errorf("unexpected type %T for the namespaces configuration", namespaces) } @@ -86,17 +97,21 @@ class Example implements Namespace {} return cmdx.PrintOpenAPIError(cmd, err) } - location, err := oplLocation(project.Services.Permission.Config) + location, err := oplLocation(project.Services.GetPermission().Config) if err != nil { - return err + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), err) + return cmdx.FailSilently(cmd) } // The location is either a remote URL or an inlined base64 payload. - // Reading local files is disabled: the location comes from the API - // and must never make the CLI read from the developer's disk. - opl, err := osx.ReadFileFromAllSources(location, osx.WithDisabledFileLoader()) + // The fetcher verifies the HTTP status code, so an expired storage + // link cannot be mistaken for the file, honors cancellation of the + // command context, and redacts base64 payloads from its errors. + opl, err := fetcher.NewFetcher(fetcher.WithAllowedSchemes(oplSchemes...)). + FetchBytes(cmd.Context(), location) if err != nil { - return fmt.Errorf("unable to read the Ory Permission Language file from %q: %w", location, err) + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "unable to read the Ory Permission Language file: %s\n", err) + return cmdx.FailSilently(cmd) } _, err = cmd.OutOrStdout().Write(opl) diff --git a/cmd/cloudx/project/get_namespace_config_test.go b/cmd/cloudx/project/get_namespace_config_test.go index f77c0797..9318bcf2 100644 --- a/cmd/cloudx/project/get_namespace_config_test.go +++ b/cmd/cloudx/project/get_namespace_config_test.go @@ -5,12 +5,16 @@ package project import ( "encoding/base64" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/ory/x/osx" + "github.com/ory/x/fetcher" ) // TestOPLLocation covers https://github.com/ory/cli/issues/321: the permission @@ -31,27 +35,37 @@ func TestOPLLocation(t *testing.T) { { name: "case=legacy namespace definitions are reported as such", config: map[string]interface{}{"namespaces": []interface{}{map[string]interface{}{"name": "files", "id": 1}}}, - wantErr: ErrLegacyNamespaces, + wantErr: errLegacyNamespaces, + }, + { + name: "case=an empty legacy list is not a legacy configuration", + config: map[string]interface{}{"namespaces": []interface{}{}}, + wantErr: errNoOPLConfigured, }, { name: "case=missing namespaces key", config: map[string]interface{}{"limit": map[string]interface{}{}}, - wantErr: ErrNoOPLConfigured, + wantErr: errNoOPLConfigured, }, { name: "case=null namespaces", config: map[string]interface{}{"namespaces": nil}, - wantErr: ErrNoOPLConfigured, + wantErr: errNoOPLConfigured, + }, + { + name: "case=nil config", + config: nil, + wantErr: errNoOPLConfigured, }, { name: "case=namespaces without a location", config: map[string]interface{}{"namespaces": map[string]interface{}{}}, - wantErr: ErrNoOPLConfigured, + wantErr: errNoOPLConfigured, }, { name: "case=empty location", config: map[string]interface{}{"namespaces": map[string]interface{}{"location": ""}}, - wantErr: ErrNoOPLConfigured, + wantErr: errNoOPLConfigured, }, } { t.Run(tc.name, func(t *testing.T) { @@ -69,20 +83,53 @@ func TestOPLLocation(t *testing.T) { // TestOPLLocationIsReadable pins the loader contract the command relies on: // `ory update opl` writes the file as a base64:// payload, and Ory Network // hands back an https:// location, so both have to resolve — while file:// -// must not, since the location comes from the API. +// must not, since the location comes from the API. An error response must not +// be mistaken for the file either, or `ory get opl > opl.ts` writes the +// storage provider's error page to disk. func TestOPLLocationIsReadable(t *testing.T) { const opl = "class Example implements Namespace {}" + f := fetcher.NewFetcher(fetcher.WithAllowedSchemes(oplSchemes...)) + t.Run("case=base64 payloads are read", func(t *testing.T) { location := "base64://" + base64.StdEncoding.EncodeToString([]byte(opl)) - got, err := osx.ReadFileFromAllSources(location, osx.WithDisabledFileLoader()) + got, err := f.FetchBytes(t.Context(), location) + require.NoError(t, err) + assert.Equal(t, opl, string(got)) + }) + + t.Run("case=remote files are read", func(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(opl)) + })) + t.Cleanup(s.Close) + + got, err := f.FetchBytes(t.Context(), s.URL+"/opl.bin") require.NoError(t, err) assert.Equal(t, opl, string(got)) }) t.Run("case=local files are refused", func(t *testing.T) { - _, err := osx.ReadFileFromAllSources("file://"+t.TempDir()+"/opl.ts", osx.WithDisabledFileLoader()) - assert.Error(t, err, "a location from the API must never read from the local disk") + // The file has to exist, otherwise the test would pass even if the + // file loader were enabled. + path := filepath.Join(t.TempDir(), "opl.ts") + require.NoError(t, os.WriteFile(path, []byte(opl), 0o600)) + + got, err := f.FetchBytes(t.Context(), "file://"+path) + require.Error(t, err, "a location from the API must never read from the local disk") + assert.NotContains(t, string(got), opl) + }) + + t.Run("case=error responses are not mistaken for the file", func(t *testing.T) { + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`AccessDenied`)) + })) + t.Cleanup(s.Close) + + got, err := f.FetchBytes(t.Context(), s.URL+"/opl.bin") + require.Error(t, err) + assert.Empty(t, got) }) } diff --git a/cmd/cloudx/project/get_permission_config.go b/cmd/cloudx/project/get_permission_config.go index fcce5880..5edca128 100644 --- a/cmd/cloudx/project/get_permission_config.go +++ b/cmd/cloudx/project/get_permission_config.go @@ -47,7 +47,7 @@ $ ory get permission-config --format json # uses currently selected project return cmdx.PrintOpenAPIError(cmd, err) } - cmdx.PrintJSONAble(cmd, outputConfig(project.Services.Permission.Config)) + cmdx.PrintJSONAble(cmd, outputConfig(project.Services.GetPermission().Config)) return nil }, } diff --git a/cmd/cloudx/project/update_namespace_config_test.go b/cmd/cloudx/project/update_namespace_config_test.go index f0fb4bab..f292c4df 100644 --- a/cmd/cloudx/project/update_namespace_config_test.go +++ b/cmd/cloudx/project/update_namespace_config_test.go @@ -55,6 +55,12 @@ func TestUpdateNamespaceConfig(t *testing.T) { data, err := fetcher.NewFetcher().FetchContext(t.Context(), url) require.NoError(t, err, "could not download the config") assert.Equal(t, content, data.String(), "the downloaded file does not match what we uploaded") + + // `ory get opl` is the read counterpart: it has to resolve the + // location above and print the file itself. + opl, stderr, err := exec(nil, "get", "opl") + require.NoError(t, err, stderr) + assert.Equal(t, content, opl, "`get opl` does not return what `%s opl` uploaded", verb) } runWithProjectAsDefault(ctx, t, defaultProject.Id, updateNamespace) From 0c90aacad937e01df324c6bb39f2091fbc7d5b8c Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Wed, 29 Jul 2026 15:31:45 +0200 Subject: [PATCH 3/4] fix: isolate the `get opl` round-trip from concurrently mutated projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-trip assertion was added to TestUpdateNamespaceConfig, which runs against the shared defaultProject and extraProject fixtures. Other tests in this package rewrite the permission config of those same projects in parallel — patch_permission_config_test.go replaces /namespaces with legacy definitions, and update_test.go replaces the permission config wholesale — so re-reading the project after writing it is racy. CI caught this: `get opl` reported "no Ory Permission Language file is configured for this project" for a project that had just been given one. Moved to its own test against a dedicated project, the same isolation TestUpdateProject and TestListProject already use, so nothing else can rewrite the config between the write and the read. TestUpdateNamespaceConfig goes back to asserting only on its own command output, which never depended on shared state. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu --- .../project/get_namespace_config_live_test.go | 44 +++++++++++++++++++ .../project/update_namespace_config_test.go | 6 --- 2 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 cmd/cloudx/project/get_namespace_config_live_test.go diff --git a/cmd/cloudx/project/get_namespace_config_live_test.go b/cmd/cloudx/project/get_namespace_config_live_test.go new file mode 100644 index 00000000..12e46dfb --- /dev/null +++ b/cmd/cloudx/project/get_namespace_config_live_test.go @@ -0,0 +1,44 @@ +// Copyright © 2026 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package project_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ory/cli/cmd/cloudx/testhelpers" +) + +// TestGetOPL is the round-trip for https://github.com/ory/cli/issues/302's +// counterpart #321: whatever `ory update opl` uploads must come back out of +// `ory get opl`, which exercises resolving the stored location and fetching it. +// +// It runs against a dedicated project rather than the shared fixtures on +// purpose. Several tests in this package concurrently rewrite the permission +// config of defaultProject and extraProject — patch_permission_config_test.go +// replaces /namespaces with legacy definitions — so any read-after-write +// assertion against those is racy. +func TestGetOPL(t *testing.T) { + if testing.Short() { + // this test needs internet, typically not available when you're on a (german) train + return + } + + t.Parallel() + + content := `class Default implements Namespace {}` + config := writeFile(t, content) + + workspace := testhelpers.CreateWorkspace(ctx, t) + project := testhelpers.CreateProject(ctx, t, workspace) + + _, stderr, err := defaultCmd.Exec(nil, "update", "opl", "--project", project.Id, "--file", config) + require.NoError(t, err, stderr) + + opl, stderr, err := defaultCmd.Exec(nil, "get", "opl", "--project", project.Id) + require.NoError(t, err, stderr) + assert.Equal(t, content, opl, "`get opl` must return what `update opl` uploaded") +} diff --git a/cmd/cloudx/project/update_namespace_config_test.go b/cmd/cloudx/project/update_namespace_config_test.go index f292c4df..f0fb4bab 100644 --- a/cmd/cloudx/project/update_namespace_config_test.go +++ b/cmd/cloudx/project/update_namespace_config_test.go @@ -55,12 +55,6 @@ func TestUpdateNamespaceConfig(t *testing.T) { data, err := fetcher.NewFetcher().FetchContext(t.Context(), url) require.NoError(t, err, "could not download the config") assert.Equal(t, content, data.String(), "the downloaded file does not match what we uploaded") - - // `ory get opl` is the read counterpart: it has to resolve the - // location above and print the file itself. - opl, stderr, err := exec(nil, "get", "opl") - require.NoError(t, err, stderr) - assert.Equal(t, content, opl, "`get opl` does not return what `%s opl` uploaded", verb) } runWithProjectAsDefault(ctx, t, defaultProject.Id, updateNamespace) From d13941f3fad3ce339f15ed8408c836df03721517 Mon Sep 17 00:00:00 2001 From: Arne Luenser Date: Wed, 29 Jul 2026 17:14:11 +0200 Subject: [PATCH 4/4] test: verify `get opl` output is accepted by `update opl` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-trip test only covered update -> get: it uploaded a file and asserted `get opl` returned it. The direction that matters for checking the permission model into version control is the other one — taking `get opl` output and applying it again — and that was only inferred from the two commands both handling raw files, never exercised. The test now feeds `get opl` output straight back into `update opl` and reads it once more, so a disagreement about the file format, or any stray wrapping or trailing newline that would accumulate over repeated round-trips, fails the test. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu --- .../project/get_namespace_config_live_test.go | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/cmd/cloudx/project/get_namespace_config_live_test.go b/cmd/cloudx/project/get_namespace_config_live_test.go index 12e46dfb..0612669f 100644 --- a/cmd/cloudx/project/get_namespace_config_live_test.go +++ b/cmd/cloudx/project/get_namespace_config_live_test.go @@ -12,9 +12,15 @@ import ( "github.com/ory/cli/cmd/cloudx/testhelpers" ) -// TestGetOPL is the round-trip for https://github.com/ory/cli/issues/302's -// counterpart #321: whatever `ory update opl` uploads must come back out of -// `ory get opl`, which exercises resolving the stored location and fetching it. +// TestGetOPL covers the round-trip #321 asks for, in both directions: +// +// ory update opl --file namespace_config.ts # what `get opl` must return +// ory get opl > namespace_config.ts # must be usable as input again +// +// The second direction is the one that matters for checking the permission +// model into version control: if the two commands disagreed on the file format, +// committing `get opl` output and applying it later would corrupt the model +// rather than restore it. // // It runs against a dedicated project rather than the shared fixtures on // purpose. Several tests in this package concurrently rewrite the permission @@ -40,5 +46,16 @@ func TestGetOPL(t *testing.T) { opl, stderr, err := defaultCmd.Exec(nil, "get", "opl", "--project", project.Id) require.NoError(t, err, stderr) - assert.Equal(t, content, opl, "`get opl` must return what `update opl` uploaded") + require.Equal(t, content, opl, "`get opl` must return what `update opl` uploaded") + + // Feed `get opl` output straight back in, exactly as a user committing the + // file and re-applying it would, and read it once more. Writing it to disk + // verbatim also catches any stray wrapping or trailing newline that would + // accumulate over repeated round-trips. + _, stderr, err = defaultCmd.Exec(nil, "update", "opl", "--project", project.Id, "--file", writeFile(t, opl)) + require.NoError(t, err, stderr, "`get opl` output must be accepted by `update opl`") + + opl, stderr, err = defaultCmd.Exec(nil, "get", "opl", "--project", project.Id) + require.NoError(t, err, stderr) + assert.Equal(t, content, opl, "the OPL file must survive a full get/update round-trip unchanged") }