初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user