Files

249 lines
7.9 KiB
Go
Raw Permalink Normal View History

package autoscan
import (
"context"
"errors"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/secret"
)
// newWebhookDBTest connects to SILO_TEST_DATABASE_URL (skipping when unset or
// unmigrated) and returns a repository plus a fresh webhook-mode source row.
func newWebhookDBTest(t *testing.T) (context.Context, *Repository, Source) {
t.Helper()
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("SILO_TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect test database: %v", err)
}
t.Cleanup(pool.Close)
var tableName *string
if err := pool.QueryRow(ctx, `SELECT to_regclass('public.autoscan_webhook_endpoints')::text`).Scan(&tableName); err != nil {
t.Fatalf("check autoscan_webhook_endpoints table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied the autoscan webhook intake migration")
}
cipher, err := secret.New([]byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatalf("new cipher: %v", err)
}
repo := NewRepository(pool, cipher)
src, err := repo.CreateSource(ctx, Source{
PluginID: BuiltinArrWebhookPluginID,
CapabilityID: BuiltinArrWebhookCapabilityID,
Enabled: true,
DeliveryMode: DeliveryModeWebhook,
})
if err != nil {
t.Fatalf("create source: %v", err)
}
t.Cleanup(func() { _ = repo.DeleteSource(ctx, src.ID) })
return ctx, repo, src
}
func TestWebhookEndpointLifecycle(t *testing.T) {
ctx, repo, src := newWebhookDBTest(t)
if src.DeliveryMode != DeliveryModeWebhook {
t.Fatalf("expected webhook delivery mode persisted, got %q", src.DeliveryMode)
}
endpoint, token, err := repo.CreateWebhookEndpoint(ctx, src.ID)
if err != nil {
t.Fatalf("create endpoint: %v", err)
}
if token == "" {
t.Fatal("create must return a plaintext token")
}
if !strings.HasSuffix(token, endpoint.SecretSuffix) || len(endpoint.SecretSuffix) != webhookSecretSuffixLen {
t.Fatalf("suffix %q does not match token tail", endpoint.SecretSuffix)
}
// Idempotent create: existing endpoint returned, no new token.
again, token2, err := repo.CreateWebhookEndpoint(ctx, src.ID)
if err != nil {
t.Fatalf("re-create endpoint: %v", err)
}
if token2 != "" || again.SecretSuffix != endpoint.SecretSuffix {
t.Fatalf("re-create must return existing endpoint with empty token, got token=%q suffix=%q", token2, again.SecretSuffix)
}
resolvedSrc, _, err := repo.ResolveWebhookToken(ctx, token)
if err != nil {
t.Fatalf("resolve token: %v", err)
}
if resolvedSrc.ID != src.ID {
t.Fatalf("resolved source %s, want %s", resolvedSrc.ID, src.ID)
}
revealed, err := repo.RevealWebhookToken(ctx, src.ID)
if err != nil {
t.Fatalf("reveal token: %v", err)
}
if revealed != token {
t.Fatal("revealed token must round-trip the original")
}
rotated, newToken, err := repo.RotateWebhookEndpoint(ctx, src.ID)
if err != nil {
t.Fatalf("rotate endpoint: %v", err)
}
if newToken == "" || newToken == token {
t.Fatal("rotate must return a fresh token")
}
if rotated.RotatedAt == nil {
t.Fatal("rotate must stamp rotated_at")
}
if _, _, err := repo.ResolveWebhookToken(ctx, token); !errors.Is(err, ErrNotFound) {
t.Fatalf("old token must be invalidated, got %v", err)
}
if _, _, err := repo.ResolveWebhookToken(ctx, newToken); err != nil {
t.Fatalf("new token must resolve: %v", err)
}
if err := repo.TouchWebhookReceived(ctx, src.ID); err != nil {
t.Fatalf("touch received: %v", err)
}
if err := repo.RecordWebhookError(ctx, src.ID, "boom"); err != nil {
t.Fatalf("record error: %v", err)
}
got, err := repo.GetWebhookEndpoint(ctx, src.ID)
if err != nil {
t.Fatalf("get endpoint: %v", err)
}
if got.LastReceivedAt == nil || got.LastErrorAt == nil || got.LastErrorMessage != "boom" {
t.Fatalf("bookkeeping fields not persisted: %+v", got)
}
// Cascade: deleting the source removes the endpoint.
if err := repo.DeleteSource(ctx, src.ID); err != nil {
t.Fatalf("delete source: %v", err)
}
if _, err := repo.GetWebhookEndpoint(ctx, src.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("endpoint must cascade-delete with source, got %v", err)
}
if err := repo.DeleteWebhookEndpoint(ctx, src.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("delete of missing endpoint must be ErrNotFound, got %v", err)
}
}
func TestCreateEventSkipRunningCheck(t *testing.T) {
ctx, repo, src := newWebhookDBTest(t)
first, err := repo.CreateEvent(ctx, EventCreate{
SourceID: src.ID,
PluginID: src.PluginID,
CapabilityID: src.CapabilityID,
DeliveryMode: DeliveryModeWebhook,
ProviderEventType: "Download",
SkipRunningCheck: true,
})
if err != nil {
t.Fatalf("create first event: %v", err)
}
// Poll-style creation is excluded while the first event runs...
if _, err := repo.CreateEvent(ctx, EventCreate{
SourceID: src.ID,
PluginID: src.PluginID,
CapabilityID: src.CapabilityID,
}); !errors.Is(err, ErrPollAlreadyRunning) {
t.Fatalf("poll create must hit running exclusion, got %v", err)
}
// ...but a webhook delivery is never dropped.
second, err := repo.CreateEvent(ctx, EventCreate{
SourceID: src.ID,
PluginID: src.PluginID,
CapabilityID: src.CapabilityID,
DeliveryMode: DeliveryModeWebhook,
ProviderEventType: "Download",
SkipRunningCheck: true,
})
if err != nil {
t.Fatalf("concurrent webhook event must not be dropped: %v", err)
}
for _, id := range []int64{first, second} {
if err := repo.FinishEvent(ctx, EventFinish{ID: id, Status: EventStatusSuccess}); err != nil {
t.Fatalf("finish event %d: %v", id, err)
}
}
events, err := repo.ListEvents(ctx, EventListFilter{SourceID: src.ID})
if err != nil {
t.Fatalf("list events: %v", err)
}
if len(events) != 2 {
t.Fatalf("expected 2 events, got %d", len(events))
}
for _, e := range events {
if e.Event.DeliveryMode != DeliveryModeWebhook || e.Event.ProviderEventType != "Download" {
t.Fatalf("event metadata not persisted: %+v", e.Event)
}
}
}
func TestWebhookDeliveryDurableRetryLifecycle(t *testing.T) {
ctx, repo, src := newWebhookDBTest(t)
var tableName *string
if err := repo.pool.QueryRow(ctx, `SELECT to_regclass('public.autoscan_webhook_deliveries')::text`).Scan(&tableName); err != nil {
t.Fatalf("check autoscan_webhook_deliveries table: %v", err)
}
if tableName == nil || *tableName == "" {
t.Skip("test database has not applied the autoscan webhook delivery queue migration")
}
delivery, err := repo.CreateWebhookDelivery(ctx, ChangeIngest{
SourceID: src.ID,
ProviderEventType: "Download",
Changes: []Change{{SourcePath: "/data/movie.mkv", Scope: ChangeScopeFile}},
})
if err != nil {
t.Fatalf("create delivery: %v", err)
}
if delivery.ID == 0 || delivery.AttemptCount != 1 || delivery.LockedBy == "" {
t.Fatalf("created delivery = %+v", delivery)
}
if err := repo.RetryWebhookDelivery(ctx, delivery.ID, delivery.LockedBy, 0, "temporary"); err != nil {
t.Fatalf("schedule retry: %v", err)
}
claimed, err := repo.ClaimWebhookDeliveries(ctx, "worker-2", 10)
if err != nil {
t.Fatalf("claim delivery: %v", err)
}
if len(claimed) != 1 || claimed[0].ID != delivery.ID || claimed[0].AttemptCount != 2 || claimed[0].LockedBy != "worker-2" {
t.Fatalf("claimed = %+v", claimed)
}
if len(claimed[0].Changes) != 1 || claimed[0].Changes[0].SourcePath != "/data/movie.mkv" {
t.Fatalf("changes did not round-trip: %+v", claimed[0].Changes)
}
if err := repo.CompleteWebhookDelivery(ctx, delivery.ID, "stale-worker"); !errors.Is(err, ErrNotFound) {
t.Fatalf("stale lease completion must be rejected, got %v", err)
}
if err := repo.CompleteWebhookDelivery(ctx, delivery.ID, "worker-2"); err != nil {
t.Fatalf("complete delivery: %v", err)
}
claimed, err = repo.ClaimWebhookDeliveries(ctx, "worker-3", 10)
if err != nil {
t.Fatalf("claim after complete: %v", err)
}
if len(claimed) != 0 {
t.Fatalf("completed delivery was reclaimed: %+v", claimed)
}
}