diff --git a/README.md b/README.md index bf2ab16..a45e45a 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ bwh start/stop/restart # Power management bwh usage --period 7d # Check usage statistics bwh abuse suspensions # Show suspension details bwh notifications list # Show notification preferences +bwh notifications set on --dry-run # Preview notification changes bwh snapshot create "backup-name" # Create snapshots bwh iso images # List available ISO images bwh iso mount ubuntu-20.04.iso # Mount ISO for rescue/install @@ -107,9 +108,9 @@ backups, err := c.ListBackups(ctx) **Migration**: `GetMigrateLocations`, `StartMigration` (use `StartMigrationWithTimeout` for custom timeouts) -**Security & Abuse**: `GetSuspensionDetails`, `GetPolicyViolations` +**Security & Abuse**: `GetSuspensionDetails`, `GetPolicyViolations`, `Unsuspend`, `ResolvePolicyViolation` -**Notifications**: `GetNotificationPreferences` +**Notifications**: `GetNotificationPreferences`, `SetNotificationPreferences` **Network**: SSH key management, IP/reverse DNS configuration, IPv6 subnet management, private IPv4 management @@ -243,8 +244,8 @@ iso Manage ISO images for VPS boot reinstall Reinstall VPS operating system (WARNING: destroys all data) usage Display detailed VPS usage statistics audit Display audit log entries -abuse Display suspension details and policy violations -notifications Display KiwiVM notification preferences +abuse Display and resolve suspension details and policy violations +notifications Display and update KiwiVM notification preferences reset-password Reset the root password snapshot Manage VPS snapshots backup Manage VPS backups @@ -258,6 +259,16 @@ completion Generate shell completion script Use `bwh --help` to view detailed options and usage examples for each command. +### Abuse and Notification Writes + +```bash +bwh abuse unsuspend --dry-run +bwh abuse resolve-policy --dry-run +bwh notifications set --dry-run +``` + +Use `--dry-run` to validate and preview without calling write APIs. Add `--yes` only when you want to skip the y/N prompt. + ## Build ```bash diff --git a/README.zh.md b/README.zh.md index 0e9c05b..d18f8e6 100644 --- a/README.zh.md +++ b/README.zh.md @@ -35,6 +35,7 @@ bwh start/stop/restart # 电源管理 bwh usage --period 7d # 检查使用统计 bwh abuse suspensions # 查看暂停详情 bwh notifications list # 查看通知偏好 +bwh notifications set on --dry-run # 预览通知偏好修改 bwh snapshot create "备份名称" # 创建快照 bwh iso images # 列出可用 ISO 镜像 bwh iso mount ubuntu-20.04.iso # 挂载 ISO 用于救援/安装 @@ -107,9 +108,9 @@ backups, err := c.ListBackups(ctx) **迁移**: `GetMigrateLocations`、`StartMigration`(支持 `StartMigrationWithTimeout` 自定义超时) -**安全与 abuse**: `GetSuspensionDetails`、`GetPolicyViolations` +**安全与 abuse**: `GetSuspensionDetails`、`GetPolicyViolations`、`Unsuspend`、`ResolvePolicyViolation` -**通知**: `GetNotificationPreferences` +**通知**: `GetNotificationPreferences`、`SetNotificationPreferences` **网络**: SSH 密钥管理、IP/反向 DNS 配置、IPv6 子网管理、私有 IPv4 管理 @@ -243,8 +244,8 @@ iso 管理 VPS 启动用 ISO 镜像 reinstall 重装 VPS 操作系统(警告:摧毁所有数据) usage 显示详细 VPS 使用统计 audit 显示审计日志条目 -abuse 显示暂停详情与策略违规 -notifications 显示 KiwiVM 通知偏好 +abuse 显示并处理暂停详情与策略违规 +notifications 显示并更新 KiwiVM 通知偏好 reset-password 重置 root 密码 snapshot 管理 VPS 快照 backup 管理 VPS 备份 @@ -258,6 +259,16 @@ completion 生成 shell 自动补全脚本 使用 `bwh --help` 查看每个命令的详细选项和用法示例。 +### Abuse 与通知写命令 + +```bash +bwh abuse unsuspend --dry-run +bwh abuse resolve-policy --dry-run +bwh notifications set --dry-run +``` + +使用 `--dry-run` 做校验和预览,不调用写 API。确认需要跳过 y/N 提示时再加 `--yes`。 + ## 构建 ```bash diff --git a/cmd/bwh/abuse.go b/cmd/bwh/abuse.go index ce6ca0a..80ddd78 100644 --- a/cmd/bwh/abuse.go +++ b/cmd/bwh/abuse.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strconv" "time" "github.com/strahe/bwh/pkg/client" @@ -12,10 +13,24 @@ import ( var abuseCmd = &cli.Command{ Name: "abuse", - Usage: "inspect abuse suspensions and policy violations", + Usage: "display and resolve abuse suspensions and policy violations", Commands: []*cli.Command{ abuseSuspensionsCmd, abusePolicyCmd, + abuseUnsuspendCmd, + abuseResolvePolicyCmd, + }, +} + +var abuseWriteFlags = []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "skip confirmation prompt", + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "validate and show the write action without calling the write API", }, } @@ -59,6 +74,61 @@ var abusePolicyCmd = &cli.Command{ }, } +var abuseUnsuspendCmd = &cli.Command{ + Name: "unsuspend", + Usage: "clear a soft abuse issue and unsuspend the VPS", + ArgsUsage: "", + Flags: abuseWriteFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() != 1 { + return fmt.Errorf("record_id is required") + } + recordID, err := parseRecordID(cmd.Args().First()) + if err != nil { + return err + } + + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + + return runAbuseUnsuspend(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation) + }, +} + +var abuseResolvePolicyCmd = &cli.Command{ + Name: "resolve-policy", + Usage: "mark a soft policy violation as resolved", + ArgsUsage: "", + Flags: abuseWriteFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() != 1 { + return fmt.Errorf("record_id is required") + } + recordID, err := parseRecordID(cmd.Args().First()) + if err != nil { + return err + } + + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + + return runAbuseResolvePolicy(ctx, bwhClient, resolvedName, recordID, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation) + }, +} + +type abuseAPI interface { + GetSuspensionDetails(context.Context) (*client.SuspensionDetailsResponse, error) + GetPolicyViolations(context.Context) (*client.PolicyViolationsResponse, error) + Unsuspend(context.Context, int) error + ResolvePolicyViolation(context.Context, int) error +} + +type confirmationFunc func(string) (bool, error) + func displaySuspensionDetails(resp *client.SuspensionDetailsResponse) { fmt.Printf("\n🚫 SUSPENSION DETAILS\n") fmt.Printf(" Suspensions (YTD): %d\n", resp.SuspensionCount) @@ -131,3 +201,122 @@ func summarizeText(s string, maxLen int) string { } return string(runes[:maxLen]) + "..." } + +func parseRecordID(raw string) (int, error) { + recordID, err := strconv.Atoi(raw) + if err != nil || recordID <= 0 { + return 0, fmt.Errorf("record_id must be a positive integer") + } + return recordID, nil +} + +func runAbuseUnsuspend(ctx context.Context, api abuseAPI, resolvedName string, recordID int, dryRun, skipConfirm bool, confirm confirmationFunc) error { + fmt.Printf("Checking suspension case #%d for instance: %s\n", recordID, resolvedName) + resp, err := api.GetSuspensionDetails(ctx) + if err != nil { + return fmt.Errorf("failed to get suspension details: %w", err) + } + + record, ok := findSuspensionRecord(resp.Suspensions, recordID) + if !ok { + return fmt.Errorf("suspension case #%d not found", recordID) + } + printSuspensionActionSummary(record) + if record.IsSoft != 1 { + return fmt.Errorf("suspension case #%d cannot be resolved through API; contact support", recordID) + } + if dryRun { + fmt.Printf("DRY RUN: would call unsuspend for case #%d on instance %s\n", recordID, resolvedName) + return nil + } + if !skipConfirm { + confirmed, err := confirm(fmt.Sprintf("Unsuspend VPS by clearing case #%d?", recordID)) + if err != nil { + return err + } + if !confirmed { + fmt.Printf("Operation cancelled\n") + return nil + } + } + + if err := api.Unsuspend(ctx, recordID); err != nil { + return fmt.Errorf("failed to unsuspend case #%d: %w", recordID, err) + } + fmt.Printf("✅ Suspension case #%d cleared\n", recordID) + return nil +} + +func runAbuseResolvePolicy(ctx context.Context, api abuseAPI, resolvedName string, recordID int, dryRun, skipConfirm bool, confirm confirmationFunc) error { + fmt.Printf("Checking policy violation case #%d for instance: %s\n", recordID, resolvedName) + resp, err := api.GetPolicyViolations(ctx) + if err != nil { + return fmt.Errorf("failed to get policy violations: %w", err) + } + + record, ok := findPolicyViolationRecord(resp.PolicyViolations, recordID) + if !ok { + return fmt.Errorf("policy violation case #%d not found", recordID) + } + printPolicyActionSummary(record) + if record.IsSoft != 1 { + return fmt.Errorf("policy violation case #%d cannot be resolved through API; contact support", recordID) + } + if dryRun { + fmt.Printf("DRY RUN: would call resolvePolicyViolation for case #%d on instance %s\n", recordID, resolvedName) + return nil + } + if !skipConfirm { + confirmed, err := confirm(fmt.Sprintf("Mark policy violation case #%d as resolved?", recordID)) + if err != nil { + return err + } + if !confirmed { + fmt.Printf("Operation cancelled\n") + return nil + } + } + + if err := api.ResolvePolicyViolation(ctx, recordID); err != nil { + return fmt.Errorf("failed to resolve policy violation case #%d: %w", recordID, err) + } + fmt.Printf("✅ Policy violation case #%d resolved\n", recordID) + return nil +} + +func findSuspensionRecord(records []client.SuspensionRecord, recordID int) (client.SuspensionRecord, bool) { + for _, record := range records { + if record.RecordID == recordID { + return record, true + } + } + return client.SuspensionRecord{}, false +} + +func findPolicyViolationRecord(records []client.PolicyViolationRecord, recordID int) (client.PolicyViolationRecord, bool) { + for _, record := range records { + if record.RecordID == recordID { + return record, true + } + } + return client.PolicyViolationRecord{}, false +} + +func printSuspensionActionSummary(record client.SuspensionRecord) { + fmt.Printf("\nTarget suspension case:\n") + fmt.Printf(" Case ID : %d\n", record.RecordID) + fmt.Printf(" Flag : %s\n", record.Flag) + fmt.Printf(" Soft Resolve: %s\n", yesNo(record.IsSoft == 1)) + fmt.Printf(" Abuse Points: %d\n", record.AbusePoints) +} + +func printPolicyActionSummary(record client.PolicyViolationRecord) { + fmt.Printf("\nTarget policy violation:\n") + fmt.Printf(" Case ID : %d\n", record.RecordID) + fmt.Printf(" Flag : %s\n", record.Flag) + fmt.Printf(" Soft Resolve: %s\n", yesNo(record.IsSoft == 1)) + fmt.Printf(" Abuse Points: %d\n", record.AbusePoints) + if record.SuspendAt > 0 { + fmt.Printf(" Suspend At : %s\n", time.Unix(record.SuspendAt, 0).Format("2006-01-02 15:04:05")) + } +} diff --git a/cmd/bwh/helpers.go b/cmd/bwh/helpers.go index ab20a6a..bb49d7a 100644 --- a/cmd/bwh/helpers.go +++ b/cmd/bwh/helpers.go @@ -64,6 +64,14 @@ func formatBytes(bytes int64) string { return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp]) } +// yesNo converts a boolean to a user-friendly Yes/No string. +func yesNo(b bool) string { + if b { + return "✅ Yes" + } + return "❌ No" +} + // validateBackupToken validates the format of a backup token func validateBackupToken(token string) error { // Backup tokens are 40-character hexadecimal strings diff --git a/cmd/bwh/notifications.go b/cmd/bwh/notifications.go index 3aa025a..e59c5ed 100644 --- a/cmd/bwh/notifications.go +++ b/cmd/bwh/notifications.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "sort" + "strings" "time" "github.com/strahe/bwh/pkg/client" @@ -12,9 +13,22 @@ import ( var notificationsCmd = &cli.Command{ Name: "notifications", - Usage: "inspect KiwiVM notification preferences", + Usage: "display and update KiwiVM notification preferences", Commands: []*cli.Command{ notificationsListCmd, + notificationsSetCmd, + }, +} + +var notificationsWriteFlags = []cli.Flag{ + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "skip confirmation prompt", + }, + &cli.BoolFlag{ + Name: "dry-run", + Usage: "validate and show the write action without calling the write API", }, } @@ -38,6 +52,38 @@ var notificationsListCmd = &cli.Command{ }, } +var notificationsSetCmd = &cli.Command{ + Name: "set", + Usage: "set a KiwiVM notification preference", + ArgsUsage: " ", + Flags: notificationsWriteFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() != 2 { + return fmt.Errorf("notifications set requires exactly two arguments: ") + } + preferenceID := strings.TrimSpace(cmd.Args().Get(0)) + if preferenceID == "" { + return fmt.Errorf("notification preference id cannot be empty") + } + enabled, err := parseNotificationState(cmd.Args().Get(1)) + if err != nil { + return err + } + + bwhClient, resolvedName, err := createBWHClient(cmd) + if err != nil { + return err + } + + return runNotificationSet(ctx, bwhClient, resolvedName, preferenceID, enabled, cmd.Bool("dry-run"), cmd.Bool("yes"), promptConfirmation) + }, +} + +type notificationAPI interface { + GetNotificationPreferences(context.Context) (*client.NotificationPreferencesResponse, error) + SetNotificationPreferences(context.Context, map[string]bool) (*client.SetNotificationPreferencesResponse, error) +} + func displayNotificationPreferences(resp *client.NotificationPreferencesResponse) { fmt.Printf("\n📧 NOTIFICATION PREFERENCES\n") if resp.NotificationEmail != "" { @@ -87,3 +133,94 @@ func enabledStatus(value int) string { } return "❌ Disabled" } + +func parseNotificationState(raw string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "on", "true", "1", "enable", "enabled": + return true, nil + case "off", "false", "0", "disable", "disabled": + return false, nil + default: + return false, fmt.Errorf("notification state must be one of: on, off, true, false, 1, 0, enable, disable, enabled, disabled") + } +} + +func runNotificationSet( + ctx context.Context, + api notificationAPI, + resolvedName string, + preferenceID string, + enabled bool, + dryRun bool, + skipConfirm bool, + confirm confirmationFunc, +) error { + fmt.Printf("Checking notification preference '%s' for instance: %s\n", preferenceID, resolvedName) + resp, err := api.GetNotificationPreferences(ctx) + if err != nil { + return fmt.Errorf("failed to get notification preferences: %w", err) + } + + category, pref, ok := findNotificationPreference(resp.EmailPreferences, preferenceID) + if !ok { + return fmt.Errorf("notification preference '%s' not found", preferenceID) + } + + currentEnabled := pref.IsEnabled == 1 + fmt.Printf("\nTarget notification preference:\n") + fmt.Printf(" ID : %s\n", preferenceID) + fmt.Printf(" Category : %s\n", category) + fmt.Printf(" Current : %s\n", enabledStatus(pref.IsEnabled)) + fmt.Printf(" Target : %s\n", enabledStatus(boolToInt(enabled))) + if pref.FriendlyDescription != "" { + fmt.Printf(" Description: %s\n", pref.FriendlyDescription) + } + + if currentEnabled == enabled { + fmt.Printf("\nNo change needed; preference already matches target state.\n") + return nil + } + if dryRun { + fmt.Printf("\nDRY RUN: would update notification preference '%s' on instance %s\n", preferenceID, resolvedName) + return nil + } + if !skipConfirm { + confirmed, err := confirm(fmt.Sprintf("Update notification preference '%s'?", preferenceID)) + if err != nil { + return err + } + if !confirmed { + fmt.Printf("Operation cancelled\n") + return nil + } + } + + updateResp, err := api.SetNotificationPreferences(ctx, map[string]bool{preferenceID: enabled}) + if err != nil { + return fmt.Errorf("failed to update notification preference: %w", err) + } + fmt.Printf("✅ Notification preference '%s' updated\n", preferenceID) + if len(updateResp.UpdatedEmailPreferences) > 0 { + fmt.Printf("Updated preferences: %d\n", len(updateResp.UpdatedEmailPreferences)) + } + return nil +} + +func findNotificationPreference( + preferences map[string]map[string]client.NotificationPreference, + preferenceID string, +) (string, client.NotificationPreference, bool) { + for category, prefs := range preferences { + if pref, ok := prefs[preferenceID]; ok { + return category, pref, true + } + } + return "", client.NotificationPreference{}, false +} + +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/cmd/bwh/private_ip.go b/cmd/bwh/private_ip.go index 92de502..be94e51 100644 --- a/cmd/bwh/private_ip.go +++ b/cmd/bwh/private_ip.go @@ -207,14 +207,6 @@ var privateIPDeleteCmd = &cli.Command{ }, } -// yesNo converts a boolean to user-friendly Yes/No string -func yesNo(b bool) string { - if b { - return "✅ Yes" - } - return "❌ No" -} - // aggregateIPv4Ranges groups contiguous IPv4 addresses into concise ranges. // If start and end share the same first three octets, prints as A.B.C.start-endD (e.g., 10.59.12.26-254). // Otherwise prints as startIP-endIP. Singletons are printed as the single IP. diff --git a/cmd/bwh/write_commands_test.go b/cmd/bwh/write_commands_test.go new file mode 100644 index 0000000..efe7bd6 --- /dev/null +++ b/cmd/bwh/write_commands_test.go @@ -0,0 +1,341 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/strahe/bwh/pkg/client" +) + +type fakeAbuseAPI struct { + suspensions *client.SuspensionDetailsResponse + policy *client.PolicyViolationsResponse + unsuspended []int + resolved []int +} + +func (f *fakeAbuseAPI) GetSuspensionDetails(context.Context) (*client.SuspensionDetailsResponse, error) { + return f.suspensions, nil +} + +func (f *fakeAbuseAPI) GetPolicyViolations(context.Context) (*client.PolicyViolationsResponse, error) { + return f.policy, nil +} + +func (f *fakeAbuseAPI) Unsuspend(_ context.Context, recordID int) error { + f.unsuspended = append(f.unsuspended, recordID) + return nil +} + +func (f *fakeAbuseAPI) ResolvePolicyViolation(_ context.Context, recordID int) error { + f.resolved = append(f.resolved, recordID) + return nil +} + +type fakeNotificationAPI struct { + preferences *client.NotificationPreferencesResponse + updates []map[string]bool +} + +func (f *fakeNotificationAPI) GetNotificationPreferences(context.Context) (*client.NotificationPreferencesResponse, error) { + return f.preferences, nil +} + +func (f *fakeNotificationAPI) SetNotificationPreferences(_ context.Context, preferences map[string]bool) (*client.SetNotificationPreferencesResponse, error) { + f.updates = append(f.updates, preferences) + return &client.SetNotificationPreferencesResponse{ + UpdatedEmailPreferences: client.NotificationPreferenceStateMap{"security-successful-login": 1}, + }, nil +} + +func TestParseRecordID(t *testing.T) { + got, err := parseRecordID("123") + if err != nil { + t.Fatalf("parseRecordID() error = %v", err) + } + if got != 123 { + t.Fatalf("parseRecordID() = %d, want 123", got) + } + + for _, input := range []string{"", "abc", "0", "-1"} { + t.Run(input, func(t *testing.T) { + if _, err := parseRecordID(input); err == nil { + t.Fatal("parseRecordID() error = nil, want error") + } + }) + } +} + +func TestRunAbuseUnsuspendGuardsWrite(t *testing.T) { + t.Run("dry run does not write", func(t *testing.T) { + api := &fakeAbuseAPI{suspensions: &client.SuspensionDetailsResponse{ + Suspensions: []client.SuspensionRecord{{RecordID: 123, Flag: "spam", IsSoft: 1}}, + }} + out := captureStdout(t, func() { + err := runAbuseUnsuspend(context.Background(), api, "test", 123, true, false, confirmYes) + if err != nil { + t.Fatalf("runAbuseUnsuspend() error = %v", err) + } + }) + + if len(api.unsuspended) != 0 { + t.Fatalf("unsuspended = %v, want no calls", api.unsuspended) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + }) + + t.Run("soft gate prevents write", func(t *testing.T) { + api := &fakeAbuseAPI{suspensions: &client.SuspensionDetailsResponse{ + Suspensions: []client.SuspensionRecord{{RecordID: 123, Flag: "spam", IsSoft: 0}}, + }} + var err error + captureStdout(t, func() { + err = runAbuseUnsuspend(context.Background(), api, "test", 123, false, true, confirmYes) + }) + if err == nil { + t.Fatal("runAbuseUnsuspend() error = nil, want soft gate error") + } + if len(api.unsuspended) != 0 { + t.Fatalf("unsuspended = %v, want no calls", api.unsuspended) + } + }) + + t.Run("confirmation cancel prevents write", func(t *testing.T) { + api := &fakeAbuseAPI{suspensions: &client.SuspensionDetailsResponse{ + Suspensions: []client.SuspensionRecord{{RecordID: 123, Flag: "spam", IsSoft: 1}}, + }} + var err error + captureStdout(t, func() { + err = runAbuseUnsuspend(context.Background(), api, "test", 123, false, false, confirmNo) + }) + if err != nil { + t.Fatalf("runAbuseUnsuspend() error = %v", err) + } + if len(api.unsuspended) != 0 { + t.Fatalf("unsuspended = %v, want no calls", api.unsuspended) + } + }) + + t.Run("confirmed write", func(t *testing.T) { + api := &fakeAbuseAPI{suspensions: &client.SuspensionDetailsResponse{ + Suspensions: []client.SuspensionRecord{{RecordID: 123, Flag: "spam", IsSoft: 1}}, + }} + var err error + captureStdout(t, func() { + err = runAbuseUnsuspend(context.Background(), api, "test", 123, false, true, confirmNo) + }) + if err != nil { + t.Fatalf("runAbuseUnsuspend() error = %v", err) + } + if len(api.unsuspended) != 1 || api.unsuspended[0] != 123 { + t.Fatalf("unsuspended = %v, want [123]", api.unsuspended) + } + }) +} + +func TestRunAbuseResolvePolicyGuardsWrite(t *testing.T) { + t.Run("dry run does not write", func(t *testing.T) { + api := &fakeAbuseAPI{policy: &client.PolicyViolationsResponse{ + PolicyViolations: []client.PolicyViolationRecord{{RecordID: 789, Flag: "policy", IsSoft: 1}}, + }} + out := captureStdout(t, func() { + err := runAbuseResolvePolicy(context.Background(), api, "test", 789, true, false, confirmYes) + if err != nil { + t.Fatalf("runAbuseResolvePolicy() error = %v", err) + } + }) + + if len(api.resolved) != 0 { + t.Fatalf("resolved = %v, want no calls", api.resolved) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + }) + + t.Run("soft gate prevents write", func(t *testing.T) { + api := &fakeAbuseAPI{policy: &client.PolicyViolationsResponse{ + PolicyViolations: []client.PolicyViolationRecord{{RecordID: 789, Flag: "policy", IsSoft: 0}}, + }} + var err error + captureStdout(t, func() { + err = runAbuseResolvePolicy(context.Background(), api, "test", 789, false, true, confirmYes) + }) + if err == nil { + t.Fatal("runAbuseResolvePolicy() error = nil, want soft gate error") + } + if len(api.resolved) != 0 { + t.Fatalf("resolved = %v, want no calls", api.resolved) + } + }) + + t.Run("confirmation cancel prevents write", func(t *testing.T) { + api := &fakeAbuseAPI{policy: &client.PolicyViolationsResponse{ + PolicyViolations: []client.PolicyViolationRecord{{RecordID: 789, Flag: "policy", IsSoft: 1}}, + }} + var err error + captureStdout(t, func() { + err = runAbuseResolvePolicy(context.Background(), api, "test", 789, false, false, confirmNo) + }) + if err != nil { + t.Fatalf("runAbuseResolvePolicy() error = %v", err) + } + if len(api.resolved) != 0 { + t.Fatalf("resolved = %v, want no calls", api.resolved) + } + }) + + t.Run("confirmed write", func(t *testing.T) { + api := &fakeAbuseAPI{policy: &client.PolicyViolationsResponse{ + PolicyViolations: []client.PolicyViolationRecord{{RecordID: 789, Flag: "policy", IsSoft: 1}}, + }} + var err error + captureStdout(t, func() { + err = runAbuseResolvePolicy(context.Background(), api, "test", 789, false, true, confirmNo) + }) + if err != nil { + t.Fatalf("runAbuseResolvePolicy() error = %v", err) + } + if len(api.resolved) != 1 || api.resolved[0] != 789 { + t.Fatalf("resolved = %v, want [789]", api.resolved) + } + }) + + t.Run("missing record", func(t *testing.T) { + api := &fakeAbuseAPI{policy: &client.PolicyViolationsResponse{}} + var err error + captureStdout(t, func() { + err = runAbuseResolvePolicy(context.Background(), api, "test", 789, false, true, confirmNo) + }) + if err == nil { + t.Fatal("runAbuseResolvePolicy() error = nil, want missing record error") + } + if len(api.resolved) != 0 { + t.Fatalf("resolved = %v, want no calls", api.resolved) + } + }) +} + +func TestParseNotificationState(t *testing.T) { + trueValues := []string{"on", "true", "1", "enable", "enabled", "ON"} + for _, input := range trueValues { + got, err := parseNotificationState(input) + if err != nil { + t.Fatalf("parseNotificationState(%q) error = %v", input, err) + } + if !got { + t.Fatalf("parseNotificationState(%q) = false, want true", input) + } + } + + falseValues := []string{"off", "false", "0", "disable", "disabled", "OFF"} + for _, input := range falseValues { + got, err := parseNotificationState(input) + if err != nil { + t.Fatalf("parseNotificationState(%q) error = %v", input, err) + } + if got { + t.Fatalf("parseNotificationState(%q) = true, want false", input) + } + } + + if _, err := parseNotificationState("maybe"); err == nil { + t.Fatal("parseNotificationState() error = nil, want error") + } +} + +func TestRunNotificationSetGuardsWrite(t *testing.T) { + basePrefs := &client.NotificationPreferencesResponse{ + EmailPreferences: map[string]map[string]client.NotificationPreference{ + "Security Notifications": { + "security-successful-login": { + FriendlyDescription: "Successful login to KiwiVM", + IsEnabled: 0, + }, + }, + }, + } + + t.Run("dry run does not write", func(t *testing.T) { + api := &fakeNotificationAPI{preferences: basePrefs} + out := captureStdout(t, func() { + err := runNotificationSet(context.Background(), api, "test", "security-successful-login", true, true, false, confirmYes) + if err != nil { + t.Fatalf("runNotificationSet() error = %v", err) + } + }) + if len(api.updates) != 0 { + t.Fatalf("updates = %v, want no calls", api.updates) + } + if !strings.Contains(out, "DRY RUN") { + t.Fatalf("output missing DRY RUN:\n%s", out) + } + }) + + t.Run("same state skips write", func(t *testing.T) { + api := &fakeNotificationAPI{preferences: basePrefs} + var err error + captureStdout(t, func() { + err = runNotificationSet(context.Background(), api, "test", "security-successful-login", false, false, true, confirmYes) + }) + if err != nil { + t.Fatalf("runNotificationSet() error = %v", err) + } + if len(api.updates) != 0 { + t.Fatalf("updates = %v, want no calls", api.updates) + } + }) + + t.Run("confirmation cancel prevents write", func(t *testing.T) { + api := &fakeNotificationAPI{preferences: basePrefs} + var err error + captureStdout(t, func() { + err = runNotificationSet(context.Background(), api, "test", "security-successful-login", true, false, false, confirmNo) + }) + if err != nil { + t.Fatalf("runNotificationSet() error = %v", err) + } + if len(api.updates) != 0 { + t.Fatalf("updates = %v, want no calls", api.updates) + } + }) + + t.Run("confirmed write", func(t *testing.T) { + api := &fakeNotificationAPI{preferences: basePrefs} + var err error + captureStdout(t, func() { + err = runNotificationSet(context.Background(), api, "test", "security-successful-login", true, false, true, confirmNo) + }) + if err != nil { + t.Fatalf("runNotificationSet() error = %v", err) + } + if len(api.updates) != 1 || !api.updates[0]["security-successful-login"] { + t.Fatalf("updates = %v, want enabled update", api.updates) + } + }) + + t.Run("unknown preference", func(t *testing.T) { + api := &fakeNotificationAPI{preferences: basePrefs} + var err error + captureStdout(t, func() { + err = runNotificationSet(context.Background(), api, "test", "missing", true, false, true, confirmNo) + }) + if err == nil { + t.Fatal("runNotificationSet() error = nil, want missing preference error") + } + if len(api.updates) != 0 { + t.Fatalf("updates = %v, want no calls", api.updates) + } + }) +} + +func confirmYes(string) (bool, error) { + return true, nil +} + +func confirmNo(string) (bool, error) { + return false, nil +} diff --git a/pkg/client/client.go b/pkg/client/client.go index e75f4f8..badb681 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -355,6 +355,90 @@ func (c *Client) GetNotificationPreferences(ctx context.Context) (*NotificationP return wrapErrorWithBase(&resp, resp.BaseResponse) } +// Unsuspend clears a soft abuse issue and unsuspends the VPS. +func (c *Client) Unsuspend(ctx context.Context, recordID int) error { + if err := validatePositiveRecordID(recordID); err != nil { + return err + } + + var resp BaseResponse + if err := c.doRequest(ctx, "unsuspend", map[string]string{ + "record_id": fmt.Sprintf("%d", recordID), + }, &resp); err != nil { + return err + } + + return wrapOnlyErrorFromBase(resp) +} + +// ResolvePolicyViolation marks a policy violation as resolved. +func (c *Client) ResolvePolicyViolation(ctx context.Context, recordID int) error { + if err := validatePositiveRecordID(recordID); err != nil { + return err + } + + var resp BaseResponse + if err := c.doRequest(ctx, "resolvePolicyViolation", map[string]string{ + "record_id": fmt.Sprintf("%d", recordID), + }, &resp); err != nil { + return err + } + + return wrapOnlyErrorFromBase(resp) +} + +// SetNotificationPreferences updates KiwiVM notification preferences. +func (c *Client) SetNotificationPreferences(ctx context.Context, preferences map[string]bool) (*SetNotificationPreferencesResponse, error) { + encoded, err := encodeNotificationPreferences(preferences) + if err != nil { + return nil, err + } + + var resp SetNotificationPreferencesResponse + if err := c.doRequest(ctx, "kiwivm/setNotificationPreferences", map[string]string{ + "json_notification_preferences": encoded, + }, &resp); err != nil { + return nil, err + } + + return wrapErrorWithBase(&resp, resp.BaseResponse) +} + +func validatePositiveRecordID(recordID int) error { + if recordID <= 0 { + return fmt.Errorf("record_id must be a positive integer") + } + return nil +} + +func encodeNotificationPreferences(preferences map[string]bool) (string, error) { + if len(preferences) == 0 { + return "", fmt.Errorf("at least one notification preference is required") + } + + payload := make(map[string]int, len(preferences)) + for id, enabled := range preferences { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + return "", fmt.Errorf("notification preference id cannot be empty") + } + if _, exists := payload[trimmed]; exists { + return "", fmt.Errorf("duplicate notification preference id after trimming: %s", trimmed) + } + if enabled { + payload[trimmed] = 1 + } else { + payload[trimmed] = 0 + } + } + + data, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to encode notification preferences: %w", err) + } + return string(data), nil +} + // GetSshKeys gets SSH keys from both Hypervisor Vault and Billing Portal func (c *Client) GetSshKeys(ctx context.Context) (*SshKeysResponse, error) { var resp SshKeysResponse diff --git a/pkg/client/types.go b/pkg/client/types.go index 44ef828..d251db7 100644 --- a/pkg/client/types.go +++ b/pkg/client/types.go @@ -309,6 +309,65 @@ type NotificationPreferencesResponse struct { NotificationEmail string `json:"notificationEmail"` } +// NotificationPreferenceStateMap maps preference IDs to enabled states. +type NotificationPreferenceStateMap map[string]int + +// UnmarshalJSON supports KiwiVM's PHP empty-array response for associative maps. +func (m *NotificationPreferenceStateMap) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "null" { + *m = nil + return nil + } + if trimmed == "[]" { + *m = NotificationPreferenceStateMap{} + return nil + } + + values := map[string]FlexibleInt{} + if err := json.Unmarshal(data, &values); err != nil { + return err + } + + result := make(NotificationPreferenceStateMap, len(values)) + for id, value := range values { + result[id] = int(value.Value) + } + *m = result + return nil +} + +// NotificationPreferenceDescriptionMap maps preference IDs to descriptions. +type NotificationPreferenceDescriptionMap map[string]string + +// UnmarshalJSON supports KiwiVM's PHP empty-array response for associative maps. +func (m *NotificationPreferenceDescriptionMap) UnmarshalJSON(data []byte) error { + trimmed := strings.TrimSpace(string(data)) + if trimmed == "null" { + *m = nil + return nil + } + if trimmed == "[]" { + *m = NotificationPreferenceDescriptionMap{} + return nil + } + + values := map[string]string{} + if err := json.Unmarshal(data, &values); err != nil { + return err + } + *m = values + return nil +} + +// SetNotificationPreferencesResponse represents changed notification preferences. +type SetNotificationPreferencesResponse struct { + BaseResponse + SubmittedEmailPreferences NotificationPreferenceStateMap `json:"submitted_email_preferences"` + UpdatedEmailPreferences NotificationPreferenceStateMap `json:"updated_email_preferences"` + FriendlyDescriptions NotificationPreferenceDescriptionMap `json:"friendly_descriptions"` +} + // SshKeysResponse represents the response from getSshKeys API call type SshKeysResponse struct { BaseResponse diff --git a/pkg/client/write_methods_test.go b/pkg/client/write_methods_test.go new file mode 100644 index 0000000..9b30b53 --- /dev/null +++ b/pkg/client/write_methods_test.go @@ -0,0 +1,218 @@ +package client + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_WriteAbuseMethods_Mock(t *testing.T) { + tests := []struct { + name string + endpoint string + call func(context.Context, *Client) error + wantRecord string + }{ + { + name: "unsuspend", + endpoint: "unsuspend", + call: func(ctx context.Context, c *Client) error { + return c.Unsuspend(ctx, 123) + }, + wantRecord: "123", + }, + { + name: "resolve policy violation", + endpoint: "resolvePolicyViolation", + call: func(ctx context.Context, c *Client) error { + return c.ResolvePolicyViolation(ctx, 789) + }, + wantRecord: "789", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path[1:] + if path != tt.endpoint { + t.Fatalf("endpoint = %s, want %s", path, tt.endpoint) + } + if got := r.URL.Query().Get("record_id"); got != tt.wantRecord { + t.Fatalf("record_id = %q, want %q", got, tt.wantRecord) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"error":0}`)) + })) + defer server.Close() + + c := NewClient("valid_key", "123456") + c.SetBaseURL(server.URL) + + if err := tt.call(context.Background(), c); err != nil { + t.Fatalf("%s error = %v", tt.name, err) + } + }) + } +} + +func TestClient_WriteMethods_InvalidInput(t *testing.T) { + c := NewClient("valid_key", "123456") + + if err := c.Unsuspend(context.Background(), 0); err == nil { + t.Fatal("Unsuspend() error = nil, want invalid record_id error") + } + if err := c.ResolvePolicyViolation(context.Background(), -1); err == nil { + t.Fatal("ResolvePolicyViolation() error = nil, want invalid record_id error") + } + if _, err := c.SetNotificationPreferences(context.Background(), map[string]bool{}); err == nil { + t.Fatal("SetNotificationPreferences() error = nil, want empty preferences error") + } + if _, err := c.SetNotificationPreferences(context.Background(), map[string]bool{" ": true}); err == nil { + t.Fatal("SetNotificationPreferences() error = nil, want empty preference id error") + } + if _, err := encodeNotificationPreferences(map[string]bool{ + "security-successful-login": true, + " security-successful-login ": false, + }); err == nil { + t.Fatal("encodeNotificationPreferences() error = nil, want duplicate preference id error") + } +} + +func TestClient_SetNotificationPreferences_Mock(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path[1:] + if path != "kiwivm/setNotificationPreferences" { + t.Fatalf("endpoint = %s, want kiwivm/setNotificationPreferences", path) + } + + var sent map[string]int + if err := json.Unmarshal([]byte(r.URL.Query().Get("json_notification_preferences")), &sent); err != nil { + t.Fatalf("json_notification_preferences decode error = %v", err) + } + if sent["bandwidth-usage-alert-80"] != 1 { + t.Fatalf("bandwidth-usage-alert-80 = %d, want 1", sent["bandwidth-usage-alert-80"]) + } + if sent["security-successful-login"] != 0 { + t.Fatalf("security-successful-login = %d, want 0", sent["security-successful-login"]) + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "error": 0, + "submitted_email_preferences": { + "bandwidth-usage-alert-80": 1, + "security-successful-login": 0 + }, + "updated_email_preferences": { + "security-successful-login": 0 + }, + "friendly_descriptions": { + "security-successful-login": "Successful login to KiwiVM" + } + }`)) + })) + defer server.Close() + + c := NewClient("valid_key", "123456") + c.SetBaseURL(server.URL) + + resp, err := c.SetNotificationPreferences(context.Background(), map[string]bool{ + "bandwidth-usage-alert-80": true, + "security-successful-login": false, + }) + if err != nil { + t.Fatalf("SetNotificationPreferences() error = %v", err) + } + + if resp.SubmittedEmailPreferences["bandwidth-usage-alert-80"] != 1 { + t.Fatalf("submitted state = %d, want 1", resp.SubmittedEmailPreferences["bandwidth-usage-alert-80"]) + } + if resp.UpdatedEmailPreferences["security-successful-login"] != 0 { + t.Fatalf("updated state = %d, want 0", resp.UpdatedEmailPreferences["security-successful-login"]) + } + if resp.FriendlyDescriptions["security-successful-login"] != "Successful login to KiwiVM" { + t.Fatalf("friendly description = %q", resp.FriendlyDescriptions["security-successful-login"]) + } +} + +func TestSetNotificationPreferencesResponse_EmptyArrayMaps(t *testing.T) { + var resp SetNotificationPreferencesResponse + err := json.Unmarshal([]byte(`{ + "error": 0, + "submitted_email_preferences": [], + "updated_email_preferences": [], + "friendly_descriptions": [] + }`), &resp) + if err != nil { + t.Fatalf("SetNotificationPreferencesResponse unmarshal error = %v", err) + } + + if len(resp.SubmittedEmailPreferences) != 0 { + t.Fatalf("submitted length = %d, want 0", len(resp.SubmittedEmailPreferences)) + } + if len(resp.UpdatedEmailPreferences) != 0 { + t.Fatalf("updated length = %d, want 0", len(resp.UpdatedEmailPreferences)) + } + if len(resp.FriendlyDescriptions) != 0 { + t.Fatalf("friendly descriptions length = %d, want 0", len(resp.FriendlyDescriptions)) + } +} + +func TestClient_NewWriteMethods_BWHError(t *testing.T) { + tests := []struct { + name string + endpoint string + call func(context.Context, *Client) error + }{ + { + name: "unsuspend", + endpoint: "unsuspend", + call: func(ctx context.Context, c *Client) error { + return c.Unsuspend(ctx, 123) + }, + }, + { + name: "resolve policy violation", + endpoint: "resolvePolicyViolation", + call: func(ctx context.Context, c *Client) error { + return c.ResolvePolicyViolation(ctx, 789) + }, + }, + { + name: "set notification preferences", + endpoint: "kiwivm/setNotificationPreferences", + call: func(ctx context.Context, c *Client) error { + _, err := c.SetNotificationPreferences(ctx, map[string]bool{"security-successful-login": true}) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path[1:] + if path != tt.endpoint { + t.Fatalf("endpoint = %s, want %s", path, tt.endpoint) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"error":700005,"message":"Authentication failure"}`)) + })) + defer server.Close() + + c := NewClient("invalid_key", "123456") + c.SetBaseURL(server.URL) + + err := tt.call(context.Background(), c) + if err == nil { + t.Fatal("expected BWH error") + } + if !IsBWHError(err) { + t.Fatalf("expected BWHError, got %T: %v", err, err) + } + }) + } +}