Scert/backend/internal/httpapi/dns_accounts_scope_test.go

137 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
func newDNSAccountScopeFixture(t *testing.T) (projectMemberAPIFixture, *fakeSecretReaderStore) {
t.Helper()
f := newProjectMemberAPIFixture(t)
f.server.config.DNS.Provider = "aliyun"
f.server.config.DNS.ProjectID = "project-a"
f.server.config.DNS.CredentialRef = "secret://dns/scope-test"
secrets := &fakeSecretReaderStore{data: map[string]map[string]string{
f.server.config.DNS.CredentialRef: {
"access_key_id": "test-access-key-1234", "access_key_secret": "test-secret-never-return",
},
}}
f.server.secrets = secrets
return f, secrets
}
func TestDNSAccountScopePermissions(t *testing.T) {
f, secrets := newDNSAccountScopeFixture(t)
for _, tc := range []struct {
actor string
count int
}{
{"primary-super-admin", 1}, {"manager", 1}, {"viewer", 1}, {"global-admin", 0}, {"target", 0},
} {
t.Run(tc.actor, func(t *testing.T) {
secrets.getCalls = 0
response := serveUserRequest(f.server, f.cookies[tc.actor], http.MethodGet, "/api/v1/dns/accounts", "")
var page struct {
Items []dnsAccountView `json:"items"`
}
if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &page) != nil || page.Items == nil || len(page.Items) != tc.count {
t.Fatalf("DNS 账户项目可见范围错误:%s,%d,%s", tc.actor, response.Code, response.Body.String())
}
if secrets.getCalls != tc.count {
t.Fatalf("无权访问的请求读取了凭据仓库:%s,%d", tc.actor, secrets.getCalls)
}
if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" {
t.Fatal("DNS 账户响应缺少身份缓存隔离")
}
for _, forbidden := range []string{"test-access-key-1234", "test-secret-never-return", "secret://dns/scope-test"} {
if strings.Contains(response.Body.String(), forbidden) {
t.Fatal("DNS 账户列表泄露完整凭据或内部引用")
}
}
if tc.count == 1 && (page.Items[0].ProjectID != "project-a" || page.Items[0].AccountIdentifier != "test****1234") {
t.Fatalf("授权响应丢失真实归属或脱敏标识:%s", response.Body.String())
}
})
}
// 请求参数不能冒充其他身份或改变配置中的真实项目归属。
f.server.config.DNS.ProjectID = "project-b"
secrets.getCalls = 0
response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, "/api/v1/dns/accounts?project_id=project-a&actor_id=primary-super-admin", "")
if response.Code != http.StatusOK || strings.TrimSpace(response.Body.String()) != `{"items":[]}` || secrets.getCalls != 0 {
t.Fatalf("跨项目请求没有在读取凭据之前拒绝:%d,%s", response.Code, response.Body.String())
}
}
func TestDNSAccountScopeRevocation(t *testing.T) {
f, secrets := newDNSAccountScopeFixture(t)
if err := f.members.GrantProjectRole(t.Context(), "primary-super-admin", "project-a", "global-admin", domain.ProjectRoleDNSAdmin); err != nil {
t.Fatal(err)
}
response := serveUserRequest(f.server, f.cookies["global-admin"], http.MethodGet, "/api/v1/dns/accounts", "")
if response.Code != http.StatusOK || secrets.getCalls != 1 {
t.Fatalf("显式授予 DNS 角色后不可读取:%d,%s", response.Code, response.Body.String())
}
if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "global-admin", domain.ProjectRoleDNSAdmin); err != nil {
t.Fatal(err)
}
response = serveUserRequest(f.server, f.cookies["global-admin"], http.MethodGet, "/api/v1/dns/accounts", "")
if response.Code != http.StatusOK || strings.TrimSpace(response.Body.String()) != `{"items":[]}` || secrets.getCalls != 1 {
t.Fatalf("撤权后原会话仍可读取 DNS 账户:%d,%s", response.Code, response.Body.String())
}
}
func TestDNSAccountScopeDefaultsAndDependencies(t *testing.T) {
for _, scenario := range []string{"default", "missing_project", "missing_authorizer", "authorizer_error", "database_error", "anonymous"} {
t.Run(scenario, func(t *testing.T) {
f, secrets := newDNSAccountScopeFixture(t)
cookie := f.cookies["primary-super-admin"]
want := http.StatusOK
switch scenario {
case "default":
f.server.config.DNS.ProjectID = " "
case "missing_project":
f.server.config.DNS.ProjectID = "missing"
case "missing_authorizer":
f.server.projectMembers, f.server.projectDomains = nil, nil
want = http.StatusServiceUnavailable
case "authorizer_error":
f.server.projectMembers = nil
f.server.projectDomains = projectDomainRepositoryStub{authorizeErr: errors.New("internal-sensitive-diagnostic")}
want = http.StatusServiceUnavailable
case "database_error":
// 认证仍使用正常账户仓储,只让项目授权的数据库读取失败。
f.server.projectMembers = nil
f.server.projectDomains = projectDomainRepositoryStub{authorize: func(ctx context.Context, actor, project string, permission domain.ProjectPermission) error {
if err := f.db.Close(); err != nil {
return err
}
return f.members.AuthorizeProject(ctx, actor, project, permission)
}}
want = http.StatusServiceUnavailable
case "anonymous":
cookie = nil
want = http.StatusUnauthorized
}
response := serveUserRequest(f.server, cookie, http.MethodGet, "/api/v1/dns/accounts", "")
if response.Code != want || response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" {
t.Fatalf("DNS 列表依赖或缓存隔离错误:%d,%s", response.Code, response.Body.String())
}
if scenario == "default" {
if secrets.getCalls != 1 || !strings.Contains(response.Body.String(), `"project_id":"default"`) {
t.Fatal("旧配置没有按默认项目授权")
}
return
}
if secrets.getCalls != 0 || strings.Contains(response.Body.String(), "project-a") || strings.Contains(response.Body.String(), "internal-sensitive-diagnostic") || strings.Contains(response.Body.String(), "account_identifier") {
t.Fatalf("未获可靠授权仍读取凭据或泄露元数据:%s", response.Body.String())
}
})
}
}