Files
weichen 4d1ca60067 Expose role and system permission APIs
Why: System, role, and project entry permissions need explicit actions instead of a single administrator flag or unrestricted project creation.

Evidence: The full Backend test suite passed, including role APIs, action isolation, project.create denial/allow, storage migration coverage, and account write fences.

Impact: Adds versioned role and grant endpoints, maps account actions to explicit permissions, and requires project.create authority.

Refs: Focused role and permission system task 3
2026-10-14 17:02:11 +00:00

210 lines
6.2 KiB
Go

package server
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"agent-system-backend/internal/store"
core "agent-system-core"
)
type pausedRequestBody struct {
reader io.Reader
entered chan struct{}
release chan struct{}
once sync.Once
}
func (b *pausedRequestBody) Read(p []byte) (int, error) {
b.once.Do(func() { close(b.entered) })
<-b.release
return b.reader.Read(p)
}
func (*pausedRequestBody) Close() error { return nil }
func TestProjectCreateCannotCommitAfterAccountDisable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = bootAdmin(t, h, st)
hash, err := store.HashPassword("actor-password")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("actor", "Actor", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("actor"); err != nil {
t.Fatal(err)
}
grantProjectCreateFixture(t, st, "actor")
key, err := st.CreateAPIKey("actor", "request")
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("actor")
if err != nil {
t.Fatal(err)
}
body := &pausedRequestBody{reader: strings.NewReader(`{"name":"late-project"}`), entered: make(chan struct{}), release: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, "/api/projects", body)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-body.entered:
case <-time.After(3 * time.Second):
t.Fatal("project request never reached its body")
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("request did not finish after disable")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("disabled request committed: status=%d body=%s", rec.Code, rec.Body.String())
}
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM projects WHERE owner='actor'`).Scan(&n); err != nil || n != 0 {
t.Fatalf("late project row count=%d err=%v", n, err)
}
}
func TestUserMessageCannotCommitAfterAccountDisable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = bootAdmin(t, h, st)
key, err := createLegacyAPIKeyFixture(t, st, "actor", "request")
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("actor")
if err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("actor", "fixture")
if err != nil {
t.Fatal(err)
}
conversation, err := st.CreateConversation("actor", string(project.ID), "fixture", "")
if err != nil {
t.Fatal(err)
}
body := &pausedRequestBody{reader: strings.NewReader(`{"text":"late message"}`), entered: make(chan struct{}), release: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, "/api/conversations/"+string(conversation.ID)+"/messages", body)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-body.entered:
case <-time.After(3 * time.Second):
t.Fatal("message request never reached its body")
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("message request did not finish after disable")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("disabled message request committed: status=%d body=%s", rec.Code, rec.Body.String())
}
var n int
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM messages WHERE conversation_id=?`, conversation.ID).Scan(&n); err != nil || n != 0 {
t.Fatalf("late message count=%d err=%v", n, err)
}
}
func TestAdminCannotDownloadOtherOwnersThroughDatabaseBackup(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
admin := bootAdmin(t, h, st)
rec := do(h, http.MethodPost, "/api/admin/backup", "{}", admin)
if rec.Code != http.StatusForbidden {
t.Fatalf("system admin received all-owner database backup: %d %s", rec.Code, rec.Body.String())
}
}
func TestRegenerationCannotChangeMessageAfterAccountDisable(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = bootAdmin(t, h, st)
key, err := createLegacyAPIKeyFixture(t, st, "actor", "regenerate")
if err != nil {
t.Fatal(err)
}
account, err := st.GetAccountByOwner("actor")
if err != nil {
t.Fatal(err)
}
project, err := st.CreateProject("actor", "fixture")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("actor", string(project.ID), "fixture", "")
if err != nil {
t.Fatal(err)
}
user := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "question"}}}
target := core.Message{ConversationID: conv.ID, Role: core.RoleAssistant, Parts: []core.Part{{Type: core.PartText, Text: "answer"}}}
if err := st.AppendMessage(&user); err != nil {
t.Fatal(err)
}
if err := st.AppendMessage(&target); err != nil {
t.Fatal(err)
}
body := &pausedRequestBody{reader: strings.NewReader(`{"messageId":"` + target.ID + `"}`), entered: make(chan struct{}), release: make(chan struct{})}
req := httptest.NewRequest(http.MethodPost, "/api/conversations/"+string(conv.ID)+"/regenerate", body)
req.Header.Set("Authorization", "Bearer "+key)
rec := httptest.NewRecorder()
done := make(chan struct{})
go func() {
h.ServeHTTP(rec, req)
close(done)
}()
select {
case <-body.entered:
case <-time.After(3 * time.Second):
t.Fatal("regeneration request never reached its body")
}
if _, err := st.SetAccountEnabled("admin", account.ID, account.StateVersion, false); err != nil {
t.Fatal(err)
}
close(body.release)
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("regeneration did not finish after disable")
}
if rec.Code != http.StatusForbidden {
t.Fatalf("disabled regeneration status=%d body=%s", rec.Code, rec.Body.String())
}
got, err := st.MessageByID(target.ID)
if err != nil || got.Excluded || got.VariantGroup != "" {
t.Fatalf("rejected regeneration changed target: %+v %v", got, err)
}
if _, _, ok := st.PeekRegenPending(string(conv.ID)); ok {
t.Fatal("rejected regeneration left marker")
}
}