An HttpOnly, SameSite=Lax session cookie is issued when a job is created. Job and snapshot routes return 404 to any other session, so indexed source is only readable by the browser that loaded it.
289 lines
9.5 KiB
Go
289 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestDeliveryRangesMergeBeforeSourceIsReleased(t *testing.T) {
|
|
var ranges [][2]int
|
|
var done bool
|
|
for _, step := range []struct {
|
|
start, end int
|
|
want bool
|
|
}{{600, 1000, false}, {0, 400, false}, {350, 550, false}, {500, 700, true}} {
|
|
if ranges, done = recordDelivery(ranges, step.start, step.end, 1000); done != step.want {
|
|
t.Fatalf("recordDelivery(%d,%d) = %v, want %v", step.start, step.end, done, step.want)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(ranges, [][2]int{{0, 1000}}) {
|
|
t.Fatalf("ranges = %v", ranges)
|
|
}
|
|
}
|
|
|
|
func TestEmptyOrDuplicateDeliveryDoesNotFakeCoverage(t *testing.T) {
|
|
var ranges [][2]int
|
|
var done bool
|
|
for _, end := range []int{0, 5, 5} {
|
|
if ranges, done = recordDelivery(ranges, 0, end, 10); done {
|
|
t.Fatalf("coverage reported after [0,%d)", end)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(ranges, [][2]int{{0, 5}}) {
|
|
t.Fatalf("ranges = %v", ranges)
|
|
}
|
|
}
|
|
|
|
func TestGithubURLValidation(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"https://github.com/makepad/makepad": "https://github.com/makepad/makepad",
|
|
" https://github.com/a/b.git/ ": "https://github.com/a/b",
|
|
"https://example.com/a/b": "",
|
|
"https://github.com/a/b/tree/main": "",
|
|
"https://github.com/a/b;rm -rf": "",
|
|
"https://github.com/-/--upload-pack=": "",
|
|
} {
|
|
if got := cleanGithubURL(in); got != want {
|
|
t.Errorf("cleanGithubURL(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPathValidation(t *testing.T) {
|
|
for in, ok := range map[string]bool{"src/main.rs": true, "a/./b": true, "../secret": false, "a/../../b": false, "/etc/passwd": false, "./a": false, "": false, ".": false} {
|
|
if _, got := safeRelativePath(in); got != ok {
|
|
t.Errorf("safeRelativePath(%q) ok = %v, want %v", in, got, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
type client struct {
|
|
t *testing.T
|
|
url string
|
|
http *http.Client
|
|
}
|
|
|
|
// stranger is a different browser session against the same server.
|
|
func (c client) stranger() client {
|
|
jar, _ := cookiejar.New(nil)
|
|
return client{c.t, c.url, &http.Client{Jar: jar}}
|
|
}
|
|
|
|
func (c client) do(method, path, body string) (int, []byte) {
|
|
c.t.Helper()
|
|
req, _ := http.NewRequest(method, c.url+path, strings.NewReader(body))
|
|
res, err := c.http.Do(req)
|
|
if err != nil {
|
|
c.t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
data, _ := io.ReadAll(res.Body)
|
|
return res.StatusCode, data
|
|
}
|
|
|
|
func (c client) json(method, path, body string, wantStatus int, out any) {
|
|
c.t.Helper()
|
|
status, data := c.do(method, path, body)
|
|
if status != wantStatus {
|
|
c.t.Fatalf("%s %s = %d %s, want %d", method, path, status, data, wantStatus)
|
|
}
|
|
if out != nil {
|
|
if err := json.Unmarshal(data, out); err != nil {
|
|
c.t.Fatalf("%s %s: %v in %s", method, path, err, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newTestServer(t *testing.T) (*Server, client) {
|
|
server := NewServer(filepath.Join(t.TempDir(), "codenavigator"))
|
|
ts := httptest.NewServer(server)
|
|
t.Cleanup(ts.Close)
|
|
return server, client{t, ts.URL, nil}.stranger()
|
|
}
|
|
|
|
func TestLocalJobLifecycle(t *testing.T) {
|
|
server, c := newTestServer(t)
|
|
var created struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local","name":"demo"}`, 201, &created)
|
|
|
|
sources := map[string]string{
|
|
"src/app.js": "import { helper } from './helper.js';\nfunction start() {\n helper();\n}\n",
|
|
"src/helper.js": "export function helper() {\n return '<ok>';\n}\n",
|
|
}
|
|
for path, content := range sources {
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/files?path="+strings.ReplaceAll(path, "/", "%2F"), content, 200, nil)
|
|
}
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/files?path=..%2Fescape.js", "x", 400, nil)
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, nil)
|
|
|
|
res, err := c.http.Get(c.url + "/api/jobs/" + created.JobID + "/events")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ct := res.Header.Get("Content-Type"); ct != "text/event-stream" {
|
|
t.Fatalf("events content type = %q", ct)
|
|
}
|
|
var job Job
|
|
scanner := bufio.NewScanner(res.Body)
|
|
for scanner.Scan() {
|
|
if data, ok := strings.CutPrefix(scanner.Text(), "data: "); ok {
|
|
job = Job{}
|
|
json.Unmarshal([]byte(data), &job)
|
|
}
|
|
}
|
|
res.Body.Close()
|
|
if job.SnapshotID == nil || job.Phase != "ready" {
|
|
t.Fatalf("final job event = %+v", job)
|
|
}
|
|
snapshotURL := "/api/snapshots/" + *job.SnapshotID
|
|
|
|
other := c.stranger()
|
|
other.json("GET", "/api/jobs/"+created.JobID, "", 404, nil)
|
|
other.json("POST", "/api/jobs/"+created.JobID+"/files?path=evil.js", "x", 404, nil)
|
|
other.json("GET", snapshotURL+"/scene", "", 404, nil)
|
|
other.json("GET", snapshotURL+"/entities/1/source", "", 404, nil)
|
|
|
|
var summary map[string]any
|
|
c.json("GET", snapshotURL, "", 200, &summary)
|
|
if summary["fileCount"] != 2.0 || summary["references"] != 1.0 || summary["name"] != "demo" {
|
|
t.Fatalf("summary = %v", summary)
|
|
}
|
|
|
|
status, raw := c.do("GET", snapshotURL+"/scene", "")
|
|
if status != 200 || !strings.Contains(string(raw), `'<ok>'`) {
|
|
t.Fatalf("scene = %d %s (previews must not be HTML-escaped)", status, raw)
|
|
}
|
|
var scene struct {
|
|
Files []sceneFile
|
|
Edges []Edge
|
|
}
|
|
json.Unmarshal(raw, &scene)
|
|
if len(scene.Files) != 2 || scene.Files[0].Path != "src/app.js" || scene.Files[0].SymbolCount != 1 || len(scene.Edges) != 1 {
|
|
t.Fatalf("scene = %+v", scene)
|
|
}
|
|
if server.snapshots[*job.SnapshotID].Files[0].Preview != "" {
|
|
t.Fatal("previews should be released after the scene is delivered")
|
|
}
|
|
|
|
var hits []searchHit
|
|
c.json("GET", snapshotURL+"/search?q=HELP", "", 200, &hits)
|
|
if len(hits) != 2 || hits[0].Kind != "file" || hits[1].Name != "helper" || hits[1].Line != 1 {
|
|
t.Fatalf("hits = %+v", hits)
|
|
}
|
|
c.json("GET", snapshotURL+"/search?q=h", "", 200, &hits)
|
|
if len(hits) != 0 {
|
|
t.Fatalf("single-character search returned %v", hits)
|
|
}
|
|
|
|
var entity FileRecord
|
|
c.json("GET", snapshotURL+"/entities/1", "", 200, &entity)
|
|
if entity.Path != "src/app.js" || entity.Imports == nil || len(entity.Symbols) != 1 {
|
|
t.Fatalf("entity = %+v", entity)
|
|
}
|
|
c.json("GET", snapshotURL+"/entities/99", "", 404, nil)
|
|
c.json("GET", snapshotURL+"/entities/1/nope", "", 404, nil)
|
|
|
|
root := server.snapshots[*job.SnapshotID].Root
|
|
var chunk struct {
|
|
Start, TotalLines int
|
|
Lines []string
|
|
}
|
|
c.json("GET", snapshotURL+"/entities/1/source?start=2&limit=5", "", 200, &chunk)
|
|
if chunk.Start != 2 || chunk.TotalLines != 4 || !reflect.DeepEqual(chunk.Lines, []string{" helper();", "}"}) {
|
|
t.Fatalf("chunk = %+v", chunk)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(root, "src/app.js")); err != nil {
|
|
t.Fatal("partially delivered source was released early")
|
|
}
|
|
c.json("GET", snapshotURL+"/entities/1/source?start=0&limit=2", "", 200, &chunk)
|
|
if _, err := os.Stat(filepath.Join(root, "src/app.js")); !os.IsNotExist(err) {
|
|
t.Fatal("fully delivered source should be deleted")
|
|
}
|
|
c.json("GET", snapshotURL+"/entities/2/source", "", 200, &chunk)
|
|
if _, err := os.Stat(filepath.Dir(root)); !os.IsNotExist(err) {
|
|
t.Fatal("workspace should be removed once every source is delivered")
|
|
}
|
|
c.json("GET", snapshotURL+"/entities/2/source", "", 404, nil)
|
|
}
|
|
|
|
func TestRoutesAndHeaders(t *testing.T) {
|
|
_, c := newTestServer(t)
|
|
var health map[string]any
|
|
c.json("GET", "/api/health", "", 200, &health)
|
|
if health["ok"] != true {
|
|
t.Fatalf("health = %v", health)
|
|
}
|
|
c.json("POST", "/api/jobs", `not json`, 400, nil)
|
|
c.json("POST", "/api/jobs", `{"kind":"github"}`, 400, nil)
|
|
c.json("POST", "/api/jobs", `{"kind":"github","url":"https://evil.example/a/b"}`, 400, nil)
|
|
c.json("GET", "/api/jobs/missing", "", 404, nil)
|
|
c.json("POST", "/api/jobs/missing/commit", "", 404, nil)
|
|
c.json("GET", "/api/snapshots/missing/scene", "", 404, nil)
|
|
c.json("GET", "/api/unknown", "", 404, nil)
|
|
|
|
res, err := http.DefaultClient.Do(must(http.NewRequest("OPTIONS", c.url+"/api/jobs", nil)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.StatusCode != 204 || res.Header.Get("Access-Control-Allow-Origin") != "*" || !strings.Contains(res.Header.Get("Access-Control-Allow-Headers"), "ngrok-skip-browser-warning") {
|
|
t.Fatalf("preflight = %d %v", res.StatusCode, res.Header)
|
|
}
|
|
|
|
for path, contentType := range map[string]string{"/": "text/html", "/api.mjs": "text/javascript", "/styles.css": "text/css", "/config.js": "text/javascript"} {
|
|
res, err := http.Get(c.url + path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
if res.StatusCode != 200 || !strings.HasPrefix(res.Header.Get("Content-Type"), contentType) {
|
|
t.Errorf("GET %s = %d %s", path, res.StatusCode, res.Header.Get("Content-Type"))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFailedIndexReportsErrorAndCleansUp(t *testing.T) {
|
|
server, c := newTestServer(t)
|
|
var created struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &created)
|
|
server.indexJob(created.JobID, filepath.Join(server.workspace, created.JobID, "files"))
|
|
var job Job
|
|
c.json("GET", "/api/jobs/"+created.JobID, "", 200, &job)
|
|
if job.Phase != "error" || job.Error == nil || job.Name != "Local codebase" {
|
|
t.Fatalf("job = %+v", job)
|
|
}
|
|
}
|
|
|
|
func must[T any](v T, err error) T {
|
|
if err != nil {
|
|
panic(fmt.Sprint(err))
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestSessionCookieFlags(t *testing.T) {
|
|
_, c := newTestServer(t)
|
|
req, _ := http.NewRequest("POST", c.url+"/api/jobs", strings.NewReader(`{"kind":"local"}`))
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
cookie := res.Header.Get("Set-Cookie")
|
|
for _, want := range []string{sessionCookie + "=", "HttpOnly", "Secure", "SameSite=Lax", "Path=/"} {
|
|
if !strings.Contains(cookie, want) {
|
|
t.Errorf("Set-Cookie %q missing %q", cookie, want)
|
|
}
|
|
}
|
|
}
|