From 02fd39afa3828281bd8d90f012fd4d19dbd46611 Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Fri, 23 Oct 2026 15:42:01 -0400 Subject: [PATCH] TPT-4740: Add MFA enforcement support --- account_settings.go | 4 + account_tfa_optional_users.go | 30 + account_users.go | 1 + profile.go | 1 + test/integration/account_settings_test.go | 18 + .../account_tfa_optional_users_test.go | 85 +++ .../TestAccountSettings_TFAEnforced.yaml | 197 +++++ .../TestAccountTFAOptionalUsers_List.yaml | 65 ++ .../TestAccountTFAOptionalUsers_Update.yaml | 694 ++++++++++++++++++ .../fixtures/TestProfile_TFAEnforced.yaml | 253 +++++++ test/integration/profile_test.go | 33 + test/unit/account_settings_test.go | 34 + test/unit/account_tfa_optional_users_test.go | 183 +++++ test/unit/account_users_test.go | 28 + test/unit/fixtures/account_settings_get.json | 1 + .../fixtures/account_settings_update.json | 1 + .../account_tfa_optional_users_list.json | 15 + ...account_tfa_optional_users_list_empty.json | 6 + .../account_tfa_optional_users_update.json | 4 + ...count_tfa_optional_users_update_empty.json | 4 + test/unit/fixtures/account_users_create.json | 1 + test/unit/fixtures/account_users_get.json | 1 + test/unit/fixtures/account_users_list.json | 1 + test/unit/fixtures/account_users_update.json | 1 + test/unit/fixtures/profile_get.json | 1 + test/unit/fixtures/profile_update.json | 1 + test/unit/profile_test.go | 2 + test/unit/profile_tfa_test.go | 41 ++ 28 files changed, 1706 insertions(+) create mode 100644 account_tfa_optional_users.go create mode 100644 test/integration/account_tfa_optional_users_test.go create mode 100644 test/integration/fixtures/TestAccountSettings_TFAEnforced.yaml create mode 100644 test/integration/fixtures/TestAccountTFAOptionalUsers_List.yaml create mode 100644 test/integration/fixtures/TestAccountTFAOptionalUsers_Update.yaml create mode 100644 test/integration/fixtures/TestProfile_TFAEnforced.yaml create mode 100644 test/unit/account_tfa_optional_users_test.go create mode 100644 test/unit/fixtures/account_tfa_optional_users_list.json create mode 100644 test/unit/fixtures/account_tfa_optional_users_list_empty.json create mode 100644 test/unit/fixtures/account_tfa_optional_users_update.json create mode 100644 test/unit/fixtures/account_tfa_optional_users_update_empty.json diff --git a/account_settings.go b/account_settings.go index 068e0847f..44c0816b8 100644 --- a/account_settings.go +++ b/account_settings.go @@ -24,6 +24,8 @@ type AccountSettings struct { // Whether or not the Network Helper is enabled for all new Linode Instance Configs on the account. NetworkHelper bool `json:"network_helper"` + TFAEnforced bool `json:"tfa_enforced"` + // A plan name like "longview-3"..."longview-100", or a nil value for to cancel any existing subscription plan. LongviewSubscription *string `json:"longview_subscription"` @@ -45,6 +47,8 @@ type AccountSettingsUpdateOptions struct { // The default network helper setting for all new Linodes and Linode Configs for all users on the account. NetworkHelper *bool `json:"network_helper,omitzero"` + TFAEnforced *bool `json:"tfa_enforced,omitzero"` + // NOTE: Interfaces for new linode setting may not currently be available to all users. // A new configuration flag defines whether new Linodes can use Linode and/or legacy config interfaces. InterfacesForNewLinodes *InterfacesForNewLinodes `json:"interfaces_for_new_linodes"` diff --git a/account_tfa_optional_users.go b/account_tfa_optional_users.go new file mode 100644 index 000000000..2a0a06b0c --- /dev/null +++ b/account_tfa_optional_users.go @@ -0,0 +1,30 @@ +package linodego + +import "context" + +// TFAOptionalUser represents an account user exempted from TFA enforcement. +type TFAOptionalUser struct { + Username string `json:"username"` + URL string `json:"url"` +} + +// TFAOptionalUsersUpdateOptions contains the replacement list of exempted users. +type TFAOptionalUsersUpdateOptions struct { + Usernames []string `json:"usernames"` +} + +// TFAOptionalUsersUpdateResponse summarizes the updated TFA exemption list. +type TFAOptionalUsersUpdateResponse struct { + TFAOptionalUsersCount int `json:"tfa_optional_users_count"` + URL string `json:"url"` +} + +// ListTFAOptionalUsers lists exempted users regardless of the account's TFA policy. +func (c *Client) ListTFAOptionalUsers(ctx context.Context, opts *ListOptions) ([]TFAOptionalUser, error) { + return getPaginatedResults[TFAOptionalUser](ctx, c, "account/tfa-optional-users", opts) +} + +// UpdateTFAOptionalUsers replaces the exemption list. An empty slice clears it. +func (c *Client) UpdateTFAOptionalUsers(ctx context.Context, opts TFAOptionalUsersUpdateOptions) (*TFAOptionalUsersUpdateResponse, error) { + return doPUTRequest[TFAOptionalUsersUpdateResponse](ctx, c, "account/tfa-optional-users", opts) +} diff --git a/account_users.go b/account_users.go index c311c5623..6a08ccf8b 100644 --- a/account_users.go +++ b/account_users.go @@ -31,6 +31,7 @@ type User struct { UserType UserType `json:"user_type"` Restricted bool `json:"restricted"` TFAEnabled bool `json:"tfa_enabled"` + TFAEnforced bool `json:"tfa_enforced"` SSHKeys []string `json:"ssh_keys"` PasswordCreated *time.Time `json:"-"` VerifiedPhoneNumber *string `json:"verified_phone_number"` diff --git a/profile.go b/profile.go index 68e6bb0dd..72f65b04d 100644 --- a/profile.go +++ b/profile.go @@ -33,6 +33,7 @@ type Profile struct { EmailNotifications bool `json:"email_notifications"` IPWhitelistEnabled bool `json:"ip_whitelist_enabled"` TwoFactorAuth bool `json:"two_factor_auth"` + TFAEnforced bool `json:"tfa_enforced"` Restricted bool `json:"restricted"` LishAuthMethod LishAuthMethod `json:"lish_auth_method"` Referrals ProfileReferrals `json:"referrals"` diff --git a/test/integration/account_settings_test.go b/test/integration/account_settings_test.go index 50de80b98..26a5aeee2 100644 --- a/test/integration/account_settings_test.go +++ b/test/integration/account_settings_test.go @@ -77,5 +77,23 @@ func TestAccountSettings_Update(t *testing.T) { require.Equal(t, "linode/migrate", settings.MaintenancePolicy, "Expected MaintenancePolicy to be 'linode/migrate'") } +func TestAccountSettings_TFAEnforced(t *testing.T) { + client, teardown := createTestClient(t, "fixtures/TestAccountSettings_TFAEnforced") + t.Cleanup(teardown) + + ctx := context.Background() + original, err := client.GetAccountSettings(ctx) + require.NoError(t, err) + + opts := linodego.AccountSettingsUpdateOptions{TFAEnforced: new(original.TFAEnforced)} + updated, err := client.UpdateAccountSettings(ctx, opts) + require.NoError(t, err) + require.Equal(t, original.TFAEnforced, updated.TFAEnforced) + + settings, err := client.GetAccountSettings(ctx) + require.NoError(t, err) + require.Equal(t, original.TFAEnforced, settings.TFAEnforced) +} + func Bool(v bool) *bool { return &v } func String(v string) *string { return &v } diff --git a/test/integration/account_tfa_optional_users_test.go b/test/integration/account_tfa_optional_users_test.go new file mode 100644 index 000000000..381caa09f --- /dev/null +++ b/test/integration/account_tfa_optional_users_test.go @@ -0,0 +1,85 @@ +package integration + +import ( + "context" + "encoding/json" + "testing" + + "github.com/linode/linodego/v2" + "github.com/stretchr/testify/require" +) + +func TestAccountTFAOptionalUsers_List(t *testing.T) { + client, teardown := createTestClient(t, "fixtures/TestAccountTFAOptionalUsers_List") + t.Cleanup(teardown) + + opts := linodego.NewListOptions(0, "") + opts.PageSize = 25 + users, err := client.ListTFAOptionalUsers(context.Background(), opts) + require.NoError(t, err) + require.Len(t, users, opts.Results) + + for _, user := range users { + require.NotEmpty(t, user.Username) + require.Equal(t, "/v4/account/users/"+user.Username, user.URL) + } +} + +func TestAccountTFAOptionalUsers_Update(t *testing.T) { + client, teardown := createTestClient(t, "fixtures/TestAccountTFAOptionalUsers_Update") + t.Cleanup(teardown) + + ctx := context.Background() + original, err := client.ListTFAOptionalUsers(ctx, nil) + require.NoError(t, err) + originalUsernames := make([]string, len(original)) + for index, user := range original { + originalUsernames[index] = user.Username + } + + user, deleteUser := createUser(t, client, func(opts *linodego.UserCreateOptions) { + opts.Username = usernamePrefix + "tfaoptional" + opts.Email = opts.Username + "@example.com" + opts.Restricted = true + }) + t.Cleanup(deleteUser) + t.Cleanup(func() { + _, err := client.UpdateTFAOptionalUsers(ctx, linodego.TFAOptionalUsersUpdateOptions{Usernames: originalUsernames}) + require.NoError(t, err, "failed to restore the original TFA exemption list") + }) + + usernames := append([]string{user.Username}, originalUsernames...) + response, err := client.UpdateTFAOptionalUsers(ctx, linodego.TFAOptionalUsersUpdateOptions{Usernames: usernames}) + require.NoError(t, err) + require.Equal(t, len(usernames), response.TFAOptionalUsersCount) + require.Equal(t, "/v4/account/tfa-optional-users", response.URL) + + users, err := client.ListTFAOptionalUsers(ctx, nil) + require.NoError(t, err) + require.Len(t, users, len(usernames)) + require.Contains(t, users, linodego.TFAOptionalUser{Username: user.Username, URL: "/v4/account/users/" + user.Username}) + + exempted, err := client.GetUser(ctx, user.Username) + require.NoError(t, err) + require.False(t, exempted.TFAEnforced) + + updated, err := client.UpdateUser(ctx, user.Username, linodego.UserUpdateOptions{Restricted: new(true)}) + require.NoError(t, err) + require.False(t, updated.TFAEnforced) + + filter, err := json.Marshal(map[string]interface{}{"username": user.Username, "tfa_enforced": false}) + require.NoError(t, err) + filtered, err := client.ListUsers(ctx, linodego.NewListOptions(0, string(filter))) + require.NoError(t, err) + require.Len(t, filtered, 1) + require.Equal(t, user.Username, filtered[0].Username) + require.False(t, filtered[0].TFAEnforced) + + response, err = client.UpdateTFAOptionalUsers(ctx, linodego.TFAOptionalUsersUpdateOptions{Usernames: originalUsernames}) + require.NoError(t, err) + require.Equal(t, len(originalUsernames), response.TFAOptionalUsersCount) + + users, err = client.ListTFAOptionalUsers(ctx, nil) + require.NoError(t, err) + require.ElementsMatch(t, original, users) +} diff --git a/test/integration/fixtures/TestAccountSettings_TFAEnforced.yaml b/test/integration/fixtures/TestAccountSettings_TFAEnforced.yaml new file mode 100644 index 000000000..9085275ca --- /dev/null +++ b/test/integration/fixtures/TestAccountSettings_TFAEnforced.yaml @@ -0,0 +1,197 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/settings + method: GET + response: + body: '{"managed": false, "network_helper": false, "longview_subscription": null, + "backups_enabled": false, "object_storage": "active", "interfaces_for_new_linodes": + "legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/migrate", + "tfa_enforced": false}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "265" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:04 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1840" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"tfa_enforced":false,"interfaces_for_new_linodes":null}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/settings + method: PUT + response: + body: '{"managed": false, "network_helper": false, "longview_subscription": null, + "backups_enabled": false, "object_storage": "active", "interfaces_for_new_linodes": + "legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/migrate", + "tfa_enforced": false}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "265" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:04 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1840" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/settings + method: GET + response: + body: '{"managed": false, "network_helper": false, "longview_subscription": null, + "backups_enabled": false, "object_storage": "active", "interfaces_for_new_linodes": + "legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/migrate", + "tfa_enforced": false}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "265" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:04 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1840" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" diff --git a/test/integration/fixtures/TestAccountTFAOptionalUsers_List.yaml b/test/integration/fixtures/TestAccountTFAOptionalUsers_List.yaml new file mode 100644 index 000000000..ee0bc375f --- /dev/null +++ b/test/integration/fixtures/TestAccountTFAOptionalUsers_List.yaml @@ -0,0 +1,65 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/tfa-optional-users?page=1&page_size=25 + method: GET + response: + body: '{"data": [], "page": 1, "pages": 1, "results": 0}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "49" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:39:26 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "50" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" diff --git a/test/integration/fixtures/TestAccountTFAOptionalUsers_Update.yaml b/test/integration/fixtures/TestAccountTFAOptionalUsers_Update.yaml new file mode 100644 index 000000000..de13bf51b --- /dev/null +++ b/test/integration/fixtures/TestAccountTFAOptionalUsers_Update.yaml @@ -0,0 +1,694 @@ +--- +version: 1 +interactions: +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/tfa-optional-users?page=1 + method: GET + response: + body: '{"data": [], "page": 1, "pages": 1, "results": 0}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "49" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:34 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "50" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"username":"linodegotest-tfaoptional","email":"linodegotest-tfaoptional@example.com","restricted":true}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/users + method: POST + response: + body: '{"username": "linodegotest-tfaoptional", "email": "linodegotest-tfaoptional@example.com", + "restricted": true, "ssh_keys": [], "tfa_enabled": false, "verified_phone_number": + null, "password_created": null, "last_login": null, "user_type": "default", + "tfa_enforced": false}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "271" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:35 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1840" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"usernames":["linodegotest-tfaoptional"]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/tfa-optional-users + method: PUT + response: + body: '{"tfa_optional_users_count": 1, "url": "/v4/account/tfa-optional-users"}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "72" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:35 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "5" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/tfa-optional-users?page=1 + method: GET + response: + body: '{"data": [{"username": "linodegotest-tfaoptional", "url": "/v4/account/users/linodegotest-tfaoptional"}], + "page": 1, "pages": 1, "results": 1}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "142" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:36 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "50" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/users/linodegotest-tfaoptional + method: GET + response: + body: '{"username": "linodegotest-tfaoptional", "email": "linodegotest-tfaoptional@example.com", + "restricted": true, "ssh_keys": [], "tfa_enabled": false, "verified_phone_number": + null, "password_created": null, "last_login": null, "user_type": "default", + "tfa_enforced": false}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "271" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:36 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1840" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"restricted":true}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/users/linodegotest-tfaoptional + method: PUT + response: + body: '{"username": "linodegotest-tfaoptional", "email": "linodegotest-tfaoptional@example.com", + "restricted": true, "ssh_keys": [], "tfa_enabled": false, "verified_phone_number": + null, "password_created": null, "last_login": null, "user_type": "default", + "tfa_enforced": false}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "271" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:36 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1840" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + X-Filter: + - '{"tfa_enforced":false,"username":"linodegotest-tfaoptional"}' + url: https://api.linode.com/v4beta/account/users?page=1 + method: GET + response: + body: '{"data": [{"username": "linodegotest-tfaoptional", "email": "linodegotest-tfaoptional@example.com", + "restricted": true, "ssh_keys": [], "tfa_enabled": false, "verified_phone_number": + null, "password_created": null, "last_login": null, "user_type": "default", + "tfa_enforced": false}], "page": 1, "pages": 1, "results": 1}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "320" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:36 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1840" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"usernames":[]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/tfa-optional-users + method: PUT + response: + body: '{"tfa_optional_users_count": 0, "url": "/v4/account/tfa-optional-users"}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "72" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:36 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "5" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/tfa-optional-users?page=1 + method: GET + response: + body: '{"data": [], "page": 1, "pages": 1, "results": 0}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "49" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:36 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "50" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: '{"usernames":[]}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/tfa-optional-users + method: PUT + response: + body: '{"tfa_optional_users_count": 0, "url": "/v4/account/tfa-optional-users"}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "72" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:37 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "5" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" +- request: + body: "" + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/users/linodegotest-tfaoptional + method: DELETE + response: + body: '{}' + headers: + Access-Control-Allow-Credentials: + - "true" + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - '*' + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - "2" + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:37 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - '*' + X-Ratelimit-Limit: + - "1840" + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: "" diff --git a/test/integration/fixtures/TestProfile_TFAEnforced.yaml b/test/integration/fixtures/TestProfile_TFAEnforced.yaml new file mode 100644 index 000000000..7de08fc6c --- /dev/null +++ b/test/integration/fixtures/TestProfile_TFAEnforced.yaml @@ -0,0 +1,253 @@ +--- +version: 1 +interactions: +- request: + body: '' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/settings + method: GET + response: + body: '{"managed": false, "network_helper": false, "longview_subscription": null, + "backups_enabled": false, "object_storage": "active", "interfaces_for_new_linodes": + "legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/migrate", + "tfa_enforced": false}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - "*" + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - '265' + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:06 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - "*" + X-Ratelimit-Limit: + - '1840' + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: '' +- request: + body: '' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/profile + method: GET + response: + body: '{"uid":1000,"username":"example-user","email":"example-user@example.com","verified_phone_number":null,"timezone":"UTC","email_notifications":false,"referrals":{"code":"example-referral-code","url":"https://www.linode.com/?r=example-referral-code","total":0,"completed":0,"pending":0,"credit":0},"ip_whitelist_enabled":false,"lish_auth_method":"password_keys","authorized_keys":null,"two_factor_auth":true,"restricted":false,"authentication_type":"password","user_type":"default","tfa_enforced":false}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - "*" + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - '500' + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:06 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - "*" + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - "*" + X-Ratelimit-Limit: + - '1840' + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: '' +- request: + body: '' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/account/tfa-optional-users?page=1 + method: GET + response: + body: '{"data": [], "page": 1, "pages": 1, "results": 0}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - "*" + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - '49' + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:06 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_only + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - "*" + X-Ratelimit-Limit: + - '50' + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: '' +- request: + body: '{"email_notifications":false}' + form: {} + headers: + Accept: + - application/json + Content-Type: + - application/json + User-Agent: + - linodego/(devel) https://github.com/linode/linodego + url: https://api.linode.com/v4beta/profile + method: PUT + response: + body: '{"uid":1000,"username":"example-user","email":"example-user@example.com","verified_phone_number":null,"timezone":"UTC","email_notifications":false,"referrals":{"code":"example-referral-code","url":"https://www.linode.com/?r=example-referral-code","total":0,"completed":0,"pending":0,"credit":0},"ip_whitelist_enabled":false,"lish_auth_method":"password_keys","authorized_keys":null,"two_factor_auth":true,"restricted":false,"authentication_type":"password","user_type":"default","tfa_enforced":false}' + headers: + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Headers: + - Authorization, Origin, X-Requested-With, Content-Type, Accept, X-Filter + Access-Control-Allow-Methods: + - HEAD, GET, OPTIONS, POST, PUT, DELETE + Access-Control-Allow-Origin: + - "*" + Access-Control-Expose-Headers: + - X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Status + Akamai-Internal-Account: + - "*" + Cache-Control: + - max-age=0, no-cache, no-store + Connection: + - keep-alive + Content-Length: + - '500' + Content-Security-Policy: + - default-src 'none' + Content-Type: + - application/json + Expires: + - Wed, 21 Oct 2026 20:41:06 GMT + Pragma: + - no-cache + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Authorization, X-Filter + X-Accepted-Oauth-Scopes: + - account:read_write + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - DENY + - DENY + X-Oauth-Scopes: + - "*" + X-Ratelimit-Limit: + - '1840' + X-Xss-Protection: + - 1; mode=block + status: 200 OK + code: 200 + duration: '' diff --git a/test/integration/profile_test.go b/test/integration/profile_test.go index e2e96d1b9..211998c38 100644 --- a/test/integration/profile_test.go +++ b/test/integration/profile_test.go @@ -3,6 +3,9 @@ package integration import ( "context" "testing" + + "github.com/linode/linodego/v2" + "github.com/stretchr/testify/require" ) func TestProfile_Get_smoke(t *testing.T) { @@ -40,3 +43,33 @@ func TestProfile_Update(t *testing.T) { t.Errorf("Expected profile email to be changed, but found %v", i) } } + +func TestProfile_TFAEnforced(t *testing.T) { + client, teardown := createTestClient(t, "fixtures/TestProfile_TFAEnforced") + t.Cleanup(teardown) + warnSensitiveTest(t) + + ctx := context.Background() + settings, err := client.GetAccountSettings(ctx) + require.NoError(t, err) + profile, err := client.GetProfile(ctx) + require.NoError(t, err) + exemptions, err := client.ListTFAOptionalUsers(ctx, nil) + require.NoError(t, err) + + expectedEnforcement := settings.TFAEnforced + for _, user := range exemptions { + if user.Username == profile.Username { + expectedEnforcement = false + break + } + } + require.Equal(t, expectedEnforcement, profile.TFAEnforced) + + updated, err := client.UpdateProfile(ctx, linodego.ProfileUpdateOptions{ + EmailNotifications: new(profile.EmailNotifications), + }) + require.NoError(t, err) + require.Equal(t, profile.TFAEnforced, updated.TFAEnforced) + require.Equal(t, profile.EmailNotifications, updated.EmailNotifications) +} diff --git a/test/unit/account_settings_test.go b/test/unit/account_settings_test.go index bfaceadcd..47f894593 100644 --- a/test/unit/account_settings_test.go +++ b/test/unit/account_settings_test.go @@ -4,8 +4,10 @@ import ( "context" "testing" + "github.com/jarcoal/httpmock" "github.com/linode/linodego/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // Helper function to create *bool @@ -28,6 +30,7 @@ func TestAccountSettings_Get(t *testing.T) { assert.NotNil(t, accountSettings, "Account settings should not be nil") assert.False(t, accountSettings.Managed, "Expected 'managed' to be false") assert.True(t, accountSettings.NetworkHelper, "Expected 'network_helper' to be true") + assert.False(t, accountSettings.TFAEnforced) assert.Nil(t, accountSettings.LongviewSubscription, "Expected 'longview_subscription' to be nil") assert.True(t, accountSettings.BackupsEnabled, "Expected 'backups_enabled' to be true") assert.Equal(t, "active", *accountSettings.ObjectStorage, "Expected 'object_storage' to be 'active'") @@ -50,6 +53,7 @@ func TestAccountSettings_Update(t *testing.T) { requestData := linodego.AccountSettingsUpdateOptions{ BackupsEnabled: Bool(true), NetworkHelper: Bool(true), + TFAEnforced: Bool(true), InterfacesForNewLinodes: &i, MaintenancePolicy: linodego.Pointer("linode/power_off_on"), } @@ -60,6 +64,7 @@ func TestAccountSettings_Update(t *testing.T) { assert.NotNil(t, accountSettings, "Account settings should not be nil") assert.False(t, accountSettings.Managed, "Expected 'managed' to be false") assert.True(t, accountSettings.NetworkHelper, "Expected 'network_helper' to be true") + assert.True(t, accountSettings.TFAEnforced) assert.Nil(t, accountSettings.LongviewSubscription, "Expected 'longview_subscription' to be nil") assert.True(t, accountSettings.BackupsEnabled, "Expected 'backups_enabled' to be true") assert.Equal(t, "active", *accountSettings.ObjectStorage, "Expected 'object_storage' to be 'active'") @@ -69,3 +74,32 @@ func TestAccountSettings_Update(t *testing.T) { ) assert.Equal(t, "linode/power_off_on", accountSettings.MaintenancePolicy, "Expected 'maintenance_policy' to be 'linode/power_off_on'") } + +func TestAccountSettings_UpdateTFAEnforced(t *testing.T) { + for _, testCase := range []struct { + name string + enforced bool + fixture string + }{ + {name: "enable", enforced: true, fixture: "account_settings_update"}, + {name: "disable", enforced: false, fixture: "account_settings_get"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixtureData, err := fixtures.GetFixture(testCase.fixture) + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + t.Cleanup(func() { + base.TearDown(t) + }) + + opts := linodego.AccountSettingsUpdateOptions{TFAEnforced: new(testCase.enforced)} + httpmock.RegisterResponder("PUT", base.BaseURL+"account/settings", mockRequestBodyValidate(t, opts, fixtureData)) + + settings, err := base.Client.UpdateAccountSettings(context.Background(), opts) + require.NoError(t, err) + assert.Equal(t, testCase.enforced, settings.TFAEnforced) + }) + } +} diff --git a/test/unit/account_tfa_optional_users_test.go b/test/unit/account_tfa_optional_users_test.go new file mode 100644 index 000000000..175333b03 --- /dev/null +++ b/test/unit/account_tfa_optional_users_test.go @@ -0,0 +1,183 @@ +package unit + +import ( + "context" + "maps" + "net/http" + "strconv" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/linode/linodego/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAccountTFAOptionalUsers_List(t *testing.T) { + fixtureData, err := fixtures.GetFixture("account_tfa_optional_users_list") + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + t.Cleanup(func() { + base.TearDown(t) + }) + + base.MockGet("account/tfa-optional-users", fixtureData) + + users, err := base.Client.ListTFAOptionalUsers(context.Background(), nil) + require.NoError(t, err) + assert.Equal(t, []linodego.TFAOptionalUser{ + {Username: "user1", URL: "/account/users/user1"}, + {Username: "user2", URL: "/account/users/user2"}, + }, users) +} + +func TestAccountTFAOptionalUsers_Update(t *testing.T) { + for _, testCase := range []struct { + name string + usernames []string + fixture string + }{ + {name: "replace", usernames: []string{"user1", "user2"}, fixture: "account_tfa_optional_users_update"}, + {name: "clear", usernames: []string{}, fixture: "account_tfa_optional_users_update_empty"}, + } { + t.Run(testCase.name, func(t *testing.T) { + fixtureData, err := fixtures.GetFixture(testCase.fixture) + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + t.Cleanup(func() { + base.TearDown(t) + }) + + opts := linodego.TFAOptionalUsersUpdateOptions{Usernames: testCase.usernames} + httpmock.RegisterResponder("PUT", base.BaseURL+"account/tfa-optional-users", mockRequestBodyValidate(t, opts, fixtureData)) + + response, err := base.Client.UpdateTFAOptionalUsers(context.Background(), opts) + require.NoError(t, err) + assert.Equal(t, len(testCase.usernames), response.TFAOptionalUsersCount) + assert.Equal(t, "/account/tfa-optional-users", response.URL) + }) + } +} + +func TestAccountTFAOptionalUsers_ListEmpty(t *testing.T) { + fixtureData, err := fixtures.GetFixture("account_tfa_optional_users_list_empty") + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + t.Cleanup(func() { + base.TearDown(t) + }) + + base.MockGet("account/tfa-optional-users", fixtureData) + + users, err := base.Client.ListTFAOptionalUsers(context.Background(), nil) + require.NoError(t, err) + assert.Empty(t, users) +} + +func TestAccountTFAOptionalUsers_ListPagination(t *testing.T) { + fixtureData, err := fixtures.GetFixture("account_tfa_optional_users_list") + require.NoError(t, err) + fixturePage := fixtureData.(map[string]interface{}) + fixtureUsers := fixturePage["data"].([]interface{}) + + for _, testCase := range []struct { + name string + page int + expectedUsers []linodego.TFAOptionalUser + expectedPages []int + }{ + { + name: "all_pages", + expectedUsers: []linodego.TFAOptionalUser{ + {Username: "user1", URL: "/account/users/user1"}, + {Username: "user2", URL: "/account/users/user2"}, + }, + expectedPages: []int{1, 2}, + }, + { + name: "single_page", + page: 2, + expectedUsers: []linodego.TFAOptionalUser{{Username: "user2", URL: "/account/users/user2"}}, + expectedPages: []int{2}, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + var base ClientBaseCase + base.SetUp(t) + t.Cleanup(func() { + base.TearDown(t) + }) + + var requestedPages []int + httpmock.RegisterResponder("GET", base.BaseURL+"account/tfa-optional-users", func(req *http.Request) (*http.Response, error) { + page, err := strconv.Atoi(req.URL.Query().Get("page")) + require.NoError(t, err) + require.Contains(t, []int{1, 2}, page) + assert.Equal(t, "1", req.URL.Query().Get("page_size")) + requestedPages = append(requestedPages, page) + + response := maps.Clone(fixturePage) + response["data"] = fixtureUsers[page-1 : page] + response["page"] = page + response["pages"] = 2 + return httpmock.NewJsonResponse(http.StatusOK, response) + }) + + opts := linodego.NewListOptions(testCase.page, "") + opts.PageSize = 1 + users, err := base.Client.ListTFAOptionalUsers(context.Background(), opts) + require.NoError(t, err) + assert.Equal(t, testCase.expectedUsers, users) + assert.Equal(t, testCase.expectedPages, requestedPages) + assert.Equal(t, 2, opts.Page) + assert.Equal(t, 2, opts.Pages) + assert.Equal(t, 2, opts.Results) + }) + } +} + +func TestAccountTFAOptionalUsers_Errors(t *testing.T) { + for _, testCase := range []struct { + name string + method string + code int + reason string + }{ + {name: "list_forbidden", method: "GET", code: http.StatusForbidden, reason: "Permission denied"}, + {name: "update_forbidden", method: "PUT", code: http.StatusForbidden, reason: "Permission denied"}, + {name: "invalid_username", method: "PUT", code: http.StatusBadRequest, reason: "Invalid username"}, + } { + t.Run(testCase.name, func(t *testing.T) { + var base ClientBaseCase + base.SetUp(t) + t.Cleanup(func() { + base.TearDown(t) + }) + + httpmock.RegisterResponder(testCase.method, base.BaseURL+"account/tfa-optional-users", + httpmock.NewJsonResponderOrPanic(testCase.code, linodego.APIError{ + Errors: []linodego.APIErrorReason{{Reason: testCase.reason}}, + })) + + var err error + if testCase.method == "GET" { + _, err = base.Client.ListTFAOptionalUsers(context.Background(), nil) + } else { + _, err = base.Client.UpdateTFAOptionalUsers(context.Background(), linodego.TFAOptionalUsersUpdateOptions{ + Usernames: []string{"invalid-user"}, + }) + } + + var apiError *linodego.Error + require.ErrorAs(t, err, &apiError) + assert.Equal(t, testCase.code, apiError.Code) + assert.Contains(t, apiError.Message, testCase.reason) + }) + } +} diff --git a/test/unit/account_users_test.go b/test/unit/account_users_test.go index cbea52dd0..a4c9b3716 100644 --- a/test/unit/account_users_test.go +++ b/test/unit/account_users_test.go @@ -2,11 +2,13 @@ package unit import ( "context" + "net/http" "testing" "github.com/jarcoal/httpmock" "github.com/linode/linodego/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestAccountUsers_List(t *testing.T) { @@ -30,6 +32,7 @@ func TestAccountUsers_List(t *testing.T) { assert.Equal(t, true, user.Restricted) assert.Equal(t, []string{"home-pc", "laptop"}, user.SSHKeys) assert.Equal(t, true, user.TFAEnabled) + assert.True(t, user.TFAEnforced) assert.Equal(t, linodego.UserType("parent"), user.UserType) assert.Equal(t, "jsmith", user.Username) assert.Equal(t, "+5555555555", *user.VerifiedPhoneNumber) @@ -54,6 +57,7 @@ func TestAccountUsers_Get(t *testing.T) { assert.Equal(t, true, user.Restricted) assert.Equal(t, []string{"home-pc", "laptop"}, user.SSHKeys) assert.Equal(t, true, user.TFAEnabled) + assert.False(t, user.TFAEnforced) assert.Equal(t, linodego.UserType("parent"), user.UserType) assert.Equal(t, "jsmith", user.Username) assert.Equal(t, "+5555555555", *user.VerifiedPhoneNumber) @@ -82,6 +86,7 @@ func TestAccountUsers_Create(t *testing.T) { assert.Equal(t, true, user.Restricted) assert.Equal(t, []string{"home-pc", "laptop"}, user.SSHKeys) assert.Equal(t, true, user.TFAEnabled) + assert.True(t, user.TFAEnforced) assert.Equal(t, "example_user", user.Username) assert.Equal(t, "+5555555555", *user.VerifiedPhoneNumber) } @@ -111,6 +116,7 @@ func TestAccountUsers_Update(t *testing.T) { assert.Equal(t, true, user.Restricted) assert.Equal(t, []string{"home-pc", "laptop"}, user.SSHKeys) assert.Equal(t, true, user.TFAEnabled) + assert.True(t, user.TFAEnforced) assert.Equal(t, linodego.UserType("parent"), user.UserType) assert.Equal(t, "adevi", user.Username) assert.Equal(t, "+5555555555", *user.VerifiedPhoneNumber) @@ -125,3 +131,25 @@ func TestAccountUsers_Delete(t *testing.T) { t.Fatal(err) } } + +func TestAccountUsers_ListTFAEnforced(t *testing.T) { + fixtureData, err := fixtures.GetFixture("account_users_list") + require.NoError(t, err) + + var base ClientBaseCase + base.SetUp(t) + t.Cleanup(func() { + base.TearDown(t) + }) + + filter := `{"tfa_enforced":true}` + httpmock.RegisterResponder("GET", base.BaseURL+"account/users", func(req *http.Request) (*http.Response, error) { + assert.JSONEq(t, filter, req.Header.Get("X-Filter")) + return httpmock.NewJsonResponse(http.StatusOK, fixtureData) + }) + + users, err := base.Client.ListUsers(context.Background(), linodego.NewListOptions(0, filter)) + require.NoError(t, err) + require.Len(t, users, 1) + assert.True(t, users[0].TFAEnforced) +} diff --git a/test/unit/fixtures/account_settings_get.json b/test/unit/fixtures/account_settings_get.json index 66facb189..9da4ae465 100644 --- a/test/unit/fixtures/account_settings_get.json +++ b/test/unit/fixtures/account_settings_get.json @@ -4,6 +4,7 @@ "longview_subscription": null, "backups_enabled": true, "object_storage": "active", + "tfa_enforced": false, "interfaces_for_new_linodes": "legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/migrate" } \ No newline at end of file diff --git a/test/unit/fixtures/account_settings_update.json b/test/unit/fixtures/account_settings_update.json index ffc9f84c2..f9176cc45 100644 --- a/test/unit/fixtures/account_settings_update.json +++ b/test/unit/fixtures/account_settings_update.json @@ -4,6 +4,7 @@ "longview_subscription": null, "backups_enabled": true, "object_storage": "active", + "tfa_enforced": true, "interfaces_for_new_linodes": "legacy_config_default_but_linode_allowed", "maintenance_policy": "linode/power_off_on" } \ No newline at end of file diff --git a/test/unit/fixtures/account_tfa_optional_users_list.json b/test/unit/fixtures/account_tfa_optional_users_list.json new file mode 100644 index 000000000..6dff86635 --- /dev/null +++ b/test/unit/fixtures/account_tfa_optional_users_list.json @@ -0,0 +1,15 @@ +{ + "data": [ + { + "username": "user1", + "url": "/account/users/user1" + }, + { + "username": "user2", + "url": "/account/users/user2" + } + ], + "page": 1, + "pages": 1, + "results": 2 +} \ No newline at end of file diff --git a/test/unit/fixtures/account_tfa_optional_users_list_empty.json b/test/unit/fixtures/account_tfa_optional_users_list_empty.json new file mode 100644 index 000000000..fd0aebfd3 --- /dev/null +++ b/test/unit/fixtures/account_tfa_optional_users_list_empty.json @@ -0,0 +1,6 @@ +{ + "data": [], + "page": 1, + "pages": 1, + "results": 0 +} \ No newline at end of file diff --git a/test/unit/fixtures/account_tfa_optional_users_update.json b/test/unit/fixtures/account_tfa_optional_users_update.json new file mode 100644 index 000000000..a185ecf2f --- /dev/null +++ b/test/unit/fixtures/account_tfa_optional_users_update.json @@ -0,0 +1,4 @@ +{ + "tfa_optional_users_count": 2, + "url": "/account/tfa-optional-users" +} \ No newline at end of file diff --git a/test/unit/fixtures/account_tfa_optional_users_update_empty.json b/test/unit/fixtures/account_tfa_optional_users_update_empty.json new file mode 100644 index 000000000..4f7c0c5a7 --- /dev/null +++ b/test/unit/fixtures/account_tfa_optional_users_update_empty.json @@ -0,0 +1,4 @@ +{ + "tfa_optional_users_count": 0, + "url": "/account/tfa-optional-users" +} \ No newline at end of file diff --git a/test/unit/fixtures/account_users_create.json b/test/unit/fixtures/account_users_create.json index 883ee2949..b1ebcdfaa 100644 --- a/test/unit/fixtures/account_users_create.json +++ b/test/unit/fixtures/account_users_create.json @@ -11,6 +11,7 @@ "laptop" ], "tfa_enabled": true, + "tfa_enforced": true, "username": "example_user", "verified_phone_number": "+5555555555" } \ No newline at end of file diff --git a/test/unit/fixtures/account_users_get.json b/test/unit/fixtures/account_users_get.json index 245574b1c..b93695b3d 100644 --- a/test/unit/fixtures/account_users_get.json +++ b/test/unit/fixtures/account_users_get.json @@ -11,6 +11,7 @@ "laptop" ], "tfa_enabled": true, + "tfa_enforced": false, "user_type": "parent", "username": "jsmith", "verified_phone_number": "+5555555555" diff --git a/test/unit/fixtures/account_users_list.json b/test/unit/fixtures/account_users_list.json index dbbb1c951..04f0aca7d 100644 --- a/test/unit/fixtures/account_users_list.json +++ b/test/unit/fixtures/account_users_list.json @@ -13,6 +13,7 @@ "laptop" ], "tfa_enabled": true, + "tfa_enforced": true, "user_type": "parent", "username": "jsmith", "verified_phone_number": "+5555555555" diff --git a/test/unit/fixtures/account_users_update.json b/test/unit/fixtures/account_users_update.json index 71b047318..db10d2ffd 100644 --- a/test/unit/fixtures/account_users_update.json +++ b/test/unit/fixtures/account_users_update.json @@ -11,6 +11,7 @@ "laptop" ], "tfa_enabled": true, + "tfa_enforced": true, "user_type": "parent", "username": "adevi", "verified_phone_number": "+5555555555" diff --git a/test/unit/fixtures/profile_get.json b/test/unit/fixtures/profile_get.json index c9ec4a6f8..3bdc46d24 100644 --- a/test/unit/fixtures/profile_get.json +++ b/test/unit/fixtures/profile_get.json @@ -15,6 +15,7 @@ "restricted": false, "timezone": "US/Eastern", "two_factor_auth": true, + "tfa_enforced": false, "uid": 1234, "username": "exampleUser", "verified_phone_number": "+5555555555" diff --git a/test/unit/fixtures/profile_update.json b/test/unit/fixtures/profile_update.json index f197db660..0e378e45f 100644 --- a/test/unit/fixtures/profile_update.json +++ b/test/unit/fixtures/profile_update.json @@ -15,6 +15,7 @@ "restricted": false, "timezone": "US/Eastern", "two_factor_auth": true, + "tfa_enforced": true, "uid": 1234, "username": "exampleUser", "verified_phone_number": "+5555555555" diff --git a/test/unit/profile_test.go b/test/unit/profile_test.go index 0c414dd5f..e904bc2f2 100644 --- a/test/unit/profile_test.go +++ b/test/unit/profile_test.go @@ -34,6 +34,7 @@ func TestProfile_Get(t *testing.T) { assert.Equal(t, false, profile.Restricted) assert.Equal(t, "US/Eastern", profile.Timezone) assert.Equal(t, true, profile.TwoFactorAuth) + assert.False(t, profile.TFAEnforced) assert.Equal(t, 1234, profile.UID) assert.Equal(t, "exampleUser", profile.Username) assert.Equal(t, "+5555555555", profile.VerifiedPhoneNumber) @@ -69,6 +70,7 @@ func TestProfile_Update(t *testing.T) { assert.Equal(t, false, profile.Restricted) assert.Equal(t, "US/Eastern", profile.Timezone) assert.Equal(t, true, profile.TwoFactorAuth) + assert.True(t, profile.TFAEnforced) assert.Equal(t, 1234, profile.UID) assert.Equal(t, "exampleUser", profile.Username) assert.Equal(t, "+5555555555", profile.VerifiedPhoneNumber) diff --git a/test/unit/profile_tfa_test.go b/test/unit/profile_tfa_test.go index ff1ff8022..aa8f09d87 100644 --- a/test/unit/profile_tfa_test.go +++ b/test/unit/profile_tfa_test.go @@ -2,12 +2,14 @@ package unit import ( "context" + "net/http" "testing" "time" "github.com/jarcoal/httpmock" "github.com/linode/linodego/v2" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTwoFactor_CreateSecret_smoke(t *testing.T) { @@ -55,3 +57,42 @@ func TestTwoFactor_Confirm(t *testing.T) { assert.Equal(t, "reallycoolandlegittfacode", response.Scratch) } + +func TestTwoFactor_DisableEnforced(t *testing.T) { + for _, testCase := range []struct { + name string + method string + endpoint string + }{ + {name: "disable", method: "POST", endpoint: "profile/tfa-disable"}, + {name: "update_profile", method: "PUT", endpoint: "profile"}, + } { + t.Run(testCase.name, func(t *testing.T) { + var base ClientBaseCase + base.SetUp(t) + t.Cleanup(func() { + base.TearDown(t) + }) + + reason := "TFA is enforced for this user" + httpmock.RegisterResponder(testCase.method, base.BaseURL+testCase.endpoint, + httpmock.NewJsonResponderOrPanic(http.StatusBadRequest, linodego.APIError{ + Errors: []linodego.APIErrorReason{{Reason: reason}}, + })) + + var err error + if testCase.method == "POST" { + err = base.Client.DisableTwoFactor(context.Background()) + } else { + _, err = base.Client.UpdateProfile(context.Background(), linodego.ProfileUpdateOptions{ + TwoFactorAuth: new(false), + }) + } + + var apiError *linodego.Error + require.ErrorAs(t, err, &apiError) + assert.Equal(t, http.StatusBadRequest, apiError.Code) + assert.Contains(t, apiError.Message, reason) + }) + } +}