461 lines
14 KiB
Go
461 lines
14 KiB
Go
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
|
|
|
|
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
|
|
|
|
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) 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) 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"}})
|
|
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)
|
|
}
|
|
}
|