249 lines
7.9 KiB
Go
249 lines
7.9 KiB
Go
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)
|
||
|
|
}
|
||
|
|
}
|