package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/errlog"
"craftdesign.group/quarry/internal/obs"
"craftdesign.group/quarry/internal/ops"
)
type slowAPIIn struct{}
type slowAPIOut struct {
OK bool `json:"ok"`
}
// An op called over /api/v1 was never timed: the slow-op log and the
// ops history lived in the web's page helper, so a slow ticket.set from
// an agent or the runner was invisible on /fleet/ops. It is filed now,
// beside the web renders, tagged with the surface it came in on.
func TestASlowAPICallReachesTheOpsHistoryAndTheSlowOpLog(t *testing.T) {
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{})}); err != nil {
t.Fatal(err)
}
// A wildcard so every run has its own URL: the recorder is the
// process-wide one, and a repeated run must not count the last.
const route = "GET /api/v1/test/slow-op-262/{run}"
url := fmt.Sprintf("/api/v1/test/slow-op-262/%d", time.Now().UnixNano())
if err := ops.Register(r, ops.Op[slowAPIIn, slowAPIOut]{
Name: "test.slow262", Doc: "sleeps past the slow-op threshold",
Kind: ops.Query, Auth: ops.Anonymous, Route: route,
Run: func(context.Context, slowAPIIn) (slowAPIOut, error) {
time.Sleep(300 * time.Millisecond)
return slowAPIOut{OK: true}, nil
},
}); err != nil {
t.Fatal(err)
}
h, err := Router(r, "v")
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
prev := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(prev) })
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", url, nil))
if w.Code != http.StatusOK {
t.Fatalf("slow op answered %d: %s", w.Code, w.Body)
}
var slow []string
for _, l := range strings.Split(buf.String(), "\n") {
if strings.Contains(l, "slow op: test.slow262 ") {
slow = append(slow, l)
}
}
if len(slow) != 1 {
t.Fatalf("%d slow-op lines for the API call, want 1:\n%s", len(slow), buf.String())
}
if !strings.Contains(slow[0], "surface=api") {
t.Errorf("the slow-op line does not say it came over the API: %q", slow[0])
}
// The ops history is read the way the page reads it: through
// ops.status, over the API.
w = httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("GET", "/api/v1/ops?limit=200", nil))
if w.Code != http.StatusOK {
t.Fatalf("ops.status answered %d: %s", w.Code, w.Body)
}
var out struct {
Renders []map[string]any `json:"renders"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
var rows []map[string]any
for _, row := range out.Renders {
if row["path"] == route && row["url"] == url {
rows = append(rows, row)
}
}
if len(rows) != 1 {
t.Fatalf("%d history rows for the API call, want 1: %v", len(rows), out.Renders)
}
row := rows[0]
if row["surface"] != "api" {
t.Errorf("surface = %v, want api", row["surface"])
}
if ms, _ := row["opsMs"].(float64); ms < 250 {
t.Errorf("opsMs = %v, want the op's own time", row["opsMs"])
}
if st, _ := row["status"].(float64); st != http.StatusOK {
t.Errorf("status = %v, want 200", row["status"])
}
}
// The recent view can be narrowed to one surface; empty keeps both.
func TestOpsStatusNarrowsRecentRendersBySurface(t *testing.T) {
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{})}); err != nil {
t.Fatal(err)
}
now := time.Now()
obs.Default.RecordRender(obs.Render{At: now, Path: "GET /surface-filter-web", Surface: obs.SurfaceWeb})
obs.Default.RecordRender(obs.Render{At: now, Path: "GET /api/v1/surface-filter-api", Surface: obs.SurfaceAPI})
for _, tc := range []struct {
surface string
web, api bool
}{{"", true, true}, {"web", true, false}, {"api", false, true}} {
got, err := r.Invoke(ops.WithUser(context.Background(), "dan"), "ops.status",
opsStatusIn{Limit: 200, Surface: tc.surface})
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, row := range got.(opsStatusOut).Renders {
if tc.surface != "" && row.Surface != tc.surface {
t.Errorf("surface=%q returned a %s row", tc.surface, row.Surface)
}
seen[row.Path] = true
}
if seen["GET /surface-filter-web"] != tc.web || seen["GET /api/v1/surface-filter-api"] != tc.api {
t.Errorf("surface=%q: web row %v api row %v, want %v %v", tc.surface,
seen["GET /surface-filter-web"], seen["GET /api/v1/surface-filter-api"], tc.web, tc.api)
}
}
}
// The ops panels show what was rendered, and a requested path names a
// private repository's wiki pages, check-ins and searches. For a
// repository the viewing admin cannot read, a row keeps its route and
// its timings and loses the path (#318).
func TestTheOpsPanelsHideAPrivateRepositorysPaths(t *testing.T) {
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{}),
CanRead: func(ctx context.Context, repo string) bool { return repo != "vault318" },
}); err != nil {
t.Fatal(err)
}
const route = "GET /r/{repo}/wiki318/{name}"
now := time.Now()
obs.Default.RecordRender(obs.Render{At: now, Path: route, URL: "/r/vault318/wiki318/Secret-Plan", Repo: "vault318", Surface: obs.SurfaceWeb})
obs.Default.RecordRender(obs.Render{At: now, Path: route, URL: "/r/alpha318/wiki318/Open-Plan", Repo: "alpha318", Surface: obs.SurfaceWeb})
ctx := ops.WithUser(context.Background(), "root")
urls := map[string]string{}
got, err := r.Invoke(ctx, "ops.route", opsRouteIn{Path: route, Limit: 20})
if err != nil {
t.Fatal(err)
}
for _, row := range got.(opsRouteOut).Renders {
urls["route:"+row.Repo] = row.URL
}
st, err := r.Invoke(ctx, "ops.status", opsStatusIn{Limit: 200})
if err != nil {
t.Fatal(err)
}
for _, row := range st.(opsStatusOut).Renders {
if row.Path == route {
urls["status:"+row.Repo] = row.URL
}
}
for _, panel := range []string{"route", "status"} {
if u := urls[panel+":vault318"]; strings.Contains(u, "Secret") || u == "" {
t.Errorf("FAIL: ops.%s shows %q for a repository the admin cannot read", panel, u)
}
if u := urls[panel+":alpha318"]; !strings.Contains(u, "Open-Plan") {
t.Errorf("FAIL: ops.%s lost a readable repository's path: %q", panel, u)
}
}
}
// The exceptions journal is shown to site admins, and an entry from a
// repository they cannot read carries its page and its words. fleet.status
// gives them the route and the kind instead (#318).
func TestTheExceptionsJournalHidesAPrivateRepositorysWords(t *testing.T) {
ring := errlog.New(50)
ring.ReportIn("web", "/r/vault/wiki/Secret-Plan", "vault", "", errors.New("render Secret-Plan failed"))
ring.ReportIn("web", "/r/alpha/wiki/Open-Plan", "alpha", "", errors.New("render Open-Plan failed"))
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Repos: NewRepos(map[string]*Repo{}), Errors: ring,
CanRead: func(ctx context.Context, repo string) bool { return repo != "vault" },
}); err != nil {
t.Fatal(err)
}
got, err := r.Invoke(ops.WithUser(context.Background(), "root"), "fleet.status", fleetStatusIn{})
if err != nil {
t.Fatal(err)
}
seen := map[string]errlog.Entry{}
for _, e := range got.(fleetStatusOut).Errors {
seen[e.Repo] = e
}
if v := seen["vault"]; strings.Contains(v.Message+v.Path, "Secret") || v.Count != 1 {
t.Errorf("FAIL: fleet.status shows a private repository's entry as %+v", v)
}
if a := seen["alpha"]; !strings.Contains(a.Path, "Open-Plan") || !strings.Contains(a.Message, "Open-Plan") {
t.Errorf("FAIL: a readable repository's entry lost its words: %+v", a)
}
}