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
2 changes: 1 addition & 1 deletion mgradm/cmd/distro/cp.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func registerDistro(connection *api.ConnectionDetails, distro *types.Distributio
"installType": distro.InstallType,
}

_, err = client.Post("kickstart/tree/create", data)
_, err = client.PostChecked("kickstart/tree/create", "api.kickstart.tree.create", data)
if err != nil {
return utils.Errorf(err, L("unable to register the distribution. Manual distro registration is required"))
}
Expand Down
2 changes: 1 addition & 1 deletion mgrctl/cmd/ssh/knownhost/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func removeKnownHost(client *api.APIClient, hostname string, port string) error
path := "admin/ssh/removeKnownHost?" + params.Encode()

var data map[string]interface{}
res, err := api.Post[interface{}](client, path, data)
res, err := api.PostChecked[interface{}](client, path, "api.admin.ssh.remove_known_host", data)
if err != nil {
return utils.Errorf(err, L("error in query '%s'"), path)
}
Expand Down
48 changes: 45 additions & 3 deletions shared/api/api.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2024 SUSE LLC
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

Expand Down Expand Up @@ -225,7 +225,7 @@ func (c *APIClient) ValidateCreds() bool {
return err == nil
}

// Post issues a POST HTTP request to the API target
// Post issues a POST HTTP request to the API target, without validating the endpoint.
//
// `path` specifies an API endpoint
// `data` contains a map of values to add to the POST query. `data` are serialized to the JSON
Expand Down Expand Up @@ -254,6 +254,26 @@ func (c *APIClient) Post(path string, data map[string]interface{}) (*http.Respon
return res, nil
}

// PostChecked validates the API endpoint before issuing a POST request.
func (c *APIClient) PostChecked(path string, endpoint string, data map[string]interface{}) (*http.Response, error) {
if err := c.validateEndpoint(endpoint); err != nil {
return nil, err
}
return c.Post(path, data)
}

// PostChecked issues a validated POST request to the API target using the client and decodes the response.
func PostChecked[T interface{}](
client *APIClient, path string, namespace string, data map[string]interface{},
) (*APIResponse[T], error) {
res, err := client.PostChecked(path, namespace, data)
if err != nil {
return nil, err
}

return decodeAPIResponse[T](res)
}

// Get issues GET HTTP request to the API target
//
// `path` specifies API endpoint together with query options
Expand Down Expand Up @@ -313,10 +333,32 @@ func Get[T interface{}](client *APIClient, path string) (*APIResponse[T], error)
return nil, err
}

return decodeAPIResponse[T](res)
}

// GetChecked validates the API namespace before issuing a GET request.
func (c *APIClient) GetChecked(path string, namespace string) (*http.Response, error) {
if err := c.validateEndpoint(namespace); err != nil {
return nil, err
}
return c.Get(path)
}

// GetChecked issues a validated GET request to the API using the client and decodes the response.
func GetChecked[T interface{}](client *APIClient, path string, endpoint string) (*APIResponse[T], error) {
res, err := client.GetChecked(path, endpoint)
if err != nil {
return nil, err
}

return decodeAPIResponse[T](res)
}

func decodeAPIResponse[T interface{}](res *http.Response) (*APIResponse[T], error) {
defer res.Body.Close()

var response APIResponse[T]
if err = json.NewDecoder(res.Body).Decode(&response); err != nil {
if err := json.NewDecoder(res.Body).Decode(&response); err != nil {
return nil, err
}

Expand Down
63 changes: 63 additions & 0 deletions shared/api/namespaces.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package api

import (
"encoding/json"
"errors"

. "github.com/uyuni-project/uyuni-tools/shared/l10n"
)

const unsupportedFunctionError = "This function is currently not supported by the server."

// ValidateNamespace checks if the server advertises the requested API namespace.
func ValidateNamespace(client *APIClient, namespace string) error {
if namespace == "" {
return nil
}
if err := client.loadValidNamespaces(); err != nil {
return err
}
if _, ok := client.validNamespaces[namespace]; ok {
return nil
}
return errors.New(L(unsupportedFunctionError))
}

func (c *APIClient) validateEndpoint(endpoint string) error {
if c.AuthCookie == nil || endpoint == "" {
return nil
}
return ValidateNamespace(c, endpoint)
}

func (c *APIClient) loadValidNamespaces() error {
if c.validNamespaces != nil {
return nil
}

res, err := c.Get("access/listNamespaces")
if err != nil {
return err
}
defer res.Body.Close()

var response APIResponse[[]NamespaceAccess]
if err = json.NewDecoder(res.Body).Decode(&response); err != nil {
return err
}
if !response.Success {
return errors.New(response.Message)
}

c.validNamespaces = map[string]struct{}{}
for _, namespace := range response.Result {
if namespace.Namespace != "" {
c.validNamespaces[namespace.Namespace] = struct{}{}
}
}
return nil
}
152 changes: 152 additions & 0 deletions shared/api/namespaces_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

package api

import (
"net/http"
"testing"

"github.com/uyuni-project/uyuni-tools/shared/api/mocks"
"github.com/uyuni-project/uyuni-tools/shared/testutils"
)

const removeKnownHostNamespace = "api.admin.ssh.remove_known_host"

func namespaceListResponse(namespace string) string {
return `{"success":true,"result":[{"namespace":"` + namespace + `","access_mode":"W"}]}`
}

func TestPostChecked(t *testing.T) {
tests := []struct {
name string
path string
endpoint string
apiPath string
expectedError string
targetCalled bool
}{
{
name: "Test advertised endpoint",
path: "admin/ssh/removeKnownHost",
endpoint: removeKnownHostNamespace,
apiPath: "/rhn/manager/api/admin/ssh/removeKnownHost",
targetCalled: true,
},
{
name: "Test unsupported endpoint",
path: "admin/ssh/removeUnknownHost",
endpoint: "api.admin.ssh.remove_unknown_host",
apiPath: "/rhn/manager/api/admin/ssh/removeUnknownHost",
expectedError: unsupportedFunctionError,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := Init(&ConnectionDetails{Server: "testServer", Cookie: "testCookie"})
if err != nil {
t.FailNow()
}

targetCalled := false
client.Client = &mocks.MockClient{DoFunc: func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/rhn/manager/api/access/listNamespaces":
return testutils.GetResponse(200, namespaceListResponse(removeKnownHostNamespace))
case tt.apiPath:
targetCalled = true
return testutils.GetResponse(200, `{"success":true,"result":true}`)
default:
t.Errorf("Unexpected API path %s", req.URL.Path)
return testutils.GetResponse(404, `{}`)
}
}}

errorMessage := ""
if _, err := client.PostChecked(tt.path, tt.endpoint, nil); err != nil {
errorMessage = err.Error()
}

testutils.AssertStringContains(t, "Unexpected error message", errorMessage, tt.expectedError)
testutils.AssertEquals(t, "Unexpected target call", tt.targetCalled, targetCalled)
})
}
}

func TestGetChecked(t *testing.T) {
tests := []struct {
name string
path string
endpoint string
apiPath string
expectedError string
targetCalled bool
}{
{
name: "Test advertised endpoint",
path: "org/getDetails?name=admin",
endpoint: "api.org.get_details",
apiPath: "/rhn/manager/api/org/getDetails",
targetCalled: true,
},
{
name: "Test unsupported endpoint",
path: "org/getUnknownDetails?name=admin",
endpoint: "api.org.get_unknown_details",
apiPath: "/rhn/manager/api/org/getUnknownDetails",
expectedError: unsupportedFunctionError,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := Init(&ConnectionDetails{Server: "testServer", Cookie: "testCookie"})
if err != nil {
t.FailNow()
}

targetCalled := false
client.Client = &mocks.MockClient{DoFunc: func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/rhn/manager/api/access/listNamespaces":
return testutils.GetResponse(200, namespaceListResponse("api.org.get_details"))
case tt.apiPath:
targetCalled = true
return testutils.GetResponse(200, `{"success":true,"result":true}`)
default:
t.Errorf("Unexpected API path %s", req.URL.Path)
return testutils.GetResponse(404, `{}`)
}
}}

errorMessage := ""
if _, err := client.GetChecked(tt.path, tt.endpoint); err != nil {
errorMessage = err.Error()
}

testutils.AssertStringContains(t, "Unexpected error message", errorMessage, tt.expectedError)
testutils.AssertEquals(t, "Unexpected target call", tt.targetCalled, targetCalled)
})
}
}

func TestRawPostDoesNotValidateEndpoint(t *testing.T) {
client, err := Init(&ConnectionDetails{Server: "testServer", Cookie: "testCookie"})
if err != nil {
t.FailNow()
}

targetCalled := false
client.Client = &mocks.MockClient{DoFunc: func(req *http.Request) (*http.Response, error) {
testutils.AssertEquals(t, "Wrong URL called", req.URL.Path, "/rhn/manager/api/org/createFirst")
targetCalled = true
return testutils.GetResponse(200, `{"success":true,"result":true}`)
}}

if _, err := client.Post("org/createFirst", nil); err != nil {
t.Fatalf("Expected raw POST request to bypass endpoint validation: %v", err)
}
testutils.AssertTrue(t, "Expected raw target endpoint to be called", targetCalled)
}
4 changes: 2 additions & 2 deletions shared/api/org/createFirst.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2024 SUSE LLC
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

Expand Down Expand Up @@ -31,7 +31,7 @@ func CreateFirst(cnxDetails *api.ConnectionDetails, orgName string, admin *types
"email": admin.Email,
}

res, err := api.Post[types.Organization](client, "org/createFirst", data)
res, err := api.PostChecked[types.Organization](client, "org/createFirst", "api.org.create_first", data)
if err != nil {
return nil, utils.Errorf(err, L("failed to create first user and organization"))
}
Expand Down
6 changes: 4 additions & 2 deletions shared/api/org/getDetails.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2024 SUSE LLC
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

Expand All @@ -23,7 +23,9 @@ func GetOrganizationDetails(cnxDetails *api.ConnectionDetails, orgName string) (
if err != nil {
return nil, utils.Errorf(err, L("failed to connect to the server"))
}
res, err := api.Get[types.Organization](client, fmt.Sprintf("org/getDetails?name=%s", orgName))
res, err := api.GetChecked[types.Organization](
client, fmt.Sprintf("org/getDetails?name=%s", orgName), "api.org.get_details",
Comment thread
marv7000 marked this conversation as resolved.
)
if err != nil {
return nil, utils.Errorf(err, L("failed to get organization details"))
}
Expand Down
5 changes: 3 additions & 2 deletions shared/api/proxy/containerConfig.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2024 SUSE LLC
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

Expand All @@ -14,6 +14,7 @@ import (
)

const containerConfigEndpoint = "proxy/containerConfig"
const containerConfigAPIEndpoint = "api.proxy.container_config"

// ContainerConfig computes and downloads the configuration file for proxy containers with generated certificates.
func ContainerConfig(client *api.APIClient, request ProxyConfigRequest) (*[]int8, error) {
Expand All @@ -28,7 +29,7 @@ func ContainerConfigGenerate(client *api.APIClient, request ProxyConfigGenerateR
// common method to execute the request.
func executeRequest(client *api.APIClient, data map[string]interface{}) (*[]int8, error) {
log.Trace().Msgf("Creating proxy configuration file with data: %v...", data)
res, err := api.Post[[]int8](client, containerConfigEndpoint, data)
res, err := api.PostChecked[[]int8](client, containerConfigEndpoint, containerConfigAPIEndpoint, data)
if err != nil {
return nil, utils.Errorf(err, L("failed to create proxy configuration file"))
}
Expand Down
12 changes: 11 additions & 1 deletion shared/api/types.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2024 SUSE LLC
// SPDX-FileCopyrightText: 2026 SUSE LLC
//
// SPDX-License-Identifier: Apache-2.0

Expand All @@ -23,6 +23,16 @@ type APIClient struct {

// Connection details
Details *ConnectionDetails

// Valid API namespaces advertised by the server
validNamespaces map[string]struct{}
}

// NamespaceAccess describes an API namespace advertised by the server.
type NamespaceAccess struct {
Namespace string `json:"namespace"`
Description string `json:"description"`
AccessMode string `json:"access_mode"`
}

// HTTPClient is a minimal HTTPClient interface primarily for unit testing.
Expand Down
1 change: 1 addition & 0 deletions uyuni-tools.changes.mfriedrich.check-endpoints
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Validate the presence of all API functions before calling them
Loading