Fix UpCloud admin detection and add name resolver

account/list marks the contract's primary account "main" on the live
API, not "mymain" as the published docs example shows, so matching the
documented spelling reported every account as a non-admin, including
the contract owner. Roles cannot stand in: a main account carries the
same technical/billing values a sub-account can hold. Classify off
"sub" instead, the one value the docs and the API agree on.

The fixture copied the docs example, so the test passed on the same
wrong assumption. Its bodies now mirror a live capture, anonymized: the
main account carries no main_account or allow_gui, sub-accounts add
them plus the access lists, and the primary account's type is "main".
A table test pins both spellings.

A review keys accounts on email plus external ID. Email came only from
account/details, and any failure blanked it while still emitting the
record, so a transient 5xx moved an account to a different key and
surfaced it as one account removed and another added. Only the stable
answers now degrade: UpCloud returns 403 ACCOUNT_FORBIDDEN, not 404,
for an account outside the token's reach, and both keep the list-only
fields. Anything else aborts the run.

A blank username no longer discards every account already collected,
matching the sibling drivers.

Resolve the source name from GET /1.3/account so sources read
"UpCloud <username>" rather than staying generic, and link the
connector to its documentation page.

Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
Aurélien Sibiril
2026-07-27 10:31:40 +02:00
parent c5a110b7a6
commit 099543dfa9
6 changed files with 315 additions and 65 deletions

View File

@@ -65,8 +65,8 @@ func TestUpCloudDriver(t *testing.T) {
assert.Equal(t, []string{"technical"}, sub.Roles)
assert.False(t, sub.IsAdmin)
// no roles assigned; details fetch fails (404), so the record falls back
// to list-only fields rather than being dropped.
// no roles assigned; details answers 403, which is a stable "no details"
// rather than an error, so the record keeps its list-only fields.
temp := records[2]
assert.Equal(t, "my_temp_account", temp.ExternalID)
assert.Equal(t, "my_temp_account", temp.FullName)
@@ -82,13 +82,6 @@ func TestUpCloudDriver(t *testing.T) {
assert.False(t, billing.IsAdmin)
}
// upcloudRoundTripFunc adapts a function to http.RoundTripper.
type upcloudRoundTripFunc func(*http.Request) (*http.Response, error)
func (f upcloudRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
// TestUpCloudDriverContextCancellation verifies that a context canceled
// mid-run aborts ListAccounts with the cancellation error instead of being
// swallowed as a best-effort per-account detail failure, which would let a
@@ -99,7 +92,7 @@ func TestUpCloudDriverContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
client := &http.Client{
Transport: upcloudRoundTripFunc(func(req *http.Request) (*http.Response, error) {
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/account/details/") {
cancel()
@@ -122,3 +115,124 @@ func TestUpCloudDriverContextCancellation(t *testing.T) {
assert.True(t, errors.Is(err, context.Canceled))
assert.Nil(t, records)
}
func TestUpCloudIsMainAccount(t *testing.T) {
t.Parallel()
cases := []struct {
accountType string
want bool
}{
{accountType: "main", want: true}, // live API
{accountType: "mymain", want: true}, // published docs example
{accountType: "MAIN", want: true},
{accountType: "sub", want: false},
{accountType: "SUB", want: false},
{accountType: " sub ", want: false},
{accountType: "", want: true},
}
for _, tc := range cases {
t.Run(tc.accountType, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, upcloudIsMainAccount(tc.accountType))
})
}
}
func TestUpCloudFetchAccountDetails(t *testing.T) {
t.Parallel()
cases := []struct {
name string
status int
wantNil bool
wantError bool
}{
{name: "forbidden yields no details", status: http.StatusForbidden, wantNil: true},
{name: "not found yields no details", status: http.StatusNotFound, wantNil: true},
{name: "server error is fatal", status: http.StatusInternalServerError, wantError: true},
{name: "rate limited is fatal", status: http.StatusTooManyRequests, wantError: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: tc.status,
Body: io.NopCloser(strings.NewReader(`{"error":{"error_code":"X"}}`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
})}
driver := NewUpCloudDriver(client, log.NewLogger(log.WithName("test")))
details, err := driver.fetchAccountDetails(context.Background(), "someone")
if tc.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantNil, details == nil)
})
}
}
// TestUpCloudDriverTransientDetailFailureAborts pins the identity guarantee:
// the review keys accounts on email plus external ID, so a record emitted
// with a blank email after a transient failure would read as one account
// removed and another added.
func TestUpCloudDriverTransientDetailFailureAborts(t *testing.T) {
t.Parallel()
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body := `{"accounts":{"account":[{"roles":{"role":["technical"]},"type":"main","username":"test"}]}}`
status := http.StatusOK
if strings.Contains(req.URL.Path, "/account/details/") {
body = `{"error":{"error_code":"INTERNAL"}}`
status = http.StatusInternalServerError
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
})}
records, err := NewUpCloudDriver(client, log.NewLogger(log.WithName("test"))).ListAccounts(context.Background())
require.Error(t, err)
assert.Nil(t, records)
}
// TestUpCloudDriverBlankUsernameAborts pins the malformed-list guarantee:
// username is the only stable identifier, so silently dropping a blank row
// would hide an account the source still exposes and mark it removed on the
// next review.
func TestUpCloudDriverBlankUsernameAborts(t *testing.T) {
t.Parallel()
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if strings.Contains(req.URL.Path, "/account/details/") {
t.Errorf("driver must not fetch details after a malformed list row")
}
body := `{"accounts":{"account":[{"roles":{"role":[]},"type":"sub","username":" "}]}}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}, nil
})}
records, err := NewUpCloudDriver(client, log.NewLogger(log.WithName("test"))).ListAccounts(context.Background())
require.Error(t, err)
assert.Nil(t, records)
}