Add SPDX headers to the source, a License section to the README, the OFL notice for the bundled fonts, and ship LICENSE in the Docker image.
466 lines
15 KiB
Go
466 lines
15 KiB
Go
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (C) 2026 Andy Dixon
|
|
|
|
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
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"))
|
|
server.showLocal = true // most tests index local folders
|
|
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", "/fonts/archivo-black.woff2": "font/woff2"} {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// indexLocal uploads files as a local job and waits for the snapshot.
|
|
func (c client) indexLocal(files map[string]string) (jobID, snapshotID string) {
|
|
c.t.Helper()
|
|
var created struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &created)
|
|
for path, content := range files {
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/files?path="+path, content, 200, nil)
|
|
}
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, nil)
|
|
status, raw := c.do("GET", "/api/jobs/"+created.JobID+"/events", "")
|
|
var job Job
|
|
for line := range strings.Lines(string(raw)) {
|
|
if data, ok := strings.CutPrefix(line, "data: "); ok {
|
|
json.Unmarshal([]byte(data), &job)
|
|
}
|
|
}
|
|
if status != 200 || job.SnapshotID == nil {
|
|
c.t.Fatalf("job did not finish: %d %+v", status, job)
|
|
}
|
|
return created.JobID, *job.SnapshotID
|
|
}
|
|
|
|
func TestSessionsKeepIndependentSnapshots(t *testing.T) {
|
|
server, alice := newTestServer(t)
|
|
bob := alice.stranger()
|
|
_, aliceFirst := alice.indexLocal(map[string]string{"a.go": "package a"})
|
|
_, bobSnapshot := bob.indexLocal(map[string]string{"b.go": "package b"})
|
|
_, aliceSecond := alice.indexLocal(map[string]string{"c.go": "package c"})
|
|
|
|
alice.json("GET", "/api/snapshots/"+aliceFirst, "", 404, nil)
|
|
alice.json("GET", "/api/snapshots/"+aliceSecond, "", 200, nil)
|
|
bob.json("GET", "/api/snapshots/"+bobSnapshot, "", 200, nil)
|
|
if len(server.snapshots) != 2 {
|
|
t.Fatalf("snapshots = %d, want 2", len(server.snapshots))
|
|
}
|
|
}
|
|
|
|
func TestExpireDropsIdleSnapshotsAndAbandonedUploads(t *testing.T) {
|
|
server, c := newTestServer(t)
|
|
_, snapshot := c.indexLocal(map[string]string{"a.go": "package a"})
|
|
var pending struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &pending)
|
|
c.json("POST", "/api/jobs/"+pending.JobID+"/files?path=b.go", "package b", 200, nil)
|
|
snapshotRoot := server.snapshots[snapshot].Root
|
|
pendingRoot := server.jobs[pending.JobID].root
|
|
|
|
server.expire(time.Now())
|
|
c.json("GET", "/api/snapshots/"+snapshot, "", 200, nil)
|
|
|
|
server.expire(time.Now().Add(idleExpiry + time.Minute))
|
|
c.json("GET", "/api/snapshots/"+snapshot, "", 404, nil)
|
|
c.json("GET", "/api/jobs/"+pending.JobID, "", 404, nil)
|
|
for _, root := range []string{snapshotRoot, pendingRoot} {
|
|
if _, err := os.Stat(root); !os.IsNotExist(err) {
|
|
t.Errorf("workspace %s should be deleted", root)
|
|
}
|
|
}
|
|
if len(server.jobs) != 0 {
|
|
t.Errorf("jobs left: %d", len(server.jobs))
|
|
}
|
|
}
|
|
|
|
func TestSnapshotCapEvictsLeastRecentlyUsed(t *testing.T) {
|
|
server, first := newTestServer(t)
|
|
_, oldest := first.indexLocal(map[string]string{"a.go": "package a"})
|
|
for i := range maxSnapshots {
|
|
first.stranger().indexLocal(map[string]string{"a.go": fmt.Sprint("package p", i)})
|
|
}
|
|
if len(server.snapshots) != maxSnapshots {
|
|
t.Fatalf("snapshots = %d, want %d", len(server.snapshots), maxSnapshots)
|
|
}
|
|
first.json("GET", "/api/snapshots/"+oldest, "", 404, nil)
|
|
}
|
|
|
|
func TestIndexingQueuesBeyondConcurrencyLimit(t *testing.T) {
|
|
server, c := newTestServer(t)
|
|
for range maxIndexing {
|
|
server.indexSlots <- struct{}{}
|
|
}
|
|
var created struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &created)
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/files?path=a.go", "package a", 200, nil)
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/commit", "", 202, nil)
|
|
var job Job
|
|
for range 100 {
|
|
if c.json("GET", "/api/jobs/"+created.JobID, "", 200, &job); job.Phase == "queued" {
|
|
break
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if job.Phase != "queued" {
|
|
t.Fatalf("phase = %q, want queued", job.Phase)
|
|
}
|
|
<-server.indexSlots
|
|
for range 200 {
|
|
if c.json("GET", "/api/jobs/"+created.JobID, "", 200, &job); job.SnapshotID != nil {
|
|
return
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
t.Fatalf("job never finished after a slot was freed: %+v", job)
|
|
}
|
|
|
|
func TestProgressStreamSendsKeepAlivesWhileQueued(t *testing.T) {
|
|
defer func(old time.Duration) { sseKeepAlive = old }(sseKeepAlive)
|
|
sseKeepAlive = 300 * time.Millisecond
|
|
server, c := newTestServer(t)
|
|
for range maxIndexing {
|
|
server.indexSlots <- struct{}{}
|
|
}
|
|
var created struct{ JobID string }
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 201, &created)
|
|
c.json("POST", "/api/jobs/"+created.JobID+"/files?path=a.go", "package a", 200, 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)
|
|
}
|
|
defer res.Body.Close()
|
|
lines := bufio.NewScanner(res.Body)
|
|
deadline := time.After(5 * time.Second)
|
|
for {
|
|
select {
|
|
case <-deadline:
|
|
t.Fatal("no keep-alive before the deadline")
|
|
default:
|
|
}
|
|
if !lines.Scan() {
|
|
t.Fatal("stream closed while job was queued")
|
|
}
|
|
if lines.Text() == ": keep-alive" {
|
|
break
|
|
}
|
|
}
|
|
<-server.indexSlots
|
|
var job Job
|
|
for lines.Scan() {
|
|
if data, ok := strings.CutPrefix(lines.Text(), "data: "); ok {
|
|
json.Unmarshal([]byte(data), &job)
|
|
}
|
|
}
|
|
if job.SnapshotID == nil {
|
|
t.Fatalf("stream ended without a snapshot: %+v", job)
|
|
}
|
|
}
|
|
|
|
func TestLocalFoldersDisabledByDefault(t *testing.T) {
|
|
t.Setenv("SHOW_LOCAL", "")
|
|
server := NewServer(filepath.Join(t.TempDir(), "codenavigator"))
|
|
ts := httptest.NewServer(server)
|
|
defer ts.Close()
|
|
c := client{t, ts.URL, nil}.stranger()
|
|
var health map[string]any
|
|
c.json("GET", "/api/health", "", 200, &health)
|
|
if health["localFolders"] != false {
|
|
t.Fatalf("health = %v", health)
|
|
}
|
|
c.json("POST", "/api/jobs", `{"kind":"local"}`, 403, nil)
|
|
c.json("POST", "/api/jobs", `{"kind":"mystery"}`, 400, nil)
|
|
|
|
t.Setenv("SHOW_LOCAL", "true")
|
|
enabled := httptest.NewServer(NewServer(filepath.Join(t.TempDir(), "codenavigator")))
|
|
defer enabled.Close()
|
|
on := client{t, enabled.URL, nil}.stranger()
|
|
on.json("GET", "/api/health", "", 200, &health)
|
|
if health["localFolders"] != true {
|
|
t.Fatalf("health with SHOW_LOCAL=true = %v", health)
|
|
}
|
|
on.json("POST", "/api/jobs", `{"kind":"local"}`, 201, nil)
|
|
}
|