// job_download_test.go — covers GET /api/jobs/{id}/log.{txt,ndjson}. package http import ( "context" "encoding/json" stdhttp "net/http" "strings" "testing" "time" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) // seedJobWithLogs creates a job + a few log lines for it. Returns the // job ID. Caller is responsible for the test server + auth. func seedJobWithLogs(t *testing.T, st *store.Store, hostID string, lineCount int) string { t.Helper() jobID := ulid.Make().String() now := time.Now().UTC() if err := st.CreateJob(context.Background(), store.Job{ ID: jobID, HostID: hostID, Kind: "diff", ActorKind: "user", CreatedAt: now, }); err != nil { t.Fatalf("create job: %v", err) } if err := st.MarkJobStarted(context.Background(), jobID, now); err != nil { t.Fatalf("mark started: %v", err) } for i := 0; i < lineCount; i++ { stream := "stdout" if i%5 == 0 { stream = "stderr" } payload := `{"message_type":"change","path":"/etc/file` + ulid.Make().String()[:6] + `","modifier":"M"}` if err := st.AppendJobLog(context.Background(), jobID, int64(i+1), now.Add(time.Duration(i)*time.Millisecond), stream, payload); err != nil { t.Fatalf("append log: %v", err) } } if err := st.MarkJobFinished(context.Background(), jobID, "succeeded", 0, nil, "", now); err != nil { t.Fatalf("mark finished: %v", err) } return jobID } // TestJobLogDownloadTxt: plain-text format includes a header + one // line per log row in the expected shape. func TestJobLogDownloadTxt(t *testing.T) { t.Parallel() srv, ts, st := rawTestServer(t) hostID, _ := enrolHostForWS(t, srv, st, "dl-txt-host") jobID := seedJobWithLogs(t, st, hostID, 12) cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/jobs/"+jobID+"/log.txt", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusOK { t.Fatalf("status: got %d, want 200", res.StatusCode) } if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { t.Errorf("content-type: got %q", ct) } if cd := res.Header.Get("Content-Disposition"); !strings.Contains(cd, ".txt") { t.Errorf("content-disposition: got %q", cd) } body := readBody(t, res.Body) // Header lines. if !strings.HasPrefix(body, "# job ") { t.Errorf("expected '# job ...' header line; got %q", short(body)) } if !strings.Contains(body, "12 log lines") { t.Errorf("expected '12 log lines'; got %q", short(body)) } // One body line per log row — count non-comment, non-empty lines. var rows int for _, line := range strings.Split(body, "\n") { l := strings.TrimSpace(line) if l == "" || strings.HasPrefix(l, "#") { continue } rows++ } if rows != 12 { t.Errorf("expected 12 body rows, got %d", rows) } // Tag check: at least one ERR row (every 5th was stderr). if !strings.Contains(body, " ERR ") { t.Errorf("expected at least one ERR row") } } // TestJobLogDownloadNDJSON: each line is a self-contained JSON object. func TestJobLogDownloadNDJSON(t *testing.T) { t.Parallel() srv, ts, st := rawTestServer(t) hostID, _ := enrolHostForWS(t, srv, st, "dl-ndjson-host") jobID := seedJobWithLogs(t, st, hostID, 5) cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/jobs/"+jobID+"/log.ndjson", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusOK { t.Fatalf("status: got %d, want 200", res.StatusCode) } if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-ndjson") { t.Errorf("content-type: got %q", ct) } body := readBody(t, res.Body) // Each non-empty line should parse as an object with seq/ts/stream/payload. var seen int for _, line := range strings.Split(body, "\n") { if strings.TrimSpace(line) == "" { continue } var obj struct { Seq int64 `json:"seq"` TS string `json:"ts"` Stream string `json:"stream"` Payload string `json:"payload"` } if err := json.Unmarshal([]byte(line), &obj); err != nil { t.Fatalf("parse line %q: %v", line, err) } if obj.Seq == 0 || obj.TS == "" || obj.Stream == "" || obj.Payload == "" { t.Errorf("incomplete object: %+v", obj) } seen++ } if seen != 5 { t.Errorf("parsed %d objects, want 5", seen) } } // TestJobLogDownloadNotFound: 404 for an unknown job id. func TestJobLogDownloadNotFound(t *testing.T) { t.Parallel() _, ts, st := rawTestServer(t) cookie := loginAsAdmin(t, st) req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/jobs/"+ulid.Make().String()+"/log.txt", nil) req.AddCookie(cookie) res, err := stdhttp.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusNotFound { t.Fatalf("status: got %d, want 404", res.StatusCode) } } // TestJobLogDownloadUnauthenticated: without a session cookie, 401. func TestJobLogDownloadUnauthenticated(t *testing.T) { t.Parallel() _, ts, _ := rawTestServer(t) res, err := stdhttp.Get(ts.URL + "/api/jobs/x/log.txt") if err != nil { t.Fatalf("do: %v", err) } defer res.Body.Close() if res.StatusCode != stdhttp.StatusUnauthorized { t.Fatalf("status: got %d, want 401", res.StatusCode) } }