Scert/backend/internal/httpapi/domain_dns_account_api_test.go

189 lines
8.3 KiB
Go
Raw Permalink 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 (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
)
func newDomainDNSAPIFixture(t *testing.T) (projectMemberAPIFixture, domain.DNSAccount) {
t.Helper()
f, _ := newProjectResourceAPIFixture(t)
item, err := f.members.CreateDNSAccount(t.Context(), "manager", domain.DNSAccount{
ID: "account-a", ProjectID: "project-a", Name: "项目账户", Provider: "cloudflare", CredentialRef: "secret://dns/fixture-sensitive-token",
})
if err != nil {
t.Fatal(err)
}
item, err = f.members.RecordDNSAccountVerification(t.Context(), "manager", item.ID, item.Revision, true)
if err != nil {
t.Fatal(err)
}
return f, item
}
func domainDNSBody(accountID, expectedID string, revision int64) string {
return fmt.Sprintf(`{"dns_account_id":%q,"expected_binding_id":%q,"account_revision":%d}`, accountID, expectedID, revision)
}
func domainDNSRequest(t *testing.T, f projectMemberAPIFixture, actor, method, path, body string, status int) domain.DNSAccountBinding {
t.Helper()
response := serveUserRequest(f.server, f.cookies[actor], method, "/api/v1/domains/"+path+"/dns-account", body)
if response.Code != status {
t.Fatalf("域名 DNS 绑定响应错误:%s %s %s,实际 %d,期望 %d,%s", actor, method, path, response.Code, status, response.Body.String())
}
if response.Header().Get("Cache-Control") != "private, no-store" || response.Header().Get("Vary") != "Cookie" {
t.Fatal("绑定响应缺少身份缓存隔离")
}
for _, secret := range []string{"secret://", "credential_ref", "fixture-sensitive-token", "internal-sensitive-error"} {
if strings.Contains(response.Body.String(), secret) {
t.Fatal("绑定响应泄露秘密或内部错误")
}
}
var binding domain.DNSAccountBinding
if status == http.StatusOK {
if err := json.Unmarshal(response.Body.Bytes(), &binding); err != nil {
t.Fatal(err)
}
}
return binding
}
func TestDomainDNSAccountAPILifecycle(t *testing.T) {
f, account := newDomainDNSAPIFixture(t)
read := domainDNSRequest(t, f, "viewer", http.MethodGet, "domain-a", "", http.StatusOK)
if read.ID != "" || read.DomainID != "domain-a" || read.ProjectID != "project-a" {
t.Fatal("未绑定时未返回域名归属")
}
body := domainDNSBody(account.ID, "", account.Revision)
bound := domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", body, http.StatusOK)
if bound.ID == "" || bound.DNSAccountID != account.ID || bound.UpdatedAt.IsZero() {
t.Fatal("绑定未返回版本令牌")
}
read = domainDNSRequest(t, f, "viewer", http.MethodGet, "domain-a", "", http.StatusOK)
if read.ID != bound.ID || read.DNSAccountID != account.ID {
t.Fatal("读取与实际绑定不符")
}
for _, stale := range []string{body, domainDNSBody(account.ID, bound.ID, 1), domainDNSBody("", "", 0)} {
domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", stale, http.StatusConflict)
}
cleared := domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", domainDNSBody("", bound.ID, 0), http.StatusOK)
if cleared.ID != "" || cleared.DNSAccountID != "" {
t.Fatal("解除后仍返回绑定")
}
rebound := domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", body, http.StatusOK)
if rebound.ID == bound.ID {
t.Fatal("重绑重复使用旧令牌")
}
domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", domainDNSBody("", bound.ID, 0), http.StatusConflict)
var count int
if err := f.db.QueryRow(`SELECT COUNT(*) FROM audit_logs WHERE action='domain.dns_account' AND status='success' AND operator_id='manager'`).Scan(&count); err != nil || count != 3 {
t.Fatal("绑定成功审计数不符", count, err)
}
}
func TestDomainDNSAccountAPIAuthorization(t *testing.T) {
f, account := newDomainDNSAPIFixture(t)
body := domainDNSBody(account.ID, "", account.Revision)
for _, actor := range []string{"anonymous", "viewer", "global-admin", "target"} {
status := http.StatusForbidden
if actor == "anonymous" {
status = http.StatusUnauthorized
}
domainDNSRequest(t, f, actor, http.MethodPut, "domain-a", body, status)
if actor != "viewer" {
domainDNSRequest(t, f, actor, http.MethodGet, "domain-a", "", status)
}
}
for _, id := range []string{"domain-b", "missing-domain"} {
domainDNSRequest(t, f, "manager", http.MethodGet, id, "", http.StatusForbidden)
domainDNSRequest(t, f, "manager", http.MethodPut, id, body, http.StatusForbidden)
}
// 超级管理员也不能将同一账户绑定到另一个项目。
domainDNSRequest(t, f, "primary-super-admin", http.MethodPut, "domain-b", body, http.StatusForbidden)
if err := f.members.RevokeProjectRole(t.Context(), "primary-super-admin", "project-a", "manager", domain.ProjectRoleAdmin); err != nil {
t.Fatal(err)
}
domainDNSRequest(t, f, "manager", http.MethodGet, "domain-a", "", http.StatusForbidden)
domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", body, http.StatusForbidden)
}
func TestDomainDNSAccountAPIInputAndOrigin(t *testing.T) {
f, account := newDomainDNSAPIFixture(t)
for _, body := range []string{
`{}`, `null`, `{"dns_account_id":null,"expected_binding_id":"","account_revision":0}`,
`{"dns_account_id":"","expected_binding_id":null,"account_revision":0}`,
`{"dns_account_id":"","expected_binding_id":"","account_revision":null}`,
domainDNSBody("", "", 1), domainDNSBody(account.ID, "", 0), domainDNSBody(account.ID, "", 2147483648),
domainDNSBody(account.ID, "invalid/id", account.Revision),
`{"dns_account_id":"","expected_binding_id":"","account_revision":0,"actor_id":"primary-super-admin"}`,
`{"dns_account_id":"","expected_binding_id":"","account_revision":0} {}`,
} {
domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", body, http.StatusBadRequest)
}
for _, method := range []string{http.MethodGet, http.MethodPut} {
response := serveUserRequest(f.server, f.cookies["manager"], method, "/api/v1/domains/domain-a/dns-account?project_id=project-b", domainDNSBody(account.ID, "", account.Revision))
if response.Code != http.StatusBadRequest {
t.Fatal("查询参数未拒绝", response.Code)
}
}
request := httptest.NewRequest(http.MethodPut, "/api/v1/domains/domain-a/dns-account", strings.NewReader(domainDNSBody(account.ID, "", account.Revision)))
request.AddCookie(f.cookies["manager"])
request.Header.Set("Origin", "https://untrusted.example.test")
response := httptest.NewRecorder()
f.server.Handler().ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatal("跨源写入未拒绝", response.Code)
}
binding := domainDNSRequest(t, f, "manager", http.MethodGet, "domain-a", "", http.StatusOK)
if binding.ID != "" {
t.Fatal("非法请求改变绑定")
}
}
func TestDomainDNSAccountAPIDependencyAndState(t *testing.T) {
for _, failure := range []string{"unverified", "disabled", "http_challenge", "active_task", "audit", "repository"} {
t.Run(failure, func(t *testing.T) {
f, account := newDomainDNSAPIFixture(t)
status := http.StatusUnprocessableEntity
switch failure {
case "unverified":
if _, err := f.db.Exec(`UPDATE dns_accounts SET verification_state='unverified'`); err != nil {
t.Fatal(err)
}
case "disabled":
if _, err := f.db.Exec(`UPDATE dns_accounts SET status='disabled'`); err != nil {
t.Fatal(err)
}
case "http_challenge":
if _, err := f.db.Exec(`UPDATE domains SET challenge_type='http-01' WHERE id='domain-a'`); err != nil {
t.Fatal(err)
}
case "active_task":
if _, err := f.db.Exec(`INSERT INTO tasks (id,domain_id,task_type,status,message) VALUES ('active-task','domain-a','certificate_issue','running','')`); err != nil {
t.Fatal(err)
}
status = http.StatusConflict
case "audit":
if _, err := f.db.Exec(`CREATE TRIGGER reject_binding_audit BEFORE INSERT ON audit_logs WHEN NEW.action='domain.dns_account' BEGIN SELECT RAISE(ABORT,'internal-sensitive-error'); END`); err != nil {
t.Fatal(err)
}
status = http.StatusServiceUnavailable
case "repository":
f.server.projectMembers = nil
status = http.StatusServiceUnavailable
}
domainDNSRequest(t, f, "manager", http.MethodPut, "domain-a", domainDNSBody(account.ID, "", account.Revision), status)
binding, err := f.members.GetDomainDNSAccountBinding(t.Context(), "manager", "domain-a")
if err != nil || binding.ID != "" {
t.Fatal("失败请求改变绑定", err)
}
})
}
}