410 lines
14 KiB
Go
410 lines
14 KiB
Go
package workbenchruntime
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRealtimeSyncTxOptionsAreReadOnlyRepeatableRead(t *testing.T) {
|
|
options := realtimeSyncTxOptions()
|
|
if options.Isolation != sql.LevelRepeatableRead {
|
|
t.Fatalf("isolation=%v, want repeatable read", options.Isolation)
|
|
}
|
|
if !options.ReadOnly {
|
|
t.Fatal("realtime sync transaction must be read only")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeRealtimeSyncQueryRequiresScopedActorAndBoundsLimit(t *testing.T) {
|
|
query, validation := normalizeRealtimeSyncQuery(realtimeSyncQuery{
|
|
SessionID: " ses_sync ",
|
|
Limit: 900,
|
|
Actor: sessionActor{ID: " usr_owner ", Role: " USER "},
|
|
})
|
|
if validation != nil {
|
|
t.Fatalf("unexpected validation: %#v", validation)
|
|
}
|
|
if query.SessionID != "ses_sync" || query.Actor.ID != "usr_owner" || query.Actor.Role != "user" {
|
|
t.Fatalf("query was not normalized: %#v", query)
|
|
}
|
|
if query.Limit != realtimeSyncMaxLimit {
|
|
t.Fatalf("limit=%d, want %d", query.Limit, realtimeSyncMaxLimit)
|
|
}
|
|
|
|
_, validation = normalizeRealtimeSyncQuery(realtimeSyncQuery{TraceID: "trc_sync", Actor: sessionActor{Role: "user"}})
|
|
if validation == nil || validation.Status != http.StatusForbidden || validation.Code != "workbench_sync_actor_required" {
|
|
t.Fatalf("ordinary user without owner id validation=%#v", validation)
|
|
}
|
|
|
|
admin, validation := normalizeRealtimeSyncQuery(realtimeSyncQuery{TraceID: "trc_sync", Actor: sessionActor{Role: "admin"}})
|
|
if validation != nil || admin.Actor.ID != "" {
|
|
t.Fatalf("admin should not require owner id: query=%#v validation=%#v", admin, validation)
|
|
}
|
|
|
|
_, validation = normalizeRealtimeSyncQuery(realtimeSyncQuery{Actor: sessionActor{ID: "usr_owner", Role: "user"}})
|
|
if validation == nil || validation.Code != "workbench_sync_scope_required" {
|
|
t.Fatalf("missing scope validation=%#v", validation)
|
|
}
|
|
|
|
_, validation = normalizeRealtimeSyncQuery(realtimeSyncQuery{SessionID: "ses_sync", AfterOutboxSeq: -1, Actor: sessionActor{ID: "usr_owner", Role: "user"}})
|
|
if validation == nil || validation.Code != "workbench_sync_cursor_invalid" {
|
|
t.Fatalf("negative cursor validation=%#v", validation)
|
|
}
|
|
|
|
_, validation = normalizeRealtimeSyncQuery(realtimeSyncQuery{SessionID: "ses_sync", SnapshotOnly: true, DeltaOnly: true, Actor: sessionActor{ID: "usr_owner", Role: "user"}})
|
|
if validation == nil || validation.Code != "workbench_sync_mode_invalid" {
|
|
t.Fatalf("conflicting sync mode validation=%#v", validation)
|
|
}
|
|
}
|
|
|
|
func TestRealtimeSyncRouteIsPostOnlyAndRejectsUnknownFields(t *testing.T) {
|
|
server := &Server{config: Config{}, mux: http.NewServeMux()}
|
|
server.routes()
|
|
|
|
methodRecorder := httptest.NewRecorder()
|
|
server.mux.ServeHTTP(methodRecorder, httptest.NewRequest(http.MethodGet, "/v1/workbench-runtime/sync", nil))
|
|
if methodRecorder.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("GET status=%d, want %d", methodRecorder.Code, http.StatusMethodNotAllowed)
|
|
}
|
|
if methodRecorder.Header().Get("Allow") != "POST" {
|
|
t.Fatalf("Allow=%q, want POST", methodRecorder.Header().Get("Allow"))
|
|
}
|
|
|
|
unknownRecorder := httptest.NewRecorder()
|
|
unknownRequest := httptest.NewRequest(http.MethodPost, "/v1/workbench-runtime/sync", strings.NewReader(`{"sessionId":"ses_sync","actor":{"id":"usr_owner","role":"user"},"unknown":true}`))
|
|
server.mux.ServeHTTP(unknownRecorder, unknownRequest)
|
|
if unknownRecorder.Code != http.StatusBadRequest || !strings.Contains(unknownRecorder.Body.String(), "invalid_json") {
|
|
t.Fatalf("unknown field response=%d %s", unknownRecorder.Code, unknownRecorder.Body.String())
|
|
}
|
|
|
|
trailingRecorder := httptest.NewRecorder()
|
|
trailingRequest := httptest.NewRequest(http.MethodPost, "/v1/workbench-runtime/sync", strings.NewReader(`{"sessionId":"ses_sync","actor":{"id":"usr_owner","role":"user"}} {}`))
|
|
server.mux.ServeHTTP(trailingRecorder, trailingRequest)
|
|
if trailingRecorder.Code != http.StatusBadRequest || !strings.Contains(trailingRecorder.Body.String(), "invalid_json") {
|
|
t.Fatalf("trailing JSON response=%d %s", trailingRecorder.Code, trailingRecorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReadRealtimeSyncSnapshotUsesOwnerScopeAndSingleCutoff(t *testing.T) {
|
|
queryer := newRealtimeSyncFakeQueryer("usr_owner")
|
|
query := realtimeSyncQuery{
|
|
SessionID: "ses_sync",
|
|
TraceID: "trc_sync",
|
|
AfterOutboxSeq: 0,
|
|
Limit: 2,
|
|
Actor: sessionActor{ID: "usr_owner", Role: "user"},
|
|
}
|
|
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, query)
|
|
if err != nil {
|
|
t.Fatalf("read sync snapshot: %v", err)
|
|
}
|
|
if snapshot.CutoffOutboxSeq != 3 || snapshot.CursorOutboxSeq != 2 || !snapshot.HasMore {
|
|
t.Fatalf("unexpected cursor boundary: %#v", snapshot)
|
|
}
|
|
if len(snapshot.Events) != 2 {
|
|
t.Fatalf("events=%d, want 2", len(snapshot.Events))
|
|
}
|
|
first := snapshot.Events[0]
|
|
if first.OutboxEventID != "obe_1" || first.EntityFamily != "traceEvents" || first.EntityID != "evt_1" {
|
|
t.Fatalf("event identity fields were not returned: %#v", first)
|
|
}
|
|
if first.EventSeq != int64(101) || first.AggregateID != "trc_sync" || first.AggregateSeq != 1 || first.ProjectionRevision != 11 {
|
|
t.Fatalf("event aggregate fields were not returned: %#v", first)
|
|
}
|
|
if len(snapshot.Facts) != len(realtimeSyncFactSpecs) {
|
|
t.Fatalf("fact families=%d, want %d: %#v", len(snapshot.Facts), len(realtimeSyncFactSpecs), snapshot.Facts)
|
|
}
|
|
for _, spec := range realtimeSyncFactSpecs {
|
|
if len(snapshot.Facts[spec.Family]) != 1 {
|
|
t.Fatalf("facts[%s]=%#v, want one fact", spec.Family, snapshot.Facts[spec.Family])
|
|
}
|
|
}
|
|
if len(queryer.calls) != 3+len(realtimeSyncFactSpecs) {
|
|
t.Fatalf("query calls=%v", queryer.calls)
|
|
}
|
|
if queryer.calls[0].kind != "scope" || queryer.calls[1].kind != "cutoff" || queryer.calls[2].kind != "events" {
|
|
t.Fatalf("snapshot boundary query order=%v", queryer.calls)
|
|
}
|
|
if got := queryer.calls[0].args; len(got) != 4 || got[2] != false || got[3] != "usr_owner" {
|
|
t.Fatalf("owner scope args=%#v", got)
|
|
}
|
|
if got := queryer.calls[2].args; len(got) != 5 || got[3] != int64(3) || got[4] != 3 {
|
|
t.Fatalf("events must use cutoff and limit+1: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestReadRealtimeSyncSnapshotRejectsOtherOwnerBeforeReadingFacts(t *testing.T) {
|
|
if !strings.Contains(realtimeSyncResolveScopeSQL, "sessions.owner_user_id = $4") {
|
|
t.Fatalf("ordinary user scope must be constrained by workbench_sessions.owner_user_id: %s", realtimeSyncResolveScopeSQL)
|
|
}
|
|
queryer := newRealtimeSyncFakeQueryer("usr_owner")
|
|
_, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
|
|
SessionID: "ses_sync",
|
|
Limit: 100,
|
|
Actor: sessionActor{ID: "usr_other", Role: "user"},
|
|
})
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
t.Fatalf("error=%v, want sql.ErrNoRows", err)
|
|
}
|
|
if len(queryer.calls) != 1 || queryer.calls[0].kind != "scope" {
|
|
t.Fatalf("unauthorized scope must stop before outbox/facts reads: %v", queryer.calls)
|
|
}
|
|
}
|
|
|
|
func TestReadRealtimeSyncSnapshotOnlySkipsHistoryAndReadsBoundedCurrentFacts(t *testing.T) {
|
|
queryer := newRealtimeSyncFakeQueryer("usr_owner")
|
|
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
|
|
SessionID: "ses_sync",
|
|
Limit: 100,
|
|
SnapshotOnly: true,
|
|
Actor: sessionActor{ID: "usr_owner", Role: "user"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("read snapshot-only sync: %v", err)
|
|
}
|
|
if len(snapshot.Events) != 0 || snapshot.HasMore || snapshot.CursorOutboxSeq != snapshot.CutoffOutboxSeq {
|
|
t.Fatalf("snapshot-only boundary=%#v", snapshot)
|
|
}
|
|
if len(snapshot.Facts) != len(realtimeSyncSnapshotFactSpecs) {
|
|
t.Fatalf("snapshot-only facts=%#v", snapshot.Facts)
|
|
}
|
|
for _, call := range queryer.calls {
|
|
if call.kind == "events" || call.kind == "facts.parts" || call.kind == "facts.traceEvents" || call.kind == "facts.checkpoints" {
|
|
t.Fatalf("snapshot-only performed unbounded read: %v", queryer.calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadRealtimeSyncDeltaOnlyReadsOutboxWithoutFacts(t *testing.T) {
|
|
queryer := newRealtimeSyncFakeQueryer("usr_owner")
|
|
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
|
|
SessionID: "ses_sync",
|
|
AfterOutboxSeq: 1,
|
|
Limit: 100,
|
|
DeltaOnly: true,
|
|
Actor: sessionActor{ID: "usr_owner", Role: "user"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("read delta-only sync: %v", err)
|
|
}
|
|
if len(snapshot.Events) == 0 || len(snapshot.Facts) != 0 {
|
|
t.Fatalf("delta-only result=%#v", snapshot)
|
|
}
|
|
for _, call := range queryer.calls {
|
|
if strings.HasPrefix(call.kind, "facts.") {
|
|
t.Fatalf("delta-only read facts: %v", queryer.calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadRealtimeSyncSnapshotClampsForeignCursorToScopedCutoff(t *testing.T) {
|
|
queryer := newRealtimeSyncFakeQueryer("usr_owner")
|
|
queryer.events = [][]any{}
|
|
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
|
|
SessionID: "ses_sync",
|
|
AfterOutboxSeq: 999,
|
|
Limit: 100,
|
|
Actor: sessionActor{ID: "usr_owner", Role: "user"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("read sync snapshot: %v", err)
|
|
}
|
|
if snapshot.CutoffOutboxSeq != 3 || snapshot.CursorOutboxSeq != 3 || snapshot.HasMore || len(snapshot.Events) != 0 {
|
|
t.Fatalf("foreign cursor was not clamped to scoped cutoff: %#v", snapshot)
|
|
}
|
|
}
|
|
|
|
func TestReadRealtimeSyncSnapshotAllowsAdminWithoutOwnerFilter(t *testing.T) {
|
|
queryer := newRealtimeSyncFakeQueryer("usr_owner")
|
|
snapshot, err := readRealtimeSyncSnapshot(context.Background(), queryer, realtimeSyncQuery{
|
|
TraceID: "trc_sync",
|
|
Limit: 3,
|
|
Actor: sessionActor{Role: "admin"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("admin sync: %v", err)
|
|
}
|
|
if len(queryer.calls) == 0 || queryer.calls[0].args[2] != true || queryer.calls[0].args[3] != "" {
|
|
t.Fatalf("admin scope args=%#v", queryer.calls)
|
|
}
|
|
if snapshot.CursorOutboxSeq != snapshot.CutoffOutboxSeq || snapshot.HasMore {
|
|
t.Fatalf("admin complete page boundary=%#v", snapshot)
|
|
}
|
|
}
|
|
|
|
type realtimeSyncFakeCall struct {
|
|
kind string
|
|
query string
|
|
args []any
|
|
}
|
|
|
|
type realtimeSyncFakeQueryer struct {
|
|
ownerID string
|
|
sessionID string
|
|
cutoff int64
|
|
events [][]any
|
|
facts map[string][][]any
|
|
calls []realtimeSyncFakeCall
|
|
}
|
|
|
|
func newRealtimeSyncFakeQueryer(ownerID string) *realtimeSyncFakeQueryer {
|
|
facts := map[string][][]any{}
|
|
for _, spec := range realtimeSyncFactSpecs {
|
|
facts[spec.Table] = [][]any{{fmt.Sprintf(`{"family":%q,"sessionId":"ses_sync"}`, spec.Family)}}
|
|
}
|
|
return &realtimeSyncFakeQueryer{
|
|
ownerID: ownerID,
|
|
sessionID: "ses_sync",
|
|
cutoff: 3,
|
|
events: [][]any{realtimeSyncTestEventRow(1), realtimeSyncTestEventRow(2), realtimeSyncTestEventRow(3)},
|
|
facts: facts,
|
|
}
|
|
}
|
|
|
|
func (queryer *realtimeSyncFakeQueryer) QueryRowContext(_ context.Context, query string, args ...any) realtimeSyncRow {
|
|
if query == realtimeSyncResolveScopeSQL {
|
|
queryer.record("scope", query, args)
|
|
isAdmin, _ := args[2].(bool)
|
|
actorID, _ := args[3].(string)
|
|
if !isAdmin && actorID != queryer.ownerID {
|
|
return realtimeSyncFakeRow{err: sql.ErrNoRows}
|
|
}
|
|
return realtimeSyncFakeRow{values: []any{queryer.sessionID}}
|
|
}
|
|
if query == realtimeSyncCutoffSQL {
|
|
queryer.record("cutoff", query, args)
|
|
return realtimeSyncFakeRow{values: []any{queryer.cutoff}}
|
|
}
|
|
return realtimeSyncFakeRow{err: fmt.Errorf("unexpected QueryRowContext: %s", query)}
|
|
}
|
|
|
|
func (queryer *realtimeSyncFakeQueryer) QueryContext(_ context.Context, query string, args ...any) (realtimeSyncRows, error) {
|
|
if query == realtimeSyncEventsSQL {
|
|
queryer.record("events", query, args)
|
|
return &realtimeSyncFakeRows{values: queryer.events}, nil
|
|
}
|
|
for _, spec := range realtimeSyncFactSpecs {
|
|
if strings.Contains(query, "FROM "+spec.Table+" ") {
|
|
queryer.record("facts."+spec.Family, query, args)
|
|
return &realtimeSyncFakeRows{values: queryer.facts[spec.Table]}, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("unexpected QueryContext: %s", query)
|
|
}
|
|
|
|
func (queryer *realtimeSyncFakeQueryer) record(kind string, query string, args []any) {
|
|
cloned := append([]any(nil), args...)
|
|
queryer.calls = append(queryer.calls, realtimeSyncFakeCall{kind: kind, query: query, args: cloned})
|
|
}
|
|
|
|
type realtimeSyncFakeRow struct {
|
|
values []any
|
|
err error
|
|
}
|
|
|
|
func (row realtimeSyncFakeRow) Scan(destinations ...any) error {
|
|
if row.err != nil {
|
|
return row.err
|
|
}
|
|
return assignRealtimeSyncFakeValues(destinations, row.values)
|
|
}
|
|
|
|
type realtimeSyncFakeRows struct {
|
|
values [][]any
|
|
index int
|
|
err error
|
|
}
|
|
|
|
func (rows *realtimeSyncFakeRows) Close() error { return nil }
|
|
|
|
func (rows *realtimeSyncFakeRows) Err() error { return rows.err }
|
|
|
|
func (rows *realtimeSyncFakeRows) Next() bool { return rows.index < len(rows.values) }
|
|
|
|
func (rows *realtimeSyncFakeRows) Scan(destinations ...any) error {
|
|
if rows.index >= len(rows.values) {
|
|
return errors.New("realtime sync fake rows exhausted")
|
|
}
|
|
values := rows.values[rows.index]
|
|
rows.index++
|
|
return assignRealtimeSyncFakeValues(destinations, values)
|
|
}
|
|
|
|
func assignRealtimeSyncFakeValues(destinations []any, values []any) error {
|
|
if len(destinations) != len(values) {
|
|
return fmt.Errorf("scan destinations=%d values=%d", len(destinations), len(values))
|
|
}
|
|
for index, destination := range destinations {
|
|
value := values[index]
|
|
switch target := destination.(type) {
|
|
case *string:
|
|
if value == nil {
|
|
return fmt.Errorf("cannot scan nil into string")
|
|
}
|
|
*target = fmt.Sprint(value)
|
|
case *int64:
|
|
integer, ok := value.(int64)
|
|
if !ok {
|
|
return fmt.Errorf("cannot scan %T into int64", value)
|
|
}
|
|
*target = integer
|
|
case *sql.NullString:
|
|
if value == nil {
|
|
*target = sql.NullString{}
|
|
} else {
|
|
*target = sql.NullString{String: fmt.Sprint(value), Valid: true}
|
|
}
|
|
case *sql.NullInt64:
|
|
if value == nil {
|
|
*target = sql.NullInt64{}
|
|
} else if integer, ok := value.(int64); ok {
|
|
*target = sql.NullInt64{Int64: integer, Valid: true}
|
|
} else {
|
|
return fmt.Errorf("cannot scan %T into NullInt64", value)
|
|
}
|
|
case *sql.NullBool:
|
|
if value == nil {
|
|
*target = sql.NullBool{}
|
|
} else if boolean, ok := value.(bool); ok {
|
|
*target = sql.NullBool{Bool: boolean, Valid: true}
|
|
} else {
|
|
return fmt.Errorf("cannot scan %T into NullBool", value)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported scan destination %T", destination)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func realtimeSyncTestEventRow(sequence int64) []any {
|
|
return []any{
|
|
sequence,
|
|
fmt.Sprintf("obe_%d", sequence),
|
|
"traceEvents",
|
|
fmt.Sprintf("evt_%d", sequence),
|
|
int64(100) + sequence,
|
|
"trc_sync",
|
|
sequence,
|
|
int64(10) + sequence,
|
|
"trc_sync",
|
|
"ses_sync",
|
|
"turn_sync",
|
|
nil,
|
|
sequence,
|
|
sequence,
|
|
fmt.Sprintf("source_%d", sequence),
|
|
"event",
|
|
false,
|
|
false,
|
|
fmt.Sprintf(`{"sequence":%d}`, sequence),
|
|
"2026-07-10T12:00:00Z",
|
|
}
|
|
}
|