mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
Creating a cron job over a connection with no tenant in context (gateway-token / CLI / master-scope UI session) panicked with a nil-pointer dereference. Root cause — tenant asymmetry between insert and readback: - AddJob INSERT uses tenantIDForInsert(ctx), which falls back to MasterTenantID when the context has no tenant (row inserted OK). - The readback (scanJob / GetJob) rejects a nil-tenant context with "tenant_id required", so GetJob returned (nil, false) and AddJob swallowed it as (nil, nil). - handleCreate then dereferenced job.ID (UpdateJob patch) → panic. Fix (defense-in-depth): - handleCreate (internal/gateway/methods/cron.go): add a nil-job guard before any job.ID use; return ErrInternal instead of panicking. Apply the post-create stateless/command patch under a tenant-consistent context (WithTenantID(ctx, job.TenantID)) so lockCronJobForMutation finds the row for nil-tenant callers. - AddJob (store/pg + store/sqlitestore cron_crud.go): read back under WithTenantID(ctx, tenantIDForInsert(ctx)) so insert and readback share the same tenant; return an explicit error if readback fails instead of swallowing it. Tests: - internal/store/sqlitestore/cron_crud_test.go: AddJob with a nil-tenant context returns a non-nil job + nil error (real SQLite DB). - internal/gateway/methods/cron_test.go: handleCreate does not panic and skips UpdateJob when the store returns (nil, nil). Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
394 lines
11 KiB
Go
394 lines
11 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func TestSQLiteCronStore_ReenableRestoresNextRun(t *testing.T) {
|
|
cronStore, ctx, _ := newTestSQLiteCronStore(t)
|
|
everyMS := int64(time.Minute / time.Millisecond)
|
|
|
|
job, err := cronStore.AddJob(ctx, "job-reenable", store.CronSchedule{
|
|
Kind: "every",
|
|
EveryMS: &everyMS,
|
|
}, "hello", false, "", "", "", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("AddJob error: %v", err)
|
|
}
|
|
if job == nil {
|
|
job = mustOnlyJob(t, cronStore, ctx)
|
|
}
|
|
|
|
if err := cronStore.EnableJob(ctx, job.ID, false); err != nil {
|
|
t.Fatalf("disable error: %v", err)
|
|
}
|
|
disabled, ok := cronStore.GetJob(ctx, job.ID)
|
|
if !ok {
|
|
t.Fatal("disabled job not found")
|
|
}
|
|
if disabled.Enabled {
|
|
t.Fatal("expected job to be disabled")
|
|
}
|
|
if disabled.State.NextRunAtMS != nil {
|
|
t.Fatalf("expected disabled job to clear next_run_at, got %v", *disabled.State.NextRunAtMS)
|
|
}
|
|
|
|
if err := cronStore.EnableJob(ctx, job.ID, true); err != nil {
|
|
t.Fatalf("re-enable error: %v", err)
|
|
}
|
|
reenabled, ok := cronStore.GetJob(ctx, job.ID)
|
|
if !ok {
|
|
t.Fatal("re-enabled job not found")
|
|
}
|
|
if !reenabled.Enabled {
|
|
t.Fatal("expected job to be enabled")
|
|
}
|
|
if reenabled.State.NextRunAtMS == nil {
|
|
t.Fatal("expected re-enabled job to have next_run_at")
|
|
}
|
|
|
|
due := cronStore.GetDueJobs(time.UnixMilli(*reenabled.State.NextRunAtMS))
|
|
if len(due) != 1 || due[0].ID != job.ID {
|
|
t.Fatalf("expected job %s to be due after re-enable, got %#v", job.ID, due)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteCronStore_AddJobPersistsCredentialUserID(t *testing.T) {
|
|
cronStore, ctx, _ := newTestSQLiteCronStore(t)
|
|
ctx = store.WithCredentialUserID(ctx, "tenant-user-123")
|
|
everyMS := int64(time.Minute / time.Millisecond)
|
|
|
|
job, err := cronStore.AddJob(ctx, "job-credential-context", store.CronSchedule{
|
|
Kind: "every",
|
|
EveryMS: &everyMS,
|
|
}, "run credentialed report", false, "", "", "", "group:telegram:-100123")
|
|
if err != nil {
|
|
t.Fatalf("AddJob error: %v", err)
|
|
}
|
|
if job == nil {
|
|
job = mustOnlyJob(t, cronStore, ctx)
|
|
}
|
|
if job.UserID != "group:telegram:-100123" {
|
|
t.Fatalf("cron user ID = %q, want group-scoped owner", job.UserID)
|
|
}
|
|
if job.Payload.CredentialUserID != "tenant-user-123" {
|
|
t.Fatalf("credential user ID = %q, want tenant-user-123", job.Payload.CredentialUserID)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteCronStore_EnableAlreadyEnabledPreservesNextRun(t *testing.T) {
|
|
cronStore, ctx, _ := newTestSQLiteCronStore(t)
|
|
everyMS := int64(time.Hour / time.Millisecond)
|
|
|
|
job, err := cronStore.AddJob(ctx, "job-idempotent", store.CronSchedule{
|
|
Kind: "every",
|
|
EveryMS: &everyMS,
|
|
}, "hello", false, "", "", "", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("AddJob error: %v", err)
|
|
}
|
|
if job == nil {
|
|
job = mustOnlyJob(t, cronStore, ctx)
|
|
}
|
|
if job.State.NextRunAtMS == nil {
|
|
t.Fatal("expected new job to have next_run_at")
|
|
}
|
|
|
|
originalNextRun := *job.State.NextRunAtMS
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
if err := cronStore.EnableJob(ctx, job.ID, true); err != nil {
|
|
t.Fatalf("enable error: %v", err)
|
|
}
|
|
|
|
current, ok := cronStore.GetJob(ctx, job.ID)
|
|
if !ok {
|
|
t.Fatal("job not found after enable")
|
|
}
|
|
if current.State.NextRunAtMS == nil {
|
|
t.Fatal("expected enabled job to keep next_run_at")
|
|
}
|
|
if got := *current.State.NextRunAtMS; got != originalNextRun {
|
|
t.Fatalf("got next_run_at %d, want preserved %d", got, originalNextRun)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteCronStore_EnableExpiredAtReturnsError(t *testing.T) {
|
|
cronStore, ctx, _ := newTestSQLiteCronStore(t)
|
|
future := time.Now().Add(time.Hour).UnixMilli()
|
|
|
|
job, err := cronStore.AddJob(ctx, "job-expired-at", store.CronSchedule{
|
|
Kind: "at",
|
|
AtMS: &future,
|
|
}, "hello", false, "", "", "", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("AddJob error: %v", err)
|
|
}
|
|
if job == nil {
|
|
job = mustOnlyJob(t, cronStore, ctx)
|
|
}
|
|
|
|
if err := cronStore.EnableJob(ctx, job.ID, false); err != nil {
|
|
t.Fatalf("disable error: %v", err)
|
|
}
|
|
|
|
past := time.Now().Add(-time.Hour).UnixMilli()
|
|
if _, err := cronStore.UpdateJob(ctx, job.ID, store.CronJobPatch{
|
|
Schedule: &store.CronSchedule{Kind: "at", AtMS: &past},
|
|
}); err != nil {
|
|
t.Fatalf("UpdateJob error: %v", err)
|
|
}
|
|
|
|
err = cronStore.EnableJob(ctx, job.ID, true)
|
|
if !errors.Is(err, store.ErrCronJobNoFutureRun) {
|
|
t.Fatalf("got %v, want ErrCronJobNoFutureRun", err)
|
|
}
|
|
|
|
current, ok := cronStore.GetJob(ctx, job.ID)
|
|
if !ok {
|
|
t.Fatal("job not found after failed enable")
|
|
}
|
|
if current.Enabled {
|
|
t.Fatal("expected failed enable to leave job disabled")
|
|
}
|
|
if current.State.NextRunAtMS != nil {
|
|
t.Fatalf("expected failed enable to leave next_run_at nil, got %v", *current.State.NextRunAtMS)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteCronStore_SchedulerSkipsDisabledCachedJob(t *testing.T) {
|
|
cronStore, ctx, db := newTestSQLiteCronStore(t)
|
|
everyMS := int64(time.Minute / time.Millisecond)
|
|
|
|
job, err := cronStore.AddJob(ctx, "job-stale-cache", store.CronSchedule{
|
|
Kind: "every",
|
|
EveryMS: &everyMS,
|
|
}, "hello", false, "", "", "", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("AddJob error: %v", err)
|
|
}
|
|
if job == nil {
|
|
job = mustOnlyJob(t, cronStore, ctx)
|
|
}
|
|
|
|
jobUUID := uuid.MustParse(job.ID)
|
|
pastRun := time.Now().Add(-time.Minute)
|
|
if _, err := db.ExecContext(ctx,
|
|
"UPDATE cron_jobs SET next_run_at = ?, updated_at = ? WHERE id = ? AND tenant_id = ?",
|
|
pastRun, time.Now(), jobUUID, store.MasterTenantID,
|
|
); err != nil {
|
|
t.Fatalf("mark due error: %v", err)
|
|
}
|
|
|
|
cached, ok := cronStore.GetJob(ctx, job.ID)
|
|
if !ok {
|
|
t.Fatal("cached job not found")
|
|
}
|
|
pastMS := pastRun.UnixMilli()
|
|
cached.Enabled = true
|
|
cached.State.NextRunAtMS = &pastMS
|
|
cronStore.jobCache = []store.CronJob{*cached}
|
|
cronStore.cacheLoaded = true
|
|
cronStore.cacheTime = time.Now()
|
|
cronStore.cacheTTL = time.Hour
|
|
cronStore.baseCtx = context.Background()
|
|
|
|
var runs atomic.Int32
|
|
cronStore.onJob = func(job *store.CronJob) (*store.CronJobResult, error) {
|
|
runs.Add(1)
|
|
return &store.CronJobResult{Content: "ok"}, nil
|
|
}
|
|
|
|
if _, err := db.ExecContext(ctx,
|
|
"UPDATE cron_jobs SET enabled = 0, next_run_at = NULL, updated_at = ? WHERE id = ? AND tenant_id = ?",
|
|
time.Now(), jobUUID, store.MasterTenantID,
|
|
); err != nil {
|
|
t.Fatalf("disable error: %v", err)
|
|
}
|
|
|
|
cronStore.checkAndRunDueJobs()
|
|
|
|
if got := runs.Load(); got != 0 {
|
|
t.Fatalf("expected claimed disabled job to be skipped, got %d executions", got)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteCronStore_ExecuteOneJob_DoesNotRestoreNextRunAfterDisable(t *testing.T) {
|
|
cronStore, ctx, db := newTestSQLiteCronStore(t)
|
|
everyMS := int64(time.Minute / time.Millisecond)
|
|
|
|
job, err := cronStore.AddJob(ctx, "job-midrun-disable", store.CronSchedule{
|
|
Kind: "every",
|
|
EveryMS: &everyMS,
|
|
}, "hello", false, "", "", "", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("AddJob error: %v", err)
|
|
}
|
|
if job == nil {
|
|
job = mustOnlyJob(t, cronStore, ctx)
|
|
}
|
|
|
|
jobUUID := uuid.MustParse(job.ID)
|
|
if _, err := db.ExecContext(ctx,
|
|
"UPDATE cron_jobs SET next_run_at = NULL, updated_at = ? WHERE id = ? AND tenant_id = ?",
|
|
time.Now(), jobUUID, store.MasterTenantID,
|
|
); err != nil {
|
|
t.Fatalf("claim setup error: %v", err)
|
|
}
|
|
|
|
cronStore.executeOneJob(*job, func(job *store.CronJob) (*store.CronJobResult, error) {
|
|
if err := cronStore.EnableJob(ctx, job.ID, false); err != nil {
|
|
t.Fatalf("disable during run error: %v", err)
|
|
}
|
|
return &store.CronJobResult{Content: "ok"}, nil
|
|
}, false)
|
|
|
|
current := mustRawJob(t, db, jobUUID)
|
|
if current.enabled {
|
|
t.Fatal("expected job to remain disabled after run")
|
|
}
|
|
if current.nextRunAt != nil {
|
|
t.Fatalf("expected disabled job to keep next_run_at nil, got %v", current.nextRunAt)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteCronStore_EnableJob_IgnoresMalformedPayload(t *testing.T) {
|
|
cronStore, ctx, db := newTestSQLiteCronStore(t)
|
|
everyMS := int64(time.Minute / time.Millisecond)
|
|
|
|
job, err := cronStore.AddJob(ctx, "job-bad-payload", store.CronSchedule{
|
|
Kind: "every",
|
|
EveryMS: &everyMS,
|
|
}, "hello", false, "", "", "", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("AddJob error: %v", err)
|
|
}
|
|
if job == nil {
|
|
job = mustOnlyJob(t, cronStore, ctx)
|
|
}
|
|
|
|
jobUUID := uuid.MustParse(job.ID)
|
|
if _, err := db.ExecContext(ctx,
|
|
"UPDATE cron_jobs SET payload = ? WHERE id = ? AND tenant_id = ?",
|
|
"{", jobUUID, store.MasterTenantID,
|
|
); err != nil {
|
|
t.Fatalf("corrupt payload error: %v", err)
|
|
}
|
|
|
|
if err := cronStore.EnableJob(ctx, job.ID, false); err != nil {
|
|
t.Fatalf("toggle with malformed payload error: %v", err)
|
|
}
|
|
|
|
current := mustRawJob(t, db, jobUUID)
|
|
if current.enabled {
|
|
t.Fatal("expected malformed-payload job to be disabled")
|
|
}
|
|
if current.nextRunAt != nil {
|
|
t.Fatalf("expected malformed-payload job to clear next_run_at, got %v", current.nextRunAt)
|
|
}
|
|
}
|
|
|
|
// TestSQLiteCronStore_AddJobNilTenantContext_ReturnsJob is a regression test for
|
|
// the cron-create nil-pointer bug (B24:2794). A connection without a tenant in
|
|
// context (gateway-token / CLI / master-scope UI session) inserts the row under
|
|
// MasterTenantID via the insert-tenant fallback, but the readback previously
|
|
// used the raw nil-tenant context and hit scanJob's "tenant_id required" guard.
|
|
// AddJob therefore returned (nil, nil), which the handler dereferenced → panic.
|
|
// The fix reads back under a tenant-consistent context, so AddJob must now
|
|
// return a non-nil job with nil error.
|
|
func TestSQLiteCronStore_AddJobNilTenantContext_ReturnsJob(t *testing.T) {
|
|
cronStore, _, _ := newTestSQLiteCronStore(t)
|
|
// Bare context: NO tenant, NO cross-tenant flag — exactly the shape a
|
|
// gateway-token / master-scope connection produces.
|
|
ctx := context.Background()
|
|
everyMS := int64(time.Minute / time.Millisecond)
|
|
|
|
job, err := cronStore.AddJob(ctx, "job-nil-tenant", store.CronSchedule{
|
|
Kind: "every",
|
|
EveryMS: &everyMS,
|
|
}, "hello", false, "", "", "", "user-1")
|
|
if err != nil {
|
|
t.Fatalf("AddJob error: %v", err)
|
|
}
|
|
if job == nil {
|
|
t.Fatal("AddJob returned (nil, nil) for nil-tenant context — readback swallowed the created job")
|
|
}
|
|
if job.TenantID != store.MasterTenantID {
|
|
t.Fatalf("job tenant = %v, want MasterTenantID %v", job.TenantID, store.MasterTenantID)
|
|
}
|
|
if job.Name != "job-nil-tenant" {
|
|
t.Fatalf("job name = %q, want %q", job.Name, "job-nil-tenant")
|
|
}
|
|
}
|
|
|
|
func newTestSQLiteCronStore(t *testing.T) (*SQLiteCronStore, context.Context, *sql.DB) {
|
|
t.Helper()
|
|
|
|
db, err := OpenDB(filepath.Join(t.TempDir(), "cron.db"))
|
|
if err != nil {
|
|
t.Fatalf("OpenDB error: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
if err := EnsureSchema(db); err != nil {
|
|
t.Fatalf("EnsureSchema error: %v", err)
|
|
}
|
|
|
|
ctx := store.WithCrossTenant(store.WithTenantID(context.Background(), store.MasterTenantID))
|
|
cronStore := NewSQLiteCronStore(db)
|
|
cronStore.baseCtx = context.Background()
|
|
cronStore.cacheTTL = time.Hour
|
|
return cronStore, ctx, db
|
|
}
|
|
|
|
func mustOnlyJob(t *testing.T, cronStore *SQLiteCronStore, ctx context.Context) *store.CronJob {
|
|
t.Helper()
|
|
|
|
jobs := cronStore.ListJobs(ctx, true, "", "")
|
|
if len(jobs) != 1 {
|
|
t.Fatalf("expected exactly 1 job, got %#v", jobs)
|
|
}
|
|
|
|
job := jobs[0]
|
|
return &job
|
|
}
|
|
|
|
type rawCronJobState struct {
|
|
enabled bool
|
|
nextRunAt *time.Time
|
|
}
|
|
|
|
func mustRawJob(t *testing.T, db *sql.DB, id uuid.UUID) rawCronJobState {
|
|
t.Helper()
|
|
|
|
var (
|
|
enabled bool
|
|
nextRunAt nullSqliteTime
|
|
)
|
|
if err := db.QueryRow(
|
|
"SELECT enabled, next_run_at FROM cron_jobs WHERE id = ?",
|
|
id,
|
|
).Scan(&enabled, &nextRunAt); err != nil {
|
|
t.Fatalf("raw cron job query error: %v", err)
|
|
}
|
|
|
|
state := rawCronJobState{enabled: enabled}
|
|
if nextRunAt.Valid {
|
|
next := nextRunAt.Time
|
|
state.nextRunAt = &next
|
|
}
|
|
return state
|
|
}
|