cairn/cmd/cairn/integration_test.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)
}
}