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
210 lines
6.2 KiB
Go
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")
|
|
}
|
|
}
|