package console import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "testing" "time" "github.com/carlosframework/platform/internal/applog" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) func logsURL(fx *consoleFixture, acct, app, query string) string { u := fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/logs" if query != "" { u += "?" + query } return u } // seedLogChunks ships a small timeline for titogo: an app line and a wake // failure, recent enough for the default 1h window. func seedLogChunks(t *testing.T, fx *consoleFixture, now time.Time) []applog.Record { t.Helper() recs := []applog.Record{ {TS: now.Add(-10 * time.Minute), Box: "box1", Host: "titogo.example.com", Stream: applog.StreamApp, Msg: "listening on socket", Seq: 100, Fields: map[string]string{"request_id": "r1"}}, {TS: now.Add(-5 * time.Minute), Box: "box1", Host: "titogo.example.com", Stream: applog.StreamPlatform, Level: applog.LevelError, Msg: "wake failed: nothing is promoted for this instance's channel", Seq: 101}, } body, err := applog.EncodeChunk(recs) if err != nil { t.Fatal(err) } key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(recs[0].TS), recs[0].Seq, len(body))) if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { t.Fatal(err) } return recs } func seedManyLogRecords(t *testing.T, fx *consoleFixture, now time.Time, n int) []applog.Record { t.Helper() recs := make([]applog.Record, n) for i := range recs { recs[i] = applog.Record{TS: now.Add(time.Duration(i) * time.Millisecond), Box: "box-many", Host: "titogo.example.com", Stream: applog.StreamApp, Msg: fmt.Sprintf("line %d", i), Seq: uint64(1000 + i)} } body, err := applog.EncodeChunk(recs) if err != nil { t.Fatal(err) } key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box-many", applog.HourOf(recs[0].TS), recs[0].Seq, len(body))) if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { t.Fatal(err) } return recs } // TestAPILogsTailFiltersAndFollow drives the endpoint end to end: default // tail, stream/level/grep/field filters, and the cursor follow loop. func TestAPILogsTailFiltersAndFollow(t *testing.T) { fx, token := instanceFixture(t, "code-logs-1") now := time.Now().UTC() seedLogChunks(t, fx, now) get := func(query string) logsResponse { t.Helper() status, _, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", query), token, "") if status != http.StatusOK { t.Fatalf("GET ?%s = %d: %s", query, status, body) } var resp logsResponse if err := json.Unmarshal([]byte(body), &resp); err != nil { t.Fatalf("bad JSON: %v: %s", err, body) } return resp } // Default window: both records, oldest first, cursor present. resp := get("") if len(resp.Records) != 2 || resp.Records[0].Msg != "listening on socket" { t.Fatalf("tail: %+v", resp) } if resp.Cursor == "" || resp.Truncated { t.Fatalf("cursor/truncated: %+v", resp) } // Filters compose: stream, level, grep, field. if r := get("stream=platform"); len(r.Records) != 1 || r.Records[0].Level != "error" { t.Fatalf("stream filter: %+v", r.Records) } if r := get("level=error"); len(r.Records) != 1 { t.Fatalf("level filter: %+v", r.Records) } if r := get("grep=promoted"); len(r.Records) != 1 || r.Records[0].Stream != "platform" { t.Fatalf("grep filter: %+v", r.Records) } if r := get("field=request_id%3Dr1"); len(r.Records) != 1 || r.Records[0].Stream != "app" { t.Fatalf("field filter: %+v", r.Records) } if r := get("host=titogo.example.com"); len(r.Records) != 2 { t.Fatalf("host filter: %+v", r.Records) } if r := get("host=other.example.com"); len(r.Records) != 0 { t.Fatalf("host mismatch filter: %+v", r.Records) } // limit=1 tails to the NEWEST record and says truncated. r := get("limit=1") if len(r.Records) != 1 || r.Records[0].Stream != "platform" || !r.Truncated { t.Fatalf("limit tail: %+v", r) } // Follow: the cursor from the full read sees nothing new, keeps itself. full := get("") fr := get("cursor=" + full.Cursor) if len(fr.Records) != 0 || fr.Cursor != full.Cursor { t.Fatalf("follow at tip: %+v", fr) } // A new record lands; the same cursor now yields exactly it. extra := []applog.Record{{TS: now.Add(-time.Minute), Box: "box1", Host: "titogo.example.com", Stream: applog.StreamApp, Msg: "fresh line", Seq: 102}} body, err := applog.EncodeChunk(extra) if err != nil { t.Fatal(err) } key := mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(extra[0].TS), 102, len(body))) if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { t.Fatal(err) } fr2 := get("cursor=" + full.Cursor) if len(fr2.Records) != 1 || fr2.Records[0].Msg != "fresh line" { t.Fatalf("follow after new record: %+v", fr2) } } // TestAPILogsRefusals: a non-member's read is the family's byte-identical // 404; bad parameters name themselves in a 400. func TestAPILogsRefusals(t *testing.T) { fx, _ := instanceFixture(t, "code-logs-2") eve := cliToken(t, fx, "eve@keymail.dev", "code-logs-eve") want := canonicalNotFound(t) status, h, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", ""), eve, "") assertNotFound(t, "logs as non-member", want, status, h, body) _, token := func() (*consoleFixture, string) { return fx, cliToken(t, fx, "alice@keymail.dev", "code-logs-3") }() for _, q := range []string{"since=garbage", "stream=nope", "level=shouting", "grep=%5B", "field=nokey", "limit=zero"} { status, _, body := rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", q), token, "") if status != http.StatusBadRequest { t.Errorf("?%s = %d, want 400 (%s)", q, status, body) } } // An app with no logs at all: an empty list, not an error and not null. status, _, body = rawReq(t, http.MethodGet, logsURL(fx, fx.sqid, "titogo", ""), token, "") if status != http.StatusOK || !json.Valid([]byte(body)) { t.Fatalf("empty logs: %d %s", status, body) } var resp logsResponse if err := json.Unmarshal([]byte(body), &resp); err != nil || resp.Records == nil { t.Fatalf("records must be [], got %s", body) } _ = fmt.Sprintf("%v", resp) } func TestAppLogsDataUsesBrowserSessionAndFilters(t *testing.T) { fx, _ := instanceFixture(t, "code-logs-browser-data") seedLogChunks(t, fx, time.Now().UTC()) fx.setUser("alice@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-logs-browser-data-signin") status, _, body := get(t, c, fx.u("/apps/titogo/logs/data?host=titogo.example.com&level=error")) if status != http.StatusOK { t.Fatalf("browser logs data = %d: %s", status, body) } var resp appLogsDataResponse if err := json.Unmarshal([]byte(body), &resp); err != nil { t.Fatalf("browser logs JSON: %v: %s", err, body) } if len(resp.Lines) != 1 || !strings.Contains(resp.Lines[0].Msg, "wake failed") || resp.Lines[0].Tone != "error" { t.Fatalf("filtered browser lines = %+v", resp.Lines) } status, _, body = get(t, c, fx.u("/apps/titogo/logs/data?host=deleted.example.com")) if status != http.StatusOK || !strings.Contains(body, `"lines":[]`) { t.Fatalf("historical host filter broadened = %d: %s", status, body) } status, _, body = get(t, c, fx.u("/apps/titogo/logs/data?text=wake%5B")) if status != http.StatusOK || !strings.Contains(body, `"lines":[]`) { t.Fatalf("browser text search was treated as a regular expression = %d: %s", status, body) } assertNonMember404(t, fx, "/apps/titogo/logs/data") } func TestAppLogsDataCursorPaginationAndRefusals(t *testing.T) { fx, _ := instanceFixture(t, "code-logs-browser-cursor") now := time.Now().UTC() recs := seedLogChunks(t, fx, now) fx.setUser("alice@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-logs-browser-cursor-signin") read := func(query string) (int, http.Header, appLogsDataResponse, string) { t.Helper() req, err := http.NewRequest(http.MethodGet, fx.u("/apps/titogo/logs/data?"+query), nil) if err != nil { t.Fatal(err) } res, err := c.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() b, err := io.ReadAll(res.Body) if err != nil { t.Fatal(err) } status, headers, body := res.StatusCode, res.Header, string(b) var out appLogsDataResponse if status == http.StatusOK { if err := json.Unmarshal([]byte(body), &out); err != nil { t.Fatalf("decode logs data: %v: %s", err, body) } } return status, headers, out, body } status, headers, out, body := read("cursor=" + url.QueryEscape(applog.EncodeCursor(recs[0]))) if status != http.StatusOK || len(out.Lines) != 1 || !strings.Contains(out.Lines[0].Msg, "wake failed") { t.Fatalf("cursor read = %d %+v: %s", status, out, body) } if headers.Get("Cache-Control") != "no-store" { t.Fatalf("logs data Cache-Control = %q", headers.Get("Cache-Control")) } last := applog.EncodeCursor(recs[1]) _, _, out, _ = read("cursor=" + url.QueryEscape(last)) if len(out.Lines) != 0 || out.Cursor != last || out.Gap { t.Fatalf("empty cursor read = %+v", out) } expired := applog.EncodeCursor(applog.Record{TS: now.Add(-2 * time.Hour), Box: "old", Seq: 1}) _, _, out, _ = read("since=1h&cursor=" + url.QueryEscape(expired)) if !out.Gap || len(out.Lines) != 0 { t.Fatalf("expired cursor did not report a gap: %+v", out) } _, _, out, _ = read("cursor=not-a-cursor") if !out.Gap { t.Fatalf("invalid cursor did not report a gap: %+v", out) } seedManyLogRecords(t, fx, now.Add(-2*time.Minute), appLogsPageCap+1) _, _, out, _ = read("limit=999") if len(out.Lines) != appLogsPageCap || !out.Truncated || out.Gap { t.Fatalf("bounded page = %d lines, truncated=%v gap=%v", len(out.Lines), out.Truncated, out.Gap) } for _, query := range []string{"since=bad", "level=loud"} { status, _, _, _ := read(query) if status != http.StatusBadRequest { t.Errorf("%s = %d, want 400", query, status) } } fx.srv.cfg.Releases = nil status, headers, _, _ = read("") if status != http.StatusServiceUnavailable || headers.Get("Cache-Control") != "no-store" { t.Fatalf("bucketless logs data = %d, Cache-Control %q", status, headers.Get("Cache-Control")) } } // TestAppLogsPage: the console tab renders the same timeline — filters as // GET params, member-gated like every app page, honest empty state. func TestAppLogsPage(t *testing.T) { fx, _ := instanceFixture(t, "code-logs-page") reg, err := registry.Open(fx.regPath) if err != nil { t.Fatal(err) } for _, rt := range []registry.Route{ {Host: "legacy.example.com", Kind: "instance", App: "titogo", Account: "bab"}, {Host: "foreign.example.com", Kind: "instance", App: "titogo", Account: "other"}, } { if err := reg.Upsert(rt); err != nil { t.Fatal(err) } } if err := reg.Close(); err != nil { t.Fatal(err) } now := time.Now().UTC() seedLogChunks(t, fx, now) fx.setUser("paul@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-logs-signin") status, _, body := get(t, c, fx.u("/apps/titogo/logs")) if status != http.StatusOK { t.Fatalf("logs page: %d %s", status, body) } for _, want := range []string{"listening on socket", "wake failed", "carlos logs --app titogo -f"} { if !strings.Contains(body, want) { t.Errorf("page missing %q", want) } } for _, want := range []string{"All instances", "Search logs", "All levels", "Follow live", "Pause"} { if !strings.Contains(body, want) { t.Errorf("page missing approved copy %q", want) } } if !strings.Contains(body, `option value="legacy.example.com"`) || strings.Contains(body, `option value="foreign.example.com"`) { t.Fatalf("instance filter did not include only the account's legacy routes") } // Stream filter narrows. status, _, body = get(t, c, fx.u("/apps/titogo/logs?stream=platform")) if status != http.StatusOK || strings.Contains(body, "listening on socket") || !strings.Contains(body, "wake failed") { t.Fatalf("stream filter page: %d", status) } // Instance, severity and text filters compose on the browser page too. status, _, body = get(t, c, fx.u("/apps/titogo/logs?host=titogo.example.com&level=error&text=wake")) if status != http.StatusOK || strings.Contains(body, "listening on socket") || !strings.Contains(body, "wake failed") { t.Fatalf("composed browser filters: %d", status) } if !strings.Contains(body, `option value="titogo.example.com" selected`) { t.Fatalf("browser host filter was not retained: %s", body) } status, _, body = get(t, c, fx.u("/apps/titogo/logs?host=deleted.example.com")) if status != http.StatusOK || strings.Contains(body, "wake failed") { t.Fatalf("historical browser host broadened: %d", status) } stamp := url.QueryEscape(now.Add(-30 * time.Minute).Format(time.RFC3339)) status, _, body = get(t, c, fx.u("/apps/titogo/logs?since="+stamp+"&grep=wake&follow=1")) if status != http.StatusOK || !strings.Contains(body, `selected>`+now.Add(-30*time.Minute).Format(time.RFC3339)+``) || !strings.Contains(body, `type="hidden" name="grep" value="wake"`) || !strings.Contains(body, `name="text" value=""`) || !strings.Contains(body, `setFollowing(true); poll();`) { t.Fatalf("non-preset/legacy/follow state was not retained = %d: %s", status, body) } status, _, body = get(t, c, fx.u("/apps/titogo/logs?cursor=hand-written")) if status != http.StatusOK || !strings.Contains(body, ``) { t.Fatalf("page cursor without since lost the bounded default window = %d: %s", status, body) } // Window with nothing in it: the honest empty state, still a 200. status, _, body = get(t, c, fx.u("/apps/titogo/logs?since=1ns")) if status != http.StatusOK || !strings.Contains(body, "No log lines in this window") { t.Fatalf("empty window page: %d", status) } } // must unwraps a key builder's (key, error) for fixtures whose names are // known good: mustKey(t)(ChunkKey(...)). func mustKey(t testing.TB) func(string, error) string { return func(k string, err error) string { t.Helper() if err != nil { t.Fatal(err) } return k } }