package api import ( "bufio" "context" "io" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "browser.local/platform/domain" "browser.local/platform/dto" "browser.local/platform/repo" "browser.local/platform/service" "browser.local/platform/validator" ) type logStreamListHookCore struct { service.Core once sync.Once hook func() } type ssePipeResponseWriter struct { header http.Header pipe *io.PipeWriter status chan int once sync.Once } func newSSEPipeResponseWriter() (*ssePipeResponseWriter, *io.PipeReader) { reader, writer := io.Pipe() return &ssePipeResponseWriter{header: make(http.Header), pipe: writer, status: make(chan int, 1)}, reader } func (writer *ssePipeResponseWriter) Header() http.Header { return writer.header } func (writer *ssePipeResponseWriter) WriteHeader(status int) { writer.once.Do(func() { writer.status <- status }) } func (writer *ssePipeResponseWriter) Write(payload []byte) (int, error) { writer.WriteHeader(http.StatusOK) return writer.pipe.Write(payload) } func (writer *ssePipeResponseWriter) Flush() {} func (writer *ssePipeResponseWriter) Close() error { return writer.pipe.Close() } func (core *logStreamListHookCore) ListLogStreams(filter domain.LogStreamFilter) ([]domain.LogStream, error) { core.once.Do(core.hook) return core.Core.ListLogStreams(filter) } func TestLogIngestAPIWorkflow(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) batch := validLogBatchRequest(t, hello.SessionToken, 1, 2) ackRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch) assertStatus(t, ackRecorder, http.StatusOK) ack := decodeBody[dto.LogBatchIngestResponse](t, ackRecorder) if !ack.Accepted || ack.AcceptedFrom != 1 || ack.AcceptedTo != 2 || ack.LatestSeq != 2 { t.Fatalf("unexpected ack: %+v", ack) } stream := getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1") if stream.LatestSeq != 2 { t.Fatalf("expected latest seq update, got %+v", stream) } queryRecorder := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: "log-1", AfterSeq: 0, Limit: 1}) assertStatus(t, queryRecorder, http.StatusOK) query := decodeBody[dto.LogStreamCursorResponse](t, queryRecorder) if len(query.Entries) != 1 || query.Entries[0].Seq != 1 || query.NextSeq != 1 || query.LatestSeq != 2 { t.Fatalf("unexpected query: %+v", query) } } func TestLogEventsSSEReplaysHistory(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) batch := validLogBatchRequest(t, hello.SessionToken, 1, 2) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK) recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=2") assertStatus(t, recorder, http.StatusOK) body := recorder.Body.String() if !strings.Contains(recorder.Header().Get("Content-Type"), "text/event-stream") || !strings.Contains(body, "event: stream") || !strings.Contains(body, "event: log") || !strings.Contains(body, "event: ready") || !strings.Contains(body, `"seq":1`) || !strings.Contains(body, `"seq":2`) { t.Fatalf("expected stream, history log, and ready SSE events, headers=%v body=%s", recorder.Header(), body) } } func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ ID: "log-2", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stderr", LogSessionID: "session-current", SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", }) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-1", "stdout", 1, 2, 0)), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-2", "stderr", 1, 2, 10)), http.StatusOK) recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=2") assertStatus(t, recorder, http.StatusOK) body := recorder.Body.String() if strings.Count(body, "event: log") != 2 || !strings.Contains(body, `"streamId":"log-2"`) || strings.Contains(body, `"streamId":"log-1"`) { t.Fatalf("expected server-wide newest history across streams, body=%s", body) } } func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) oldStream := dto.LogStreamCreateRequest{ID: "log-old", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stdout", LogSessionID: "session-old", SessionStartedAt: time.Date(2026, 7, 3, 11, 0, 0, 0, time.UTC), StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default"} postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", oldStream) oldBatch := validLogBatchRequestForStream(t, hello.SessionToken, "log-old", "stdout", 1, 1, 0) oldBatch.LogSessionID = "session-old" oldBatch.SessionStartedAt = oldStream.SessionStartedAt assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", oldBatch), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK) recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10") body := recorder.Body.String() if !strings.Contains(body, `"logSessionId":"session-current"`) || strings.Contains(body, `"streamId":"log-old"`) { t.Fatalf("expected only current session in live SSE, body=%s", body) } } func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK) stopped := dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}} assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", stopped), http.StatusOK) assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10")) running := stopped running.ManagedProcessID = "log-session:session-missing" running.ObservationSeq = 1 running.ObservedAt = time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC) running.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"} assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", running), http.StatusOK) assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10")) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.0", Status: domain.RunEndpointStatusOffline, CapabilityFingerprint: "cap-logs", Capacity: dto.RunCapacityResponse{MaxJobs: 1}}), http.StatusOK) assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10")) } func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconnect(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=10", nil).WithContext(ctx) streamWriter, streamReader := newSSEPipeResponseWriter() done := make(chan struct{}) go func() { router.ServeHTTP(streamWriter, request) _ = streamWriter.Close() close(done) }() t.Cleanup(func() { cancel() _ = streamReader.Close() <-done }) if status := <-streamWriter.status; status != http.StatusOK { t.Fatalf("unexpected SSE status: %d", status) } reader := bufio.NewReader(streamReader) assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`) assertSSEEvent(t, reader, "stream", `"id":"log-1"`) assertSSEEvent(t, reader, "log", `"seq":1`) assertSSEEvent(t, reader, "ready", `"streamCount":1`) statusReport := dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}} assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK) name, data := readSSEEvent(t, reader) if name != "session" || strings.Contains(data, `"logSessionId"`) || !strings.Contains(data, `"streamCount":0`) { t.Fatalf("expected stopped process to emit an empty session boundary, name=%q data=%s", name, data) } nextStartedAt := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC) next := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stdout", "stdout", 1, 1, 20) next.LogSessionID = "session-next" next.SessionStartedAt = nextStartedAt assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", next), http.StatusOK) statusReport.ManagedProcessID = "log-session:session-next" statusReport.ObservationSeq = 1 statusReport.ObservedAt = nextStartedAt statusReport.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"} assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK) assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`) assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`) assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`) } func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicates(t *testing.T) { core := service.NewCoreService(repo.NewMemoryStore()) if err := core.SeedLocalPlatformAdmin(); err != nil { t.Fatalf("seed platform admin: %v", err) } setupRouter := NewTestRouterWithCore(core) hello := createLogIngestAPIFixtures(t, setupRouter) initial := validLogBatchRequest(t, hello.SessionToken, 1, 1) hookResult := make(chan error, 1) hooked := &logStreamListHookCore{Core: core, hook: func() { _, err := core.IngestLogBatch(initial.ToDomain()) hookResult <- err }} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=10", nil).WithContext(ctx) streamWriter, streamReader := newSSEPipeResponseWriter() done := make(chan struct{}) go func() { NewTestRouterWithCore(hooked).ServeHTTP(streamWriter, request) _ = streamWriter.Close() close(done) }() t.Cleanup(func() { cancel() _ = streamReader.Close() <-done }) if status := <-streamWriter.status; status != http.StatusOK { t.Fatalf("unexpected SSE status: %d", status) } reader := bufio.NewReader(streamReader) assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`) assertSSEEvent(t, reader, "stream", `"id":"log-1"`) assertSSEEvent(t, reader, "log", `"seq":1`) assertSSEEvent(t, reader, "ready", `"streamCount":1`) if err := <-hookResult; err != nil { t.Fatalf("ingest during stream snapshot: %v", err) } nextStartedAt := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC) next := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stdout", "stdout", 1, 1, 20) next.LogSessionID = "session-next" next.SessionStartedAt = nextStartedAt if _, err := core.IngestLogBatch(next.ToDomain()); err != nil { t.Fatalf("ingest next session stdout: %v", err) } if _, err := core.ReportRunLifecycle(domain.RunLifecycleReport{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, ManagedProcessID: "log-session:session-next", ObservationSeq: 1, ObservedAt: nextStartedAt, ExecutionResult: domain.JobExecutionResult{Kind: "process", ProcessState: "running"}}); err != nil { t.Fatalf("report next managed process: %v", err) } assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`) assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`) assertSSEEvent(t, reader, "log", `"seq":1`) stderr := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21) stderr.LogSessionID = "session-next" stderr.SessionStartedAt = nextStartedAt if _, err := core.IngestLogBatch(stderr.ToDomain()); err != nil { t.Fatalf("ingest next session stderr: %v", err) } assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stderr"`) assertSSEEvent(t, reader, "log", `"streamKey":"stderr"`) delayedOld := validLogBatchRequest(t, hello.SessionToken, 2, 2) if _, err := core.IngestLogBatch(delayedOld.ToDomain()); err != nil { t.Fatalf("ingest delayed old session batch: %v", err) } nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 22) nextLive.LogSessionID = "session-next" nextLive.SessionStartedAt = nextStartedAt if _, err := core.IngestLogBatch(nextLive.ToDomain()); err != nil { t.Fatalf("ingest next session live append: %v", err) } assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`) } func assertSSEEvent(t *testing.T, reader *bufio.Reader, wantName string, wantData string) { t.Helper() name, data := readSSEEvent(t, reader) if name != wantName || !strings.Contains(data, wantData) { t.Fatalf("unexpected SSE event: name=%q data=%s; want name=%q containing %s", name, data, wantName, wantData) } } func assertEmptyInitialLogSession(t *testing.T, recorder *httptest.ResponseRecorder) { t.Helper() assertStatus(t, recorder, http.StatusOK) body := recorder.Body.String() if !strings.Contains(body, "event: session") || strings.Contains(body, `"logSessionId"`) || strings.Contains(body, "event: log") || !strings.Contains(body, `"streamCount":0`) { t.Fatalf("expected an empty initial live session, body=%s", body) } } func readSSEEvent(t *testing.T, reader *bufio.Reader) (string, string) { t.Helper() for { name, data := "", "" for { line, err := reader.ReadString('\n') if err != nil { t.Fatalf("read SSE event: %v", err) } line = strings.TrimRight(line, "\r\n") if line == "" { if name != "" { return name, data } break } if strings.HasPrefix(line, "event: ") { name = strings.TrimPrefix(line, "event: ") } if strings.HasPrefix(line, "data: ") { data = strings.TrimPrefix(line, "data: ") } } } } func performCancelledSSE(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder { t.Helper() ctx, cancel := context.WithCancel(context.Background()) cancel() req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) return rec } func TestLogIngestAPIDuplicateAndErrors(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) batch := validLogBatchRequest(t, hello.SessionToken, 1, 1) first := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch) assertStatus(t, first, http.StatusOK) duplicate := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch) assertStatus(t, duplicate, http.StatusOK) duplicateAck := decodeBody[dto.LogBatchIngestResponse](t, duplicate) if !duplicateAck.Duplicate { t.Fatalf("expected duplicate ack, got %+v", duplicateAck) } gap := validLogBatchRequest(t, hello.SessionToken, 3, 3) gapRecorder := performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", gap) assertErrorResponse(t, gapRecorder, http.StatusBadRequest, errorCodeValidation) missingQuery := performJSON(t, router, http.MethodPost, "/api/v1/log-streams/query", dto.LogStreamCursorRequest{LogStreamID: "missing", Limit: 1}) assertErrorResponse(t, missingQuery, http.StatusNotFound, errorCodeNotFound) } func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunControlHelloResponse { t.Helper() helloRequest := validRunControlHelloRequest() helloRequest.CapabilityReport.Capabilities = append(helloRequest.CapabilityReport.Capabilities, "process.install", "process.start", "process.stop", "logs.read") helloRequest.CapabilityReport.Fingerprint = "cap-logs" hello := decodeBody[dto.RunControlHelloResponse](t, performRunControlHello(t, router, helloRequest)) adminSession := createAdminSession(t, router) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-1", PluginID: "server.scum", RunEndpointID: "run-local", Name: "SCUM #1", State: domain.ServerInstanceStateRunning}, adminSession) postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ ID: "log-1", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stdout", LogSessionID: "session-current", SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", }) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", dto.RunLifecycleReportRequest{ RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, ManagedProcessID: "log-session:session-current", ObservationSeq: 1, ObservedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"}, }), http.StatusOK) return hello } func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest { return validLogBatchRequestForStream(t, sessionToken, "log-1", "stdout", firstSeq, lastSeq, 0) } func validLogBatchRequestForStream(t *testing.T, sessionToken string, streamID string, streamKey string, firstSeq uint64, lastSeq uint64, timestampOffset int) dto.LogBatchIngestRequest { t.Helper() entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1) domainEntries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1) for seq := firstSeq; seq <= lastSeq; seq++ { entry := dto.LogEntryBody{Seq: seq, Timestamp: time.Date(2026, 7, 3, 12, 0, timestampOffset+int(seq), 0, time.UTC), Level: "info", Line: "line"} entries = append(entries, entry) domainEntries = append(domainEntries, domain.LogEntry{Seq: entry.Seq, Timestamp: entry.Timestamp, Level: entry.Level, Line: entry.Line}) } checksum, err := validator.LogEntriesChecksum(domainEntries) if err != nil { t.Fatalf("checksum entries: %v", err) } return dto.LogBatchIngestRequest{ RunEndpointID: "run-local", SessionToken: sessionToken, LogStreamID: streamID, ServerInstanceID: "server-1", StreamKey: streamKey, Source: domain.LogStreamSourceProcess, LogSessionID: "session-current", SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), FirstSeq: firstSeq, LastSeq: lastSeq, Compression: "none", Checksum: checksum, Entries: entries, } }