329 lines
8.1 KiB
Go
329 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/mattnite/cairn/internal/blob"
|
|
"github.com/mattnite/cairn/internal/database"
|
|
"github.com/mattnite/cairn/internal/forgejo"
|
|
"github.com/mattnite/cairn/internal/models"
|
|
"github.com/mattnite/cairn/internal/web"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type memoryBlobStore struct {
|
|
mu sync.RWMutex
|
|
data map[string][]byte
|
|
}
|
|
|
|
var _ blob.Store = (*memoryBlobStore)(nil)
|
|
|
|
func newMemoryBlobStore() *memoryBlobStore {
|
|
return &memoryBlobStore{data: make(map[string][]byte)}
|
|
}
|
|
|
|
func (s *memoryBlobStore) Put(_ context.Context, key string, reader io.Reader, _ int64) error {
|
|
b, err := io.ReadAll(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
s.data[key] = b
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (s *memoryBlobStore) Get(_ context.Context, key string) (io.ReadCloser, error) {
|
|
s.mu.RLock()
|
|
b, ok := s.data[key]
|
|
s.mu.RUnlock()
|
|
if !ok {
|
|
return nil, io.EOF
|
|
}
|
|
return io.NopCloser(bytes.NewReader(b)), nil
|
|
}
|
|
|
|
func (s *memoryBlobStore) Delete(_ context.Context, key string) error {
|
|
s.mu.Lock()
|
|
delete(s.data, key)
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
type forgejoMock struct {
|
|
server *httptest.Server
|
|
mu sync.Mutex
|
|
issueCalls int
|
|
}
|
|
|
|
func newForgejoMock(t *testing.T) *forgejoMock {
|
|
t.Helper()
|
|
m := &forgejoMock{}
|
|
m.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/repos/") && strings.HasSuffix(r.URL.Path, "/issues") {
|
|
m.mu.Lock()
|
|
m.issueCalls++
|
|
m.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(w, `{"number":123,"html_url":"http://mock/issues/123"}`)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}))
|
|
t.Cleanup(m.server.Close)
|
|
return m
|
|
}
|
|
|
|
func (m *forgejoMock) calls() int {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.issueCalls
|
|
}
|
|
|
|
func setupCLIServer(t *testing.T, enableForgejo bool) (serverURL string, db *gorm.DB, cleanup func(), mock *forgejoMock) {
|
|
t.Helper()
|
|
|
|
dbPath := filepath.Join(t.TempDir(), "cairn-test.db")
|
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
|
if err != nil {
|
|
t.Fatalf("opening sqlite db: %v", err)
|
|
}
|
|
if err := database.Migrate(db); err != nil {
|
|
t.Fatalf("migrating db: %v", err)
|
|
}
|
|
|
|
store := newMemoryBlobStore()
|
|
|
|
var forgejoClient *forgejo.Client
|
|
forgejoURL := ""
|
|
if enableForgejo {
|
|
mock = newForgejoMock(t)
|
|
forgejoURL = mock.server.URL
|
|
forgejoClient = forgejo.NewClient(mock.server.URL, "token")
|
|
}
|
|
|
|
router, err := web.NewRouter(web.RouterConfig{
|
|
DB: db,
|
|
Store: store,
|
|
ForgejoClient: forgejoClient,
|
|
ForgejoURL: forgejoURL,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("creating router: %v", err)
|
|
}
|
|
|
|
srv := httptest.NewServer(router)
|
|
cleanup = srv.Close
|
|
return srv.URL, db, cleanup, mock
|
|
}
|
|
|
|
func TestCLIUploadAndDownloadRoundTrip(t *testing.T) {
|
|
serverURL, db, cleanup, _ := setupCLIServer(t, false)
|
|
defer cleanup()
|
|
|
|
// Create a target first.
|
|
if err := cmdTarget("ensure", []string{
|
|
"-server", serverURL,
|
|
"-repo", "demo",
|
|
"-owner", "acme",
|
|
"-name", "seed-target",
|
|
"-type", "fuzz",
|
|
}); err != nil {
|
|
t.Fatalf("cmdTarget ensure failed: %v", err)
|
|
}
|
|
var target models.Target
|
|
if err := db.First(&target).Error; err != nil {
|
|
t.Fatalf("querying target: %v", err)
|
|
}
|
|
|
|
// Start a run.
|
|
if err := cmdRun("start", []string{
|
|
"-server", serverURL,
|
|
"-target-id", strconv.FormatUint(uint64(target.ID), 10),
|
|
"-commit", "abcdef1234567890",
|
|
}); err != nil {
|
|
t.Fatalf("cmdRun start failed: %v", err)
|
|
}
|
|
var run models.Run
|
|
if err := db.First(&run).Error; err != nil {
|
|
t.Fatalf("querying run: %v", err)
|
|
}
|
|
|
|
artifactFile := filepath.Join(t.TempDir(), "artifact.bin")
|
|
original := []byte("artifact bytes")
|
|
if err := os.WriteFile(artifactFile, original, 0o644); err != nil {
|
|
t.Fatalf("writing test artifact: %v", err)
|
|
}
|
|
|
|
err := cmdUpload([]string{
|
|
"-server", serverURL,
|
|
"-repo", "demo",
|
|
"-owner", "acme",
|
|
"-commit", "abcdef1234567890",
|
|
"-run-id", strconv.FormatUint(uint64(run.ID), 10),
|
|
"-type", "fuzz",
|
|
"-file", artifactFile,
|
|
"-crash-message", "boom",
|
|
"-signal", "11",
|
|
"-kind", "crash",
|
|
"-track-key", "track-123",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("cmdUpload failed: %v", err)
|
|
}
|
|
|
|
var a models.Artifact
|
|
if err := db.First(&a).Error; err != nil {
|
|
t.Fatalf("querying uploaded artifact: %v", err)
|
|
}
|
|
|
|
var md map[string]any
|
|
if err := json.Unmarshal(a.Metadata, &md); err != nil {
|
|
t.Fatalf("unmarshal metadata: %v", err)
|
|
}
|
|
if got := md["signal"]; got != "11" {
|
|
t.Fatalf("expected metadata.signal=11, got %#v", got)
|
|
}
|
|
if got := md["kind"]; got != "crash" {
|
|
t.Fatalf("expected metadata.kind=crash, got %#v", got)
|
|
}
|
|
if got := md["track_key"]; got != "track-123" {
|
|
t.Fatalf("expected metadata.track_key=track-123, got %#v", got)
|
|
}
|
|
if a.RunID == nil || *a.RunID != run.ID {
|
|
t.Fatalf("expected run_id=%d, got %#v", run.ID, a.RunID)
|
|
}
|
|
|
|
outFile := filepath.Join(t.TempDir(), "downloaded.bin")
|
|
if err := cmdDownload([]string{"-server", serverURL, "-id", strconv.FormatUint(uint64(a.ID), 10), "-o", outFile}); err != nil {
|
|
t.Fatalf("cmdDownload failed: %v", err)
|
|
}
|
|
|
|
downloaded, err := os.ReadFile(outFile)
|
|
if err != nil {
|
|
t.Fatalf("reading downloaded artifact: %v", err)
|
|
}
|
|
if !bytes.Equal(downloaded, original) {
|
|
t.Fatalf("downloaded content mismatch: got %q want %q", downloaded, original)
|
|
}
|
|
}
|
|
|
|
func TestCLITargetEnsureAndRunStartFinish(t *testing.T) {
|
|
serverURL, db, cleanup, _ := setupCLIServer(t, false)
|
|
defer cleanup()
|
|
|
|
if err := cmdTarget("ensure", []string{
|
|
"-server", serverURL,
|
|
"-repo", "demo",
|
|
"-owner", "acme",
|
|
"-name", "nightly",
|
|
"-type", "fuzz",
|
|
}); err != nil {
|
|
t.Fatalf("cmdTarget ensure failed: %v", err)
|
|
}
|
|
|
|
var target models.Target
|
|
if err := db.First(&target).Error; err != nil {
|
|
t.Fatalf("querying target: %v", err)
|
|
}
|
|
if target.Name != "nightly" {
|
|
t.Fatalf("expected target name=nightly, got %q", target.Name)
|
|
}
|
|
|
|
// Ensure is idempotent.
|
|
if err := cmdTarget("ensure", []string{
|
|
"-server", serverURL,
|
|
"-repo", "demo",
|
|
"-owner", "acme",
|
|
"-name", "nightly",
|
|
"-type", "fuzz",
|
|
}); err != nil {
|
|
t.Fatalf("cmdTarget ensure (idempotent) failed: %v", err)
|
|
}
|
|
var count int64
|
|
db.Model(&models.Target{}).Count(&count)
|
|
if count != 1 {
|
|
t.Fatalf("expected 1 target after idempotent ensure, got %d", count)
|
|
}
|
|
|
|
// Start a run.
|
|
if err := cmdRun("start", []string{
|
|
"-server", serverURL,
|
|
"-target-id", strconv.FormatUint(uint64(target.ID), 10),
|
|
"-commit", "abc123",
|
|
}); err != nil {
|
|
t.Fatalf("cmdRun start failed: %v", err)
|
|
}
|
|
|
|
var run models.Run
|
|
if err := db.First(&run).Error; err != nil {
|
|
t.Fatalf("querying run: %v", err)
|
|
}
|
|
if run.Status != "running" {
|
|
t.Fatalf("expected running run, got %q", run.Status)
|
|
}
|
|
|
|
if err := cmdRun("finish", []string{
|
|
"-server", serverURL,
|
|
"-id", strconv.FormatUint(uint64(run.ID), 10),
|
|
}); err != nil {
|
|
t.Fatalf("cmdRun finish failed: %v", err)
|
|
}
|
|
|
|
if err := db.First(&run, run.ID).Error; err != nil {
|
|
t.Fatalf("re-querying run: %v", err)
|
|
}
|
|
if run.Status != "finished" {
|
|
t.Fatalf("expected finished run, got %q", run.Status)
|
|
}
|
|
}
|
|
|
|
func TestCLIUploadTriggersForgejoIssueCreation(t *testing.T) {
|
|
serverURL, db, cleanup, mock := setupCLIServer(t, true)
|
|
defer cleanup()
|
|
|
|
artifactFile := filepath.Join(t.TempDir(), "crash.txt")
|
|
if err := os.WriteFile(artifactFile, []byte("crash payload"), 0o644); err != nil {
|
|
t.Fatalf("writing test artifact: %v", err)
|
|
}
|
|
|
|
stack := "at foo crash.c:10\nat bar crash.c:20\n"
|
|
err := cmdUpload([]string{
|
|
"-server", serverURL,
|
|
"-repo", "demo",
|
|
"-owner", "acme",
|
|
"-commit", "abcdef1234567890",
|
|
"-type", "fuzz",
|
|
"-file", artifactFile,
|
|
"-stack-trace", stack,
|
|
"-crash-message", "boom",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("cmdUpload failed: %v", err)
|
|
}
|
|
|
|
if got := mock.calls(); got != 1 {
|
|
t.Fatalf("expected 1 forgejo issue call, got %d", got)
|
|
}
|
|
|
|
var cg models.CrashGroup
|
|
if err := db.First(&cg).Error; err != nil {
|
|
t.Fatalf("querying crash group: %v", err)
|
|
}
|
|
if cg.ForgejoIssueID == nil || *cg.ForgejoIssueID != 123 {
|
|
t.Fatalf("expected forgejo_issue_id=123, got %#v", cg.ForgejoIssueID)
|
|
}
|
|
}
|