Scert/backend/internal/httpapi/project_members_boundary_test.go

159 lines
6.5 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 (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/logging"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
)
// 在会话验证之后、真实仓储执行之前改变权限,避免仅验证顺序请求的撤权场景。
type projectMemberBoundaryRepository struct {
repository.ProjectMemberRepository
before func()
audit func(context.Context)
}
func (r projectMemberBoundaryRepository) ListProjectMembers(ctx context.Context, actor, project, after string, size int) (repository.ProjectMemberPage, error) {
r.before()
return r.ProjectMemberRepository.ListProjectMembers(ctx, actor, project, after, size)
}
func (r projectMemberBoundaryRepository) GrantProjectRole(ctx context.Context, actor, project, user string, role domain.ProjectRole) error {
r.before()
return r.ProjectMemberRepository.GrantProjectRole(ctx, actor, project, user, role)
}
func (r projectMemberBoundaryRepository) RevokeProjectRole(ctx context.Context, actor, project, user string, role domain.ProjectRole) error {
r.before()
return r.ProjectMemberRepository.RevokeProjectRole(ctx, actor, project, user, role)
}
func (r projectMemberBoundaryRepository) RecordProjectMemberFailure(ctx context.Context, failure repository.ProjectMemberFailure) error {
if r.audit != nil {
r.audit(ctx)
}
return r.ProjectMemberRepository.RecordProjectMemberFailure(ctx, failure)
}
func TestProjectMemberAPIDeleteRejectsPayload(t *testing.T) {
f := newProjectMemberAPIFixture(t)
path := memberTestPath + "/manager/roles/project_admin"
for _, tc := range []struct{ name, suffix, body string }{
{"json", "", `{"actor_id":"primary-super-admin"}`},
{"whitespace", "", " "},
{"large_body", "", strings.Repeat("x", 70*1024)},
{"query", "?actor_id=primary-super-admin", ""},
{"unknown_query", "?ignored=1", ""},
} {
t.Run(tc.name, func(t *testing.T) {
response := serveUserRequest(f.server, f.cookies["manager"], http.MethodDelete, path+tc.suffix, tc.body)
log := f.lastAudit(t)
if response.Code != http.StatusBadRequest || log.Status != "failed" || log.OperatorID != "manager" {
t.Fatalf("撤销角色未拒绝额外输入或审计身份错误:%d,%+v", response.Code, log)
}
if _, err := f.members.ListProjectMembers(t.Context(), "manager", "project-a", "", 5); err != nil {
t.Fatalf("拒绝的请求仍撤销了角色:%v", err)
}
})
}
}
func TestProjectMemberAPIRechecksAfterAuthentication(t *testing.T) {
for _, change := range []string{"revoke", "disable"} {
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} {
t.Run(change+"/"+method, func(t *testing.T) {
f := newProjectMemberAPIFixture(t)
called := false
f.server.projectMembers = projectMemberBoundaryRepository{
ProjectMemberRepository: f.members,
before: func() {
called = true
query := "DELETE FROM project_members WHERE user_id='manager'"
if change == "disable" {
query = "UPDATE users SET status='disabled' WHERE id='manager'"
}
if _, err := f.db.ExecContext(t.Context(), query); err != nil {
t.Fatal(err)
}
},
}
path, body := memberTestPath, ""
if method == http.MethodPost {
body = `{"user_id":"target","role":"dns_admin"}`
} else if method == http.MethodDelete {
path += "/viewer/roles/read_only"
}
response := serveUserRequest(f.server, f.cookies["manager"], method, path, body)
log := f.lastAudit(t)
if !called || response.Code != http.StatusForbidden || log.OperatorID != "manager" || log.Status != "failed" {
t.Fatalf("会话验证后的撤权未拒绝,或丢失已验证身份:%t,%d,%+v", called, response.Code, log)
}
var target, viewer int
if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members WHERE user_id='target'").Scan(&target); err != nil {
t.Fatal(err)
}
if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members WHERE user_id='viewer'").Scan(&viewer); err != nil || target != 0 || viewer != 1 {
t.Fatalf("撤权后仍修改了成员关系:%d,%d,%v", target, viewer, err)
}
})
}
}
}
func TestProjectMemberAPICanceledRequestKeepsBoundedFailureAudit(t *testing.T) {
f := newProjectMemberAPIFixture(t)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
audited := false
f.server.projectMembers = projectMemberBoundaryRepository{
ProjectMemberRepository: f.members,
before: cancel,
audit: func(auditContext context.Context) {
audited = true
deadline, ok := auditContext.Deadline()
if ctx.Err() == nil || auditContext.Err() != nil || !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 3*time.Second {
t.Fatalf("失败审计未脱离取消请求或等待无界:%v,%v,%v", ctx.Err(), auditContext.Err(), deadline)
}
},
}
request := httptest.NewRequest(http.MethodGet, memberTestPath, nil).WithContext(ctx)
request.AddCookie(f.cookies["manager"])
response := httptest.NewRecorder()
f.server.Handler().ServeHTTP(response, request)
log := f.lastAudit(t)
if !audited || response.Code != http.StatusInternalServerError || log.Status != "failed" || log.OperatorID != "manager" {
t.Fatalf("取消请求导致失败审计丢失:%t,%d,%+v", audited, response.Code, log)
}
}
func TestProjectMemberAPIFailureAuditStorageUnavailable(t *testing.T) {
f := newProjectMemberAPIFixture(t)
var output bytes.Buffer
f.server.logger = &logging.Logger{Logger: slog.New(slog.NewJSONHandler(&output, nil))}
previous := f.lastAudit(t)
if _, err := f.db.ExecContext(t.Context(), `CREATE TRIGGER reject_failed_member_audit BEFORE INSERT ON audit_logs
WHEN NEW.resource_type='project_member' AND NEW.status='failed'
BEGIN SELECT RAISE(ABORT, 'sensitive-audit-storage-marker'); END`); err != nil {
t.Fatal(err)
}
response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, memberTestPath, "")
if response.Code != http.StatusForbidden || f.lastAudit(t).ID != previous.ID {
t.Fatalf("审计存储失败改变了原拒绝响应或伪造了审计:%d", response.Code)
}
if !strings.Contains(output.String(), "项目成员失败审计未保存") || !strings.Contains(output.String(), response.Header().Get(requestIDHeader)) {
t.Fatalf("审计存储失败没有稳定可关联日志:%s", output.String())
}
if strings.Contains(output.String()+response.Body.String(), "sensitive-audit-storage-marker") {
t.Fatal("审计存储失败泄露底层错误")
}
}