Why: Historical full sessions could reach self-profile actions, and a lost recovery response had no safe idempotent retry contract. Evidence: SELF receipts /tmp/iam-u3-full-password-profile.json, /tmp/iam-u3-recovery-idempotent-sqlite.json, /tmp/iam-u3-recovery-idempotent-pg.json, /tmp/iam-u3-account-sse-pass.json, /tmp/iam-u3-backend-idempotent-all.json, and /tmp/iam-u3-backend-idempotent-pg.json. Impact: Profile actions require a password-authenticated full cookie; duplicate owner confirmation or rotation returns status without plaintext; an active SSE stream stops before its next business frame after disable. Refs: IAM U3 r1 stages 4-5; Dev feature/iam-u3-account-directory-recovery
79 lines
2.2 KiB
Go
79 lines
2.2 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
func TestAccountDisableStopsNextSSEBusinessFrame(t *testing.T) {
|
|
srv, st := newAuthTestServer(t)
|
|
if err := st.CreateAccount("admin", "", "admin-hash", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.CreateAccount("sse-user", "", "user-hash", false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ClearMustChangePassword("sse-user"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, err := st.GetAccountByOwner("sse-user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
project, err := st.CreateProject("sse-user", "stream")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conv, err := st.CreateConversation("sse-user", project.ID, "stream", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
selected := make(chan core.StreamEvent, 1)
|
|
srv.Subscribe = func(string) (<-chan core.StreamEvent, func()) { return selected, func() {} }
|
|
token, err := st.CreateSession("sse-user")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
httpServer := httptest.NewServer(srv.Handler())
|
|
defer httpServer.Close()
|
|
request, err := http.NewRequest(http.MethodGet, httpServer.URL+"/api/events?conversationId="+conv.ID, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: token})
|
|
client := &http.Client{Timeout: 3 * time.Second}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer response.Body.Close()
|
|
reader := bufio.NewReader(response.Body)
|
|
first, err := reader.ReadString('\n')
|
|
if err != nil || !strings.HasPrefix(first, "retry:") {
|
|
t.Fatalf("SSE did not open: %q %v", first, err)
|
|
}
|
|
if _, err := st.SetAccountEnabled("admin", user.ID, user.StateVersion, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
selected <- core.StreamEvent{ConversationID: conv.ID, Seq: 1, Type: core.EvToolOutput,
|
|
Payload: json.RawMessage(`{"text":"must-not-stream"}`)}
|
|
tail, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
t.Fatalf("revoked stream did not close: %v", err)
|
|
}
|
|
if strings.Contains(string(tail), "must-not-stream") {
|
|
t.Fatal("disabled account received the next SSE business frame")
|
|
}
|
|
if _, _, ok := st.SessionInfo(token); ok {
|
|
t.Fatal("disabled account session still authenticates")
|
|
}
|
|
}
|