package console import ( "context" "encoding/json" "net/http" "strconv" "strings" "testing" "time" "github.com/carlosframework/platform/internal/apperr" "github.com/carlosframework/platform/internal/applog" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/svcrole" "github.com/carlosframework/platform/internal/telemetry" ) func errorsURL(fx *consoleFixture, acct, app, query string) string { u := fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/errors" if query != "" { u += "?" + query } return u } // seedErrors ships one coalesced edge error and, via seedLogChunks, one // error-level platform line and one warn-level app line — plus, for // Availability, a warn-level platform exit, two 502s filed before the // amendment (one row per path) and one filed after it (by cause). func seedErrors(t *testing.T, fx *consoleFixture, now time.Time) { t.Helper() seedLogChunks(t, fx, now) warn := []applog.Record{{TS: now.Add(-7 * time.Minute), Box: "box1", Host: "titogo.example.com", Stream: applog.StreamApp, Level: applog.LevelWarn, Msg: "slow query 4123ms", Seq: 150}, {TS: now.Add(-6 * time.Minute), Box: "box1", Host: "titogo.example.com", Stream: applog.StreamPlatform, Level: applog.LevelWarn, Msg: "instance exited: signal: killed", Seq: 151}, {TS: now.Add(-6 * time.Minute), Box: "box1", Host: "titogo.example.com", Stream: applog.StreamPlatform, Level: applog.LevelWarn, Msg: "dropped 12 app log line(s) (rate cap)", Seq: 152}} body, _ := applog.EncodeChunk(warn) if _, err := fx.rel.Put(context.Background(), mustKey(t)(applog.ChunkKey(release.OpsAccount, "titogo", "box1", applog.HourOf(warn[0].TS), 150, len(body))), body, ""); err != nil { t.Fatal(err) } e := []apperr.Record{{TS: now.Add(-3 * time.Minute), Box: "box1", Host: "titogo.example.com", Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 POST /checkout", Path: "/checkout", Status: 500, Release: "v3", FP: "edge500", FPV: 1, Count: 12, First: now.Add(-9 * time.Minute), Last: now.Add(-3 * time.Minute), Seq: 200}, {TS: now.Add(-8 * time.Minute), Box: "box1", Host: "titogo.example.com", Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "502 GET /.env", Path: "/.env", Status: 502, Release: "v3", FP: "old502env", FPV: 1, Count: 1, First: now.Add(-8 * time.Minute), Last: now.Add(-8 * time.Minute), Seq: 201}, {TS: now.Add(-8 * time.Minute), Box: "box1", Host: "titogo.example.com", Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "502 GET /phpinfo.php", Path: "/phpinfo.php", Status: 502, Release: "v3", FP: "old502php", FPV: 1, Count: 1, First: now.Add(-8 * time.Minute), Last: now.Add(-8 * time.Minute), Seq: 202}, {TS: now.Add(-2 * time.Minute), Box: "box1", Host: "titogo.example.com", Source: apperr.SourceEdge, Kind: apperr.KindUnavailable, Cause: apperr.CauseRefused, Name: apperr.AvailabilityName(502, apperr.CauseRefused), Path: "/admin", Status: 502, Release: "v4", FP: "new502", FPV: 1, Count: 5, First: now.Add(-4 * time.Minute), Last: now.Add(-2 * time.Minute), Seq: 203}} eb, _ := apperr.EncodeChunk(e) if _, err := fx.rel.Put(context.Background(), mustKey(t)(apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(e[0].TS), 200, len(eb))), eb, ""); err != nil { t.Fatal(err) } } func TestAPIErrorsGroupsBothFamilies(t *testing.T) { fx, token := instanceFixture(t, "code-errors-1") now := time.Now().UTC() seedErrors(t, fx, now) get := func(query string) errorsResponse { t.Helper() status, _, body := rawReq(t, http.MethodGet, errorsURL(fx, fx.sqid, "titogo", query), token, "") if status != http.StatusOK { t.Fatalf("GET ?%s = %d: %s", query, status, body) } var resp errorsResponse if err := json.Unmarshal([]byte(body), &resp); err != nil { t.Fatal(err) } return resp } // The 502s and the platform events are Availability's, not Errors'. resp := get("") if len(resp.Groups) != 1 { t.Fatalf("groups = %+v", resp.Groups) } if resp.Groups[0].FP != "edge500" || resp.Groups[0].Count != 12 || resp.Groups[0].Releases[0] != "v3" { t.Fatalf("newest first, coalesced count: %+v", resp.Groups[0]) } if resp.LogLevel != "error" || len(resp.Sources) != 1 { t.Fatalf("meta = %q %v", resp.LogLevel, resp.Sources) } // A warn read widens without changing the setting. if resp = get("level=warn"); len(resp.Groups) != 2 { t.Fatalf("level=warn groups = %d", len(resp.Groups)) } if resp.Groups[1].Source != apperr.SourceLog || !strings.Contains(resp.Groups[1].Name, "slow query") { t.Fatalf("log-derived group: %+v", resp.Groups[1]) } // Source and fp filters. if resp = get("source=edge"); len(resp.Groups) != 1 || resp.Groups[0].FP != "edge500" { t.Fatalf("source filter = %+v", resp.Groups) } if resp = get("fp=edge500"); len(resp.Records) != 1 || resp.Records[0].Status != 500 { t.Fatalf("fp expand = %+v", resp.Records) } } // TestAPIErrorsRefusals mirrors TestAPILogsRefusals (apilogs_test.go): a // non-member's read is the family's byte-identical 404; bad parameters // name themselves in a 400. func TestAPIErrorsRefusals(t *testing.T) { fx, _ := instanceFixture(t, "code-errs-refusal") eve := cliToken(t, fx, "eve@keymail.dev", "code-errs-eve") want := canonicalNotFound(t) status, h, body := rawReq(t, http.MethodGet, errorsURL(fx, fx.sqid, "titogo", ""), eve, "") assertNotFound(t, "errors as non-member", want, status, h, body) token := cliToken(t, fx, "alice@keymail.dev", "code-errs-token") for _, q := range []string{"since=nonsense", "source=mars", "fp=has space", "level=loud"} { status, _, body := rawReq(t, http.MethodGet, errorsURL(fx, fx.sqid, "titogo", q), token, "") if status != http.StatusBadRequest { t.Errorf("?%s = %d, want 400 (%s)", q, status, body) } } } func TestAPITelemetryPutSetsLogLevel(t *testing.T) { fx, token := instanceFixture(t, "code-errors-2") now := time.Now().UTC() seedErrors(t, fx, now) // The instance token may read but not write settings: Operate only. inst := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) status, _, body := rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", inst, `{"errors":{"log_level":"warn"}}`) if status != http.StatusForbidden && status != http.StatusNotFound { t.Fatalf("instance token PUT = %d %s", status, body) } // A PERSON must be an owner: the browser form for this setting 404s a // member, so the API must not be the way around it. alice (the // fixture's token) is a member. status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", token, `{"errors":{"log_level":"warn"}}`) if status != http.StatusForbidden { t.Fatalf("member PUT = %d %s", status, body) } if r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo"); r.LogLevel != "error" { t.Fatalf("a refused PUT wrote anyway: %+v", r) } // paul owns the ops account in the fixture. owner := cliToken(t, fx, "paul@keymail.dev", "code-errors-owner") status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", owner, `{"errors":{"log_level":"error"}}`) if status != http.StatusOK { t.Fatalf("owner PUT = %d %s", status, body) } op := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate) status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, `{"errors":{"log_level":"warn"}}`) if status != http.StatusOK { t.Fatalf("PUT = %d %s", status, body) } r, _ := telemetry.Resolve(context.Background(), fx.rel, fx.sqid, "titogo") if r.LogLevel != "warn" || r.LogLevelFrom != "app" { t.Fatalf("resolved = %+v", r) } status, _, body = rawReq(t, http.MethodGet, errorsURL(fx, fx.sqid, "titogo", ""), token, "") var resp errorsResponse json.Unmarshal([]byte(body), &resp) if status != 200 || resp.LogLevel != "warn" || len(resp.Groups) != 2 { t.Fatalf("after set: %d level=%q groups=%d", status, resp.LogLevel, len(resp.Groups)) } status, _, body = rawReq(t, http.MethodPut, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/titogo/telemetry", op, `{"errors":{"log_level":"loud"}}`) if status != http.StatusBadRequest { t.Fatalf("bad level = %d %s", status, body) } } // TestAppErrorsPageCaps: the tab renders a page's worth of both of its // shapes — appErrorsPageCap groups in the table, appErrorsPageCap // occurrences in an expanded group — and says so. readErrors collects up to // logsCollectCap records, so without the cap a busy app's tab is a render // nobody asked for. func TestAppErrorsPageCaps(t *testing.T) { fx, _ := instanceFixture(t, "code-errors-cap") now := time.Now().UTC() var recs []apperr.Record for i := 0; i < appErrorsPageCap+50; i++ { ts := now.Add(-10 * time.Minute).Add(time.Duration(i) * time.Second) recs = append(recs, // One group each: the table's cap. apperr.Record{TS: ts, Box: "box1", Host: "titogo.example.com", Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 GET /bulk" + strconv.Itoa(i), Path: "/bulk" + strconv.Itoa(i), Status: 500, FP: "bulk" + strconv.Itoa(i), FPV: 1, Count: 1, First: ts, Last: ts, Seq: uint64(1000 + i)}, // One group of many: the expanded view's cap. apperr.Record{TS: ts, Box: "box1", Host: "titogo.example.com", Source: apperr.SourceEdge, Kind: apperr.KindHTTP, Name: "500 POST /one", Path: "/one", Status: 500, FP: "bulkone", FPV: 1, Count: 1, First: ts, Last: ts, Seq: uint64(9000 + i)}) } body, err := apperr.EncodeChunk(recs) if err != nil { t.Fatal(err) } if _, err := fx.rel.Put(context.Background(), mustKey(t)(apperr.ChunkKey(release.OpsAccount, "titogo", "box1", apperr.HourOf(recs[0].TS), 1000, len(body))), body, ""); err != nil { t.Fatal(err) } fx.setUser("paul@keymail.dev") c := newBrowser(t) signUp(t, fx, c, "code-errors-cap-signin") status, _, page := get(t, c, fx.u("/apps/titogo/errors")) if status != http.StatusOK { t.Fatalf("errors page: %d", status) } if n := strings.Count(page, "fp=bulk"); n != appErrorsPageCap { t.Errorf("group rows = %d, want %d", n, appErrorsPageCap) } if !strings.Contains(page, "groups of a larger window") { t.Error("a capped table must say it was capped") } status, _, page = get(t, c, fx.u("/apps/titogo/errors?fp=bulkone")) if status != http.StatusOK { t.Fatalf("expanded page: %d", status) } if n := strings.Count(page, "