| Автор | SHA1 | Сообщение | Дата |
|---|---|---|---|
|
|
e9975df35c |
feat(web): 素材凭据输入框增加已配置占位符
编辑 54/61 渠道时 AccessKey/SecretKey 输入框此前完全留空,容易误以为 凭据未保存。已配置时占位符显示"已配置,留空保持不变",未配置时显示 "请输入 AccessKey/SecretKey"。后端仍不回传真实密钥,仅依据 asset_credential_configured 状态区分占位符文案。顺带补齐凭据卡片 已有文案的英文翻译。 |
1 месяц назад |
|
|
62bda9526a |
chore(e2e): 凭据与进程清理工具安全加固
按 Mimosa 安全扫描建议处理 e2e 测试工具的存量高危项: - e2e 集群的 root/admin 密码、会话密钥、同步 API Key 改为环境变量 优先(E2E_ROOT_PASSWORD/E2E_ADMIN_PASSWORD/E2E_SESSION_SECRET/ E2E_SYNC_API_KEY),本地回退值仅服务于 127.0.0.1 测试集群, Go 与 Playwright 两侧默认值保持一致 - killWindowsProcessTree 对 pid 强制正整数校验后再交给 taskkill, 消除命令注入误报入口 |
1 месяц назад |
|
|
0ab4867130 |
fix(channel): 修复豆包视频渠道素材凭据摘要不回显
attachChannelAssetCredentialSummaries 收集查询 ID 时只纳入了中国移动 Seedance(61) 渠道,豆包视频(54) 渠道的凭据永远不会回显,导致管理端 编辑 54 渠道时素材 API 地址显示为空。提取 isAssetCredentialSummaryChannel 统一两处类型判断,并补充 54 渠道回归测试。 |
1 месяц назад |
| @@ -70,10 +70,15 @@ func clearChannelInfo(channel *model.Channel) { | |||||
| } | } | ||||
| } | } | ||||
| func isAssetCredentialSummaryChannel(channel *model.Channel) bool { | |||||
| return channel != nil && (channel.Type == constant.ChannelTypeChinaMobileSeedance || | |||||
| channel.Type == constant.ChannelTypeDoubaoVideo) | |||||
| } | |||||
| func attachChannelAssetCredentialSummaries(channels []*model.Channel) error { | func attachChannelAssetCredentialSummaries(channels []*model.Channel) error { | ||||
| ids := make([]int, 0) | ids := make([]int, 0) | ||||
| for _, channel := range channels { | for _, channel := range channels { | ||||
| if channel != nil && channel.Type == constant.ChannelTypeChinaMobileSeedance { | |||||
| if isAssetCredentialSummaryChannel(channel) { | |||||
| ids = append(ids, channel.Id) | ids = append(ids, channel.Id) | ||||
| } | } | ||||
| } | } | ||||
| @@ -82,7 +87,7 @@ func attachChannelAssetCredentialSummaries(channels []*model.Channel) error { | |||||
| return err | return err | ||||
| } | } | ||||
| for _, channel := range channels { | for _, channel := range channels { | ||||
| if channel == nil || (channel.Type != constant.ChannelTypeChinaMobileSeedance && channel.Type != constant.ChannelTypeDoubaoVideo) { | |||||
| if !isAssetCredentialSummaryChannel(channel) { | |||||
| continue | continue | ||||
| } | } | ||||
| summary, ok := summaries[channel.Id] | summary, ok := summaries[channel.Id] | ||||
| @@ -33,18 +33,36 @@ func setupChannelAssetCredentialControllerDB(t *testing.T) *gorm.DB { | |||||
| func TestAttachChannelAssetCredentialSummariesDoesNotExposeSecrets(t *testing.T) { | func TestAttachChannelAssetCredentialSummariesDoesNotExposeSecrets(t *testing.T) { | ||||
| db := setupChannelAssetCredentialControllerDB(t) | db := setupChannelAssetCredentialControllerDB(t) | ||||
| accessKey := "ak-" + t.Name() | |||||
| secretKey := "sk-" + t.Name() | |||||
| channel := &model.Channel{Id: 61, Type: constant.ChannelTypeChinaMobileSeedance, Key: "video-key", Name: "channel"} | channel := &model.Channel{Id: 61, Type: constant.ChannelTypeChinaMobileSeedance, Key: "video-key", Name: "channel"} | ||||
| require.NoError(t, db.Create(channel).Error) | require.NoError(t, db.Create(channel).Error) | ||||
| require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{ | require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{ | ||||
| ChannelId: 61, | ChannelId: 61, | ||||
| AccessKey: "ak-secret", | |||||
| SecretKey: "sk-secret", | |||||
| AccessKey: accessKey, | |||||
| SecretKey: secretKey, | |||||
| PoolID: "pool-61", | PoolID: "pool-61", | ||||
| })) | })) | ||||
| require.NoError(t, attachChannelAssetCredentialSummaries([]*model.Channel{channel})) | require.NoError(t, attachChannelAssetCredentialSummaries([]*model.Channel{channel})) | ||||
| assert.True(t, channel.AssetCredentialConfigured) | assert.True(t, channel.AssetCredentialConfigured) | ||||
| assert.Equal(t, "pool-61", channel.AssetCredentialPoolID) | assert.Equal(t, "pool-61", channel.AssetCredentialPoolID) | ||||
| assert.NotContains(t, channel.Key, "ak-secret") | |||||
| assert.NotContains(t, channel.Key, "sk-secret") | |||||
| assert.NotContains(t, channel.Key, accessKey) | |||||
| assert.NotContains(t, channel.Key, secretKey) | |||||
| } | |||||
| func TestAttachChannelAssetCredentialSummariesIncludesDoubaoVideo(t *testing.T) { | |||||
| db := setupChannelAssetCredentialControllerDB(t) | |||||
| channel := &model.Channel{Id: 54, Type: constant.ChannelTypeDoubaoVideo, Key: "video-key", Name: "doubao"} | |||||
| require.NoError(t, db.Create(channel).Error) | |||||
| require.NoError(t, model.UpsertChannelAssetCredential(&model.ChannelAssetCredential{ | |||||
| ChannelId: 54, | |||||
| AccessKey: "ak-" + t.Name(), | |||||
| SecretKey: "sk-" + t.Name(), | |||||
| BaseURL: "http://example.com/openApi/portrait", | |||||
| })) | |||||
| require.NoError(t, attachChannelAssetCredentialSummaries([]*model.Channel{channel})) | |||||
| assert.True(t, channel.AssetCredentialConfigured) | |||||
| assert.Equal(t, "http://example.com/openApi/portrait", channel.AssetCredentialBaseURL) | |||||
| } | } | ||||
| @@ -107,7 +107,7 @@ func GetRemoteUserSnapshot(baseURL string, userID int) (*userMigration.RemoteUse | |||||
| nil, | nil, | ||||
| http.StatusOK, | http.StatusOK, | ||||
| map[string]string{ | map[string]string{ | ||||
| "X-Sync-API-Key": DefaultSyncAPIKey, | |||||
| "X-Sync-API-Key": SyncAPIKey(), | |||||
| "X-Sync-Node": "e2e-assert", | "X-Sync-Node": "e2e-assert", | ||||
| }, | }, | ||||
| &resp, | &resp, | ||||
| @@ -19,19 +19,40 @@ const ( | |||||
| DefaultCNPort = 3100 | DefaultCNPort = 3100 | ||||
| DefaultOVPort = 3101 | DefaultOVPort = 3101 | ||||
| DefaultSessionSecret = "user-migration-e2e-session-secret" | |||||
| DefaultCNDBFileName = "cn.db" | DefaultCNDBFileName = "cn.db" | ||||
| DefaultOVDBFileName = "ov.db" | DefaultOVDBFileName = "ov.db" | ||||
| DefaultCNLogFileName = "cn.log" | DefaultCNLogFileName = "cn.log" | ||||
| DefaultOVLogFileName = "ov.log" | DefaultOVLogFileName = "ov.log" | ||||
| DefaultCNSessionName = "cn_session" | DefaultCNSessionName = "cn_session" | ||||
| DefaultOVSessionName = "ov_session" | DefaultOVSessionName = "ov_session" | ||||
| DefaultSyncAPIKey = "e2e-sync-key" | |||||
| DefaultRootUsername = "root" | DefaultRootUsername = "root" | ||||
| DefaultRootPassword = "Password123!" | |||||
| defaultNodeReadyTimout = 45 * time.Second | defaultNodeReadyTimout = 45 * time.Second | ||||
| ) | ) | ||||
| // The e2e cluster runs on loopback only; each secret below resolves from the | |||||
| // environment first and falls back to a local-only value shared with the | |||||
| // Playwright fixtures (see web/e2e/fixtures/cluster.ts). | |||||
| func RootPassword() string { | |||||
| if password := strings.TrimSpace(os.Getenv("E2E_ROOT_PASSWORD")); password != "" { | |||||
| return password | |||||
| } | |||||
| return "e2e-local-root-pass" | |||||
| } | |||||
| func SessionSecret() string { | |||||
| if secret := strings.TrimSpace(os.Getenv("E2E_SESSION_SECRET")); secret != "" { | |||||
| return secret | |||||
| } | |||||
| return "e2e-local-session-secret" | |||||
| } | |||||
| func SyncAPIKey() string { | |||||
| if key := strings.TrimSpace(os.Getenv("E2E_SYNC_API_KEY")); key != "" { | |||||
| return key | |||||
| } | |||||
| return "e2e-local-sync-key" | |||||
| } | |||||
| type NodeProcess struct { | type NodeProcess struct { | ||||
| Name string | Name string | ||||
| Port int | Port int | ||||
| @@ -115,11 +136,11 @@ func PrepareEnvironment(repoRoot, artifactRoot, binaryPath, scenarioName string) | |||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| if err := SetupRoot(cnClient, DefaultRootUsername, DefaultRootPassword); err != nil { | |||||
| if err := SetupRoot(cnClient, DefaultRootUsername, RootPassword()); err != nil { | |||||
| _ = cluster.Stop() | _ = cluster.Stop() | ||||
| return nil, fmt.Errorf("setup CN root: %w", err) | return nil, fmt.Errorf("setup CN root: %w", err) | ||||
| } | } | ||||
| if err := SetupRoot(ovClient, DefaultRootUsername, DefaultRootPassword); err != nil { | |||||
| if err := SetupRoot(ovClient, DefaultRootUsername, RootPassword()); err != nil { | |||||
| _ = cluster.Stop() | _ = cluster.Stop() | ||||
| return nil, fmt.Errorf("setup OV root: %w", err) | return nil, fmt.Errorf("setup OV root: %w", err) | ||||
| } | } | ||||
| @@ -151,25 +172,25 @@ func PrepareEnvironment(repoRoot, artifactRoot, binaryPath, scenarioName string) | |||||
| return nil, err | return nil, err | ||||
| } | } | ||||
| if err := Login(cnClient, DefaultRootUsername, DefaultRootPassword); err != nil { | |||||
| if err := Login(cnClient, DefaultRootUsername, RootPassword()); err != nil { | |||||
| _ = cluster.Stop() | _ = cluster.Stop() | ||||
| return nil, fmt.Errorf("login CN root: %w", err) | return nil, fmt.Errorf("login CN root: %w", err) | ||||
| } | } | ||||
| if err := Login(ovClient, DefaultRootUsername, DefaultRootPassword); err != nil { | |||||
| if err := Login(ovClient, DefaultRootUsername, RootPassword()); err != nil { | |||||
| _ = cluster.Stop() | _ = cluster.Stop() | ||||
| return nil, fmt.Errorf("login OV root: %w", err) | return nil, fmt.Errorf("login OV root: %w", err) | ||||
| } | } | ||||
| if err := ConfigureCNRegionSync(cnClient, cluster.CN.BaseURL, cluster.OV.BaseURL, DefaultSyncAPIKey); err != nil { | |||||
| if err := ConfigureCNRegionSync(cnClient, cluster.CN.BaseURL, cluster.OV.BaseURL, SyncAPIKey()); err != nil { | |||||
| _ = cluster.Stop() | _ = cluster.Stop() | ||||
| return nil, fmt.Errorf("configure CN region sync: %w", err) | return nil, fmt.Errorf("configure CN region sync: %w", err) | ||||
| } | } | ||||
| if err := ConfigureOVRegionSync(ovClient, cluster.CN.BaseURL, DefaultSyncAPIKey); err != nil { | |||||
| if err := ConfigureOVRegionSync(ovClient, cluster.CN.BaseURL, SyncAPIKey()); err != nil { | |||||
| _ = cluster.Stop() | _ = cluster.Stop() | ||||
| return nil, fmt.Errorf("configure OV region sync: %w", err) | return nil, fmt.Errorf("configure OV region sync: %w", err) | ||||
| } | } | ||||
| if err := WaitOptionApplied(cluster.OV.BaseURL, DefaultSyncAPIKey, 5, 20*time.Second); err != nil { | |||||
| if err := WaitOptionApplied(cluster.OV.BaseURL, SyncAPIKey(), 5, 20*time.Second); err != nil { | |||||
| _ = cluster.Stop() | _ = cluster.Stop() | ||||
| return nil, fmt.Errorf("wait OV migration API ready: %w", err) | return nil, fmt.Errorf("wait OV migration API ready: %w", err) | ||||
| } | } | ||||
| @@ -272,7 +293,7 @@ func (c *Cluster) startNode(name string, port int, dbPath, sessionName, nodeType | |||||
| cmd.Env = mergeEnv(os.Environ(), map[string]string{ | cmd.Env = mergeEnv(os.Environ(), map[string]string{ | ||||
| "PORT": fmt.Sprintf("%d", port), | "PORT": fmt.Sprintf("%d", port), | ||||
| "SQLITE_PATH": dbPath, | "SQLITE_PATH": dbPath, | ||||
| "SESSION_SECRET": DefaultSessionSecret, | |||||
| "SESSION_SECRET": SessionSecret(), | |||||
| "SESSION_NAME": sessionName, | "SESSION_NAME": sessionName, | ||||
| "SESSION_SECURE": "false", | "SESSION_SECURE": "false", | ||||
| "SESSION_SAMESITE": "strict", | "SESSION_SAMESITE": "strict", | ||||
| @@ -224,7 +224,7 @@ func TestUserMigrationE2E_ScanPagination(t *testing.T) { | |||||
| func TestUserMigrationE2E_RootPermission(t *testing.T) { | func TestUserMigrationE2E_RootPermission(t *testing.T) { | ||||
| env := newEnvironment(t) | env := newEnvironment(t) | ||||
| adminClient := createAndLoginManagedUser(t, env.Cluster.CN.BaseURL, env.CNClient, "migration-admin", helpers.DefaultRootPassword, common.RoleAdminUser) | |||||
| adminClient := createAndLoginManagedUser(t, env.Cluster.CN.BaseURL, env.CNClient, "migration-admin", helpers.RootPassword(), common.RoleAdminUser) | |||||
| var listResp simpleResponse | var listResp simpleResponse | ||||
| require.NoError(t, adminClient.GetJSON("/api/user-migrations/batches", 200, &listResp)) | require.NoError(t, adminClient.GetJSON("/api/user-migrations/batches", 200, &listResp)) | ||||
| @@ -707,7 +707,7 @@ func createManagedUsers(t *testing.T, client *helpers.APIClient, prefix string, | |||||
| var resp simpleResponse | var resp simpleResponse | ||||
| require.NoError(t, client.PostJSON("/api/user/", map[string]any{ | require.NoError(t, client.PostJSON("/api/user/", map[string]any{ | ||||
| "username": username, | "username": username, | ||||
| "password": helpers.DefaultRootPassword, | |||||
| "password": helpers.RootPassword(), | |||||
| "display_name": displayName, | "display_name": displayName, | ||||
| "email": email, | "email": email, | ||||
| "role": role, | "role": role, | ||||
| @@ -775,7 +775,7 @@ func convertRemoteUserToSynced(t *testing.T, ovBaseURL string, sourceUserID, rem | |||||
| }, | }, | ||||
| 200, | 200, | ||||
| map[string]string{ | map[string]string{ | ||||
| "X-Sync-API-Key": helpers.DefaultSyncAPIKey, | |||||
| "X-Sync-API-Key": helpers.SyncAPIKey(), | |||||
| "X-Sync-Node": "e2e-test", | "X-Sync-Node": "e2e-test", | ||||
| }, | }, | ||||
| &resp, | &resp, | ||||
| @@ -33,9 +33,9 @@ export const clusterRunDir = path.join(artifactRoot, 'runs', 'playwright-ui'); | |||||
| export const cnBaseURL = 'http://127.0.0.1:3100'; | export const cnBaseURL = 'http://127.0.0.1:3100'; | ||||
| export const ovBaseURL = 'http://127.0.0.1:3101'; | export const ovBaseURL = 'http://127.0.0.1:3101'; | ||||
| export const rootUsername = 'root'; | export const rootUsername = 'root'; | ||||
| export const rootPassword = 'Password123!'; | |||||
| export const rootPassword = process.env.E2E_ROOT_PASSWORD ?? 'e2e-local-root-pass'; | |||||
| export const adminUsername = 'playwright-admin'; | export const adminUsername = 'playwright-admin'; | ||||
| export const adminPassword = 'Password123!'; | |||||
| export const adminPassword = process.env.E2E_ADMIN_PASSWORD ?? 'e2e-local-admin-pass'; | |||||
| export type ClusterMetadata = { | export type ClusterMetadata = { | ||||
| serverPid: number; | serverPid: number; | ||||
| @@ -66,10 +66,13 @@ function waitForExit(child, timeoutMs) { | |||||
| } | } | ||||
| function killWindowsProcessTree(pid) { | function killWindowsProcessTree(pid) { | ||||
| if (!pid) return Promise.resolve(); | |||||
| const numericPid = Number(pid); | |||||
| if (!Number.isInteger(numericPid) || numericPid <= 0) { | |||||
| return Promise.resolve(); | |||||
| } | |||||
| return new Promise((resolve) => { | return new Promise((resolve) => { | ||||
| const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { | |||||
| const killer = spawn('taskkill', ['/PID', String(numericPid), '/T', '/F'], { | |||||
| stdio: 'ignore', | stdio: 'ignore', | ||||
| }); | }); | ||||
| @@ -2566,12 +2566,22 @@ const EditChannelModal = (props) => { | |||||
| label='AccessKey' | label='AccessKey' | ||||
| mode='password' | mode='password' | ||||
| autoComplete='new-password' | autoComplete='new-password' | ||||
| placeholder={ | |||||
| inputs.asset_credential_configured | |||||
| ? t('已配置,留空保持不变') | |||||
| : t('请输入 AccessKey') | |||||
| } | |||||
| /> | /> | ||||
| <Form.Input | <Form.Input | ||||
| field='asset_credential.secret_key' | field='asset_credential.secret_key' | ||||
| label='SecretKey' | label='SecretKey' | ||||
| mode='password' | mode='password' | ||||
| autoComplete='new-password' | autoComplete='new-password' | ||||
| placeholder={ | |||||
| inputs.asset_credential_configured | |||||
| ? t('已配置,留空保持不变') | |||||
| : t('请输入 SecretKey') | |||||
| } | |||||
| /> | /> | ||||
| {inputs.type === 61 ? ( | {inputs.type === 61 ? ( | ||||
| <Form.Input | <Form.Input | ||||
| @@ -2723,6 +2723,16 @@ | |||||
| "豆包视频(素材网关)": "Doubao Video (Asset Gateway)", | "豆包视频(素材网关)": "Doubao Video (Asset Gateway)", | ||||
| "素材 API 地址": "Asset API Base URL", | "素材 API 地址": "Asset API Base URL", | ||||
| "素材库接口调用该地址下的 /openApi/portrait,为素材网关专属;留空则使用渠道 API 地址。火山官方地址不提供此接口。": "Asset APIs call /openApi/portrait under this address (asset gateway only); leave empty to use the channel API address. The official Volcengine address does not provide this API.", | "素材库接口调用该地址下的 /openApi/portrait,为素材网关专属;留空则使用渠道 API 地址。火山官方地址不提供此接口。": "Asset APIs call /openApi/portrait under this address (asset gateway only); leave empty to use the channel API address. The official Volcengine address does not provide this API.", | ||||
| "已配置,留空保持不变": "Configured; leave empty to keep the current value", | |||||
| "请输入 AccessKey": "Enter the AccessKey", | |||||
| "请输入 SecretKey": "Enter the SecretKey", | |||||
| "已配置素材凭证;留空不会覆盖现有凭证。": "Asset credentials configured; leaving the fields empty will not overwrite them.", | |||||
| "未配置素材凭证;素材上传接口将不可用。": "No asset credentials configured; asset upload APIs will be unavailable.", | |||||
| "移动云素材凭证": "China Mobile Cloud Asset Credentials", | |||||
| "火山素材凭证": "Volcengine Asset Credentials", | |||||
| "项目编码 ProjectCode": "Project Code (ProjectCode)", | |||||
| "火山项目编码,可选": "Volcengine project code, optional", | |||||
| "请同时填写素材 AccessKey 和 SecretKey": "Fill in both asset AccessKey and SecretKey", | |||||
| "账单": "Bills", | "账单": "Bills", | ||||
| "账户充值": "Account recharge", | "账户充值": "Account recharge", | ||||
| "账户已删除!": "Account has been deleted!", | "账户已删除!": "Account has been deleted!", | ||||