package service import ( "context" "errors" "testing" "github.com/glebarez/sqlite" "gorm.io/gorm" "gorm.io/gorm/logger" "oci-portal/internal/crypto" "oci-portal/internal/model" "oci-portal/internal/oci" ) const testPEM = "-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----" // fakeClient 是 oci.Client 的测试替身,普通测试不访问真实 OCI。 // 内嵌接口以自动满足 oci.Client,未覆写的方法不会被这些测试调用。 type fakeClient struct { oci.Client tenancy oci.TenancyInfo tenancyErr error profile oci.AccountProfile profileErr error caps oci.AccountCapabilities capsErr error regionSubs []oci.RegionSubscription regionSubsErr error regionSubsCalls int compartments []oci.Compartment compartmentsErr error compartmentsCalls int subscribeErr error limitValues []oci.LimitValue limitsErr error limitServices []oci.LimitService subscriptions []oci.SubscriptionInfo subDetail oci.SubscriptionDetail subDetailErr error instances []oci.Instance instancesErr error costItems []oci.CostItem costErr error instance oci.Instance instanceErr error image oci.Image imageErr error subscribedHomeRegion string subscribedKey string limitsQuery oci.LimitsQuery subDetailID string launched []oci.CreateInstanceInput } func (f *fakeClient) ValidateKey(ctx context.Context, cred oci.Credentials) (oci.TenancyInfo, error) { return f.tenancy, f.tenancyErr } func (f *fakeClient) GetAccountCapabilities(ctx context.Context, cred oci.Credentials, region string) (oci.AccountCapabilities, error) { return f.caps, f.capsErr } func (f *fakeClient) FetchAccountProfile(ctx context.Context, cred oci.Credentials) (oci.AccountProfile, error) { return f.profile, f.profileErr } func (f *fakeClient) ListRegionSubscriptions(ctx context.Context, cred oci.Credentials) ([]oci.RegionSubscription, error) { f.regionSubsCalls++ return f.regionSubs, f.regionSubsErr } func (f *fakeClient) ListCompartments(ctx context.Context, cred oci.Credentials) ([]oci.Compartment, error) { f.compartmentsCalls++ return f.compartments, f.compartmentsErr } func (f *fakeClient) SubscribeRegion(ctx context.Context, cred oci.Credentials, homeRegion, regionKey string) error { f.subscribedHomeRegion = homeRegion f.subscribedKey = regionKey return f.subscribeErr } func (f *fakeClient) ListLimits(ctx context.Context, cred oci.Credentials, q oci.LimitsQuery) ([]oci.LimitValue, error) { f.limitsQuery = q return f.limitValues, f.limitsErr } func (f *fakeClient) ListLimitServices(ctx context.Context, cred oci.Credentials, region string) ([]oci.LimitService, error) { return f.limitServices, nil } func (f *fakeClient) ListSubscriptions(ctx context.Context, cred oci.Credentials) ([]oci.SubscriptionInfo, error) { return f.subscriptions, nil } func (f *fakeClient) GetSubscription(ctx context.Context, cred oci.Credentials, subscriptionID string) (oci.SubscriptionDetail, error) { f.subDetailID = subscriptionID return f.subDetail, f.subDetailErr } func (f *fakeClient) LaunchInstance(ctx context.Context, cred oci.Credentials, in oci.CreateInstanceInput) (oci.Instance, error) { f.launched = append(f.launched, in) return oci.Instance{DisplayName: in.DisplayName, LifecycleState: "PROVISIONING", FreeformTags: in.FreeformTags}, nil } // ListAvailabilityDomains 默认单可用域,抢机自动轮询退化为固定 ad-1。 func (f *fakeClient) ListAvailabilityDomains(ctx context.Context, cred oci.Credentials, region string) ([]string, error) { return []string{"fake-ad-1"}, nil } func (f *fakeClient) ListInstances(ctx context.Context, cred oci.Credentials, region string) ([]oci.Instance, error) { return f.instances, f.instancesErr } func (f *fakeClient) GetInstance(ctx context.Context, cred oci.Credentials, region, instanceID string) (oci.Instance, error) { return f.instance, f.instanceErr } func (f *fakeClient) GetImage(ctx context.Context, cred oci.Credentials, region, imageID string) (oci.Image, error) { return f.image, f.imageErr } func (f *fakeClient) SummarizeCosts(ctx context.Context, cred oci.Credentials, q oci.CostQuery) ([]oci.CostItem, error) { return f.costItems, f.costErr } func newTestService(t *testing.T, client oci.Client) *OciConfigService { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { t.Fatalf("open in-memory sqlite: %v", err) } if err := db.AutoMigrate( &model.OciConfig{}, &model.RegionCache{}, &model.CompartmentCache{}, &model.Proxy{}, ); err != nil { t.Fatalf("auto migrate: %v", err) } cipher, err := crypto.NewCipher("test-data-key") if err != nil { t.Fatalf("new cipher: %v", err) } return NewOciConfigService(db, cipher, client) } func trialImportInput() ImportInput { return ImportInput{ Alias: "试用期", Group: "教育", TenancyOCID: "ocid1.tenancy.oc1..t", UserOCID: "ocid1.user.oc1..u", Region: "eu-frankfurt-1", Fingerprint: "aa:bb", PrivateKey: testPEM, } } func TestImportAutoVerifiesAndClassifies(t *testing.T) { client := &fakeClient{ tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"}, profile: oci.AccountProfile{ AccountType: model.AccountTypeTrial, SubscriptionID: "ocid1.organizationssubscription.oc1..s", PaymentModel: "FREE_TRIAL", SubscriptionTier: "FREE_AND_TRIAL", PromotionStatus: "ACTIVE", PromotionAmount: 300, }, } svc := newTestService(t, client) cfg, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import: %v", err) } if got, want := cfg.AliveStatus, model.AliveStatusAlive; got != want { t.Errorf("AliveStatus = %q, want %q", got, want) } if got, want := cfg.AccountType, model.AccountTypeTrial; got != want { t.Errorf("AccountType = %q, want %q", got, want) } if got, want := cfg.TenancyName, "mytenancy"; got != want { t.Errorf("TenancyName = %q, want %q", got, want) } if got, want := cfg.Group, "教育"; got != want { t.Errorf("Group = %q, want %q", got, want) } if cfg.LastVerifiedAt == nil { t.Error("LastVerifiedAt = nil, want set") } if cfg.PrivateKeyEnc == testPEM || cfg.PrivateKeyEnc == "" { t.Error("PrivateKeyEnc: private key stored without encryption") } } func TestImportDeadKeyStillSaved(t *testing.T) { client := &fakeClient{tenancyErr: errors.New("NotAuthenticated")} svc := newTestService(t, client) cfg, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import: %v", err) } if got, want := cfg.AliveStatus, model.AliveStatusDead; got != want { t.Errorf("AliveStatus = %q, want %q", got, want) } if cfg.LastError == "" { t.Error("LastError is empty, want failure message") } } func TestImportParsesConfigINI(t *testing.T) { client := &fakeClient{profileErr: errors.New("skip profile")} svc := newTestService(t, client) cfg, err := svc.Import(context.Background(), ImportInput{ Alias: "ini 导入", ConfigINI: "[DEFAULT]\nuser=ocid1.user.oc1..u\ntenancy=ocid1.tenancy.oc1..t\nfingerprint=ff:ee\nregion=ap-tokyo-1\n", PrivateKey: testPEM, }) if err != nil { t.Fatalf("Import: %v", err) } if got, want := cfg.Region, "ap-tokyo-1"; got != want { t.Errorf("Region = %q, want %q", got, want) } if got, want := cfg.UserOCID, "ocid1.user.oc1..u"; got != want { t.Errorf("UserOCID = %q, want %q", got, want) } } func TestImportProfileFailureDegradesToUnknown(t *testing.T) { client := &fakeClient{ tenancy: oci.TenancyInfo{Name: "mytenancy"}, profileErr: errors.New("subscription api forbidden"), } svc := newTestService(t, client) cfg, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import: %v", err) } if got, want := cfg.AliveStatus, model.AliveStatusAlive; got != want { t.Errorf("AliveStatus = %q, want %q", got, want) } if got, want := cfg.AccountType, model.AccountTypeUnknown; got != want { t.Errorf("AccountType = %q, want %q", got, want) } if cfg.LastError == "" { t.Error("LastError is empty, want profile failure message") } } func TestImportRejectsInvalidInput(t *testing.T) { svc := newTestService(t, &fakeClient{}) in := trialImportInput() in.PrivateKey = "not a pem" if _, err := svc.Import(context.Background(), in); err == nil { t.Error("Import with bad key: got nil error, want failure") } } func TestVerifyDetectsChanges(t *testing.T) { client := &fakeClient{ tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"}, profile: oci.AccountProfile{ AccountType: model.AccountTypeTrial, PaymentModel: "FREE_TRIAL", PromotionStatus: "ACTIVE", }, } svc := newTestService(t, client) cfg, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import: %v", err) } // 模拟云端状态变化:试用到期变免费 client.profile = oci.AccountProfile{ AccountType: model.AccountTypeFree, PaymentModel: "FREE_TRIAL", PromotionStatus: "EXPIRED", } updated, changes, err := svc.Verify(context.Background(), cfg.ID) if err != nil { t.Fatalf("Verify: %v", err) } if got, want := updated.AccountType, model.AccountTypeFree; got != want { t.Errorf("AccountType = %q, want %q", got, want) } if got, want := changes["accountType"], [2]string{model.AccountTypeTrial, model.AccountTypeFree}; got != want { t.Errorf("changes[accountType] = %v, want %v", got, want) } if got, want := changes["promotionStatus"], [2]string{"ACTIVE", "EXPIRED"}; got != want { t.Errorf("changes[promotionStatus] = %v, want %v", got, want) } } func TestVerifyNoChanges(t *testing.T) { client := &fakeClient{ tenancy: oci.TenancyInfo{Name: "mytenancy", HomeRegionKey: "FRA"}, profile: oci.AccountProfile{AccountType: model.AccountTypeTrial, PaymentModel: "FREE_TRIAL"}, } svc := newTestService(t, client) cfg, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import: %v", err) } _, changes, err := svc.Verify(context.Background(), cfg.ID) if err != nil { t.Fatalf("Verify: %v", err) } if len(changes) != 0 { t.Errorf("changes = %v, want empty", changes) } } func TestVerifyMissingConfig(t *testing.T) { svc := newTestService(t, &fakeClient{}) if _, _, err := svc.Verify(context.Background(), 999); err == nil { t.Error("Verify(999): got nil error, want not found") } } func TestDelete(t *testing.T) { svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) migrateTenantDeleteModels(t, svc.db) cfg, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import: %v", err) } if err := svc.Delete(context.Background(), cfg.ID); err != nil { t.Fatalf("Delete: %v", err) } if err := svc.Delete(context.Background(), cfg.ID); err == nil { t.Error("Delete twice: got nil error, want not found") } } func TestUpdateFields(t *testing.T) { const newPEM = "-----BEGIN PRIVATE KEY-----\nrotated\n-----END PRIVATE KEY-----" empty := "" prod := "生产" tests := []struct { name string in UpdateInput check func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) }{ { name: "改别名与默认区域", in: UpdateInput{Alias: "改名后", Region: "ap-tokyo-1"}, check: func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) { if cfg.Alias != "改名后" || cfg.Region != "ap-tokyo-1" { t.Errorf("alias/region = %q/%q, want 改名后/ap-tokyo-1", cfg.Alias, cfg.Region) } if cred, err := svc.credentialsOf(cfg); err != nil || cred.PrivateKey != testPEM { t.Errorf("private key changed unexpectedly: %v", err) } }, }, { name: "改分组", in: UpdateInput{Group: &prod}, check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) { if cfg.Group != "生产" { t.Errorf("Group = %q, want 生产", cfg.Group) } }, }, { name: "清空分组", in: UpdateInput{Group: &empty}, check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) { if cfg.Group != "" { t.Errorf("Group = %q, want empty", cfg.Group) } }, }, { name: "缺省分组保持不变", in: UpdateInput{Alias: "只改名"}, check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) { if cfg.Group != "教育" { t.Errorf("Group = %q, want 教育 (unchanged)", cfg.Group) } }, }, { name: "换私钥与指纹", in: UpdateInput{PrivateKey: newPEM, Fingerprint: "cc:dd"}, check: func(t *testing.T, svc *OciConfigService, cfg *model.OciConfig) { if cfg.Fingerprint != "cc:dd" { t.Errorf("fingerprint = %q, want cc:dd", cfg.Fingerprint) } if cred, err := svc.credentialsOf(cfg); err != nil || cred.PrivateKey != newPEM { t.Errorf("private key not rotated: %v", err) } }, }, { name: "清除口令", in: UpdateInput{Passphrase: &empty}, check: func(t *testing.T, _ *OciConfigService, cfg *model.OciConfig) { if cfg.PassphraseEnc != "" { t.Errorf("PassphraseEnc = %q, want empty", cfg.PassphraseEnc) } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) cfg, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import: %v", err) } updated, _, err := svc.Update(context.Background(), cfg.ID, tt.in) if err != nil { t.Fatalf("Update: %v", err) } if updated.LastVerifiedAt == nil || !updated.LastVerifiedAt.After(*cfg.LastVerifiedAt) { t.Error("Update should re-verify: LastVerifiedAt not advanced") } tt.check(t, svc, updated) }) } } func TestUpdateKeyRequiresFingerprint(t *testing.T) { svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}}) cfg, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import: %v", err) } if _, _, err := svc.Update(context.Background(), cfg.ID, UpdateInput{PrivateKey: "x"}); err == nil { t.Error("Update with key but no fingerprint: got nil error, want rejection") } } func TestListReturnsSummaries(t *testing.T) { svc := newTestService(t, &fakeClient{tenancy: oci.TenancyInfo{Name: "mytenancy"}}) first, err := svc.Import(context.Background(), trialImportInput()) if err != nil { t.Fatalf("Import first: %v", err) } second := trialImportInput() second.Alias = "生产号" second.Group = "" if _, err := svc.Import(context.Background(), second); err != nil { t.Fatalf("Import second: %v", err) } items, err := svc.List(context.Background()) if err != nil { t.Fatalf("List: %v", err) } if len(items) != 2 { t.Fatalf("len(items) = %d, want 2", len(items)) } got := items[0] want := ConfigSummary{ ID: first.ID, Alias: "试用期", Group: "教育", TenancyOCID: first.TenancyOCID, TenancyName: "mytenancy", Region: first.Region, AccountType: first.AccountType, AliveStatus: model.AliveStatusAlive, LastVerifiedAt: got.LastVerifiedAt, CreatedAt: got.CreatedAt, } if got != want { t.Errorf("items[0] = %+v, want %+v", got, want) } if items[1].Group != "" || items[1].Alias != "生产号" { t.Errorf("items[1] alias/group = %q/%q, want 生产号/empty", items[1].Alias, items[1].Group) } } // TestVerifySuspendedOverridesStatus 云端暂停标记应覆盖测活结论为 suspended; // 能力接口失败或未标记时不改变原结论。 func TestVerifySuspendedOverridesStatus(t *testing.T) { client := &fakeClient{tenancy: oci.TenancyInfo{Name: "t"}} svc := newTestService(t, client) cfg := importAliveConfig(t, svc) ctx := context.Background() // 标记暂停:即使 ValidateKey 成功也应判 suspended client.caps = oci.AccountCapabilities{Suspended: true, AccountStatus: "terminated"} got, _, err := svc.Verify(ctx, cfg.ID) if err != nil { t.Fatalf("Verify: %v", err) } if got.AliveStatus != model.AliveStatusSuspended { t.Fatalf("AliveStatus = %q, want suspended", got.AliveStatus) } if got.LastError == "" { t.Error("暂停时 LastError 应携带账户状态备注") } // 解除暂停:恢复 alive client.caps = oci.AccountCapabilities{} got, _, err = svc.Verify(ctx, cfg.ID) if err != nil || got.AliveStatus != model.AliveStatusAlive { t.Fatalf("解除暂停后 = %q, %v; want alive", got.AliveStatus, err) } // 能力接口失败:不影响测活结论 client.capsErr = errors.New("boom") got, _, err = svc.Verify(ctx, cfg.ID) if err != nil || got.AliveStatus != model.AliveStatusAlive { t.Fatalf("能力接口失败后 = %q, %v; want alive", got.AliveStatus, err) } }