mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +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>
442 lines
13 KiB
Go
442 lines
13 KiB
Go
package methods
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// ---- stub CronStore ----
|
|
|
|
type stubCronStore struct {
|
|
jobs map[string]*store.CronJob
|
|
addErr error
|
|
addNilJob bool // simulate insert-ok-but-readback-failed: AddJob returns (nil, nil)
|
|
removeErr error
|
|
updateErr error
|
|
enableErr error
|
|
addedJob *store.CronJob
|
|
updateCnt int
|
|
enableCnt int
|
|
runCnt int
|
|
}
|
|
|
|
func newStubCronStore() *stubCronStore {
|
|
return &stubCronStore{jobs: make(map[string]*store.CronJob)}
|
|
}
|
|
|
|
func (s *stubCronStore) addJob(id, name, userID string) {
|
|
s.jobs[id] = &store.CronJob{ID: id, Name: name, UserID: userID, Enabled: true}
|
|
}
|
|
|
|
func (s *stubCronStore) AddJob(_ context.Context, name string, schedule store.CronSchedule, message string,
|
|
deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) {
|
|
if s.addErr != nil {
|
|
return nil, s.addErr
|
|
}
|
|
if s.addNilJob {
|
|
return nil, nil
|
|
}
|
|
job := &store.CronJob{
|
|
ID: "new-job-id",
|
|
Name: name,
|
|
UserID: userID,
|
|
Enabled: true,
|
|
Schedule: schedule,
|
|
}
|
|
s.addedJob = job
|
|
s.jobs[job.ID] = job
|
|
return job, nil
|
|
}
|
|
|
|
func (s *stubCronStore) GetJob(_ context.Context, jobID string) (*store.CronJob, bool) {
|
|
j, ok := s.jobs[jobID]
|
|
return j, ok
|
|
}
|
|
|
|
func (s *stubCronStore) ListJobs(_ context.Context, _ bool, _, userID string) []store.CronJob {
|
|
var result []store.CronJob
|
|
for _, j := range s.jobs {
|
|
if userID == "" || j.UserID == userID {
|
|
result = append(result, *j)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *stubCronStore) RemoveJob(_ context.Context, jobID string) error {
|
|
if s.removeErr != nil {
|
|
return s.removeErr
|
|
}
|
|
delete(s.jobs, jobID)
|
|
return nil
|
|
}
|
|
|
|
func (s *stubCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
|
|
s.updateCnt++
|
|
if s.updateErr != nil {
|
|
return nil, s.updateErr
|
|
}
|
|
j, ok := s.jobs[jobID]
|
|
if !ok {
|
|
return nil, store.ErrCronJobNotFound
|
|
}
|
|
return j, nil
|
|
}
|
|
|
|
func (s *stubCronStore) EnableJob(_ context.Context, jobID string, enabled bool) error {
|
|
s.enableCnt++
|
|
if s.enableErr != nil {
|
|
return s.enableErr
|
|
}
|
|
if j, ok := s.jobs[jobID]; ok {
|
|
j.Enabled = enabled
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *stubCronStore) GetRunLog(_ context.Context, _ string, _, _ int) ([]store.CronRunLogEntry, int) {
|
|
return nil, 0
|
|
}
|
|
|
|
func (s *stubCronStore) Status() map[string]any { return map[string]any{"running": true} }
|
|
|
|
// Lifecycle stubs (not called in unit tests)
|
|
func (s *stubCronStore) Start() error { return nil }
|
|
func (s *stubCronStore) Stop() {}
|
|
func (s *stubCronStore) SetOnJob(_ func(*store.CronJob) (*store.CronJobResult, error)) {}
|
|
func (s *stubCronStore) SetOnEvent(_ func(store.CronEvent)) {}
|
|
func (s *stubCronStore) RunJob(_ context.Context, _ string, _ bool) (bool, string, error) {
|
|
s.runCnt++
|
|
return true, "", nil
|
|
}
|
|
func (s *stubCronStore) SetDefaultTimezone(_ string) {}
|
|
func (s *stubCronStore) GetDueJobs(_ time.Time) []store.CronJob { return nil }
|
|
|
|
// ---- helpers ----
|
|
|
|
func buildCronMethods(t *testing.T, svc *stubCronStore) *CronMethods {
|
|
t.Helper()
|
|
cfg := &config.Config{}
|
|
return NewCronMethods(svc, &stubEventPub{}, cfg)
|
|
}
|
|
|
|
func cronReqFrame(t *testing.T, method string, params map[string]any) *protocol.RequestFrame {
|
|
t.Helper()
|
|
raw, err := json.Marshal(params)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
return &protocol.RequestFrame{
|
|
Type: protocol.FrameTypeRequest,
|
|
ID: "cron-req-1",
|
|
Method: method,
|
|
Params: raw,
|
|
}
|
|
}
|
|
|
|
// ---- Tests: handleList ----
|
|
|
|
func TestCronList_EmptyStore_ReturnsEmptyJobs(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronList, map[string]any{})
|
|
m.handleList(context.Background(), client, req)
|
|
// No panic = success
|
|
}
|
|
|
|
func TestCronList_WithJobs_ReturnsAll(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.addJob("job-1", "my-job", "user-x")
|
|
svc.addJob("job-2", "another-job", "user-y")
|
|
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronList, map[string]any{"includeDisabled": false})
|
|
m.handleList(context.Background(), client, req)
|
|
// No panic = success
|
|
}
|
|
|
|
// ---- Tests: handleCreate ----
|
|
|
|
func TestCronCreate_MissingName_ReturnsInvalidRequest(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
|
|
"message": "hello",
|
|
"schedule": map[string]any{"kind": "every", "everyMs": 60000},
|
|
})
|
|
m.handleCreate(context.Background(), client, req)
|
|
// No panic = name-required error path hit
|
|
}
|
|
|
|
func TestCronCreate_MissingMessage_ReturnsInvalidRequest(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
|
|
"name": "my-job",
|
|
"schedule": map[string]any{"kind": "every", "everyMs": 60000},
|
|
// message intentionally omitted
|
|
})
|
|
m.handleCreate(context.Background(), client, req)
|
|
// No panic = message-required error path hit
|
|
}
|
|
|
|
func TestCronCreate_InvalidSlug_ReturnsInvalidRequest(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
// Name contains uppercase — invalid slug
|
|
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
|
|
"name": "My Invalid Name!",
|
|
"message": "do something",
|
|
"schedule": map[string]any{"kind": "every", "everyMs": 60000},
|
|
})
|
|
m.handleCreate(context.Background(), client, req)
|
|
// No panic = invalid-slug error path hit
|
|
}
|
|
|
|
func TestCronCreate_ValidParams_CreatesJob(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
|
|
"name": "my-daily-job",
|
|
"message": "do the thing",
|
|
"schedule": map[string]any{"kind": "every", "everyMs": 3600000},
|
|
})
|
|
m.handleCreate(context.Background(), client, req)
|
|
// No panic = successful create path hit
|
|
if svc.addedJob == nil {
|
|
t.Error("expected AddJob to be called")
|
|
}
|
|
if svc.addedJob != nil && svc.addedJob.Name != "my-daily-job" {
|
|
t.Errorf("job name = %q, want %q", svc.addedJob.Name, "my-daily-job")
|
|
}
|
|
}
|
|
|
|
// Regression (B24:2794): when the store's AddJob returns (nil, nil) — the
|
|
// insert succeeded but the tenant-scoped readback failed — handleCreate must
|
|
// NOT dereference the nil job (previously panicked at job.ID). It must return
|
|
// an error response and never call UpdateJob with a nil job.
|
|
func TestCronCreate_NilJobFromStore_NoPanic_ReturnsError(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.addNilJob = true
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
|
|
"name": "nil-job",
|
|
"message": "do the thing",
|
|
"schedule": map[string]any{"kind": "every", "everyMs": 60000},
|
|
})
|
|
m.handleCreate(context.Background(), client, req)
|
|
// No panic = nil-guard hit before job.ID dereference.
|
|
if svc.updateCnt != 0 {
|
|
t.Fatalf("UpdateJob called %d times after nil job, want 0", svc.updateCnt)
|
|
}
|
|
}
|
|
|
|
// ---- Tests: handleDelete ----
|
|
|
|
func TestCronDelete_MissingJobID_ReturnsInvalidRequest(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{})
|
|
m.handleDelete(context.Background(), client, req)
|
|
// No panic = jobId-required error path hit
|
|
}
|
|
|
|
func TestCronDelete_UserOwnsJob_DeletesJob(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
// nullClient().UserID() returns "" — match the job's userID to that so ownership check passes.
|
|
svc.addJob("del-job", "to-delete", "")
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
|
|
req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{"jobId": "del-job"})
|
|
m.handleDelete(context.Background(), client, req)
|
|
|
|
if _, exists := svc.jobs["del-job"]; exists {
|
|
t.Error("expected job to be deleted from store")
|
|
}
|
|
}
|
|
|
|
func TestCronDelete_NonExistentJob_NonAdminPath_ReturnsUnauthorized(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
// No job with that ID exists — non-admin client will get unauthorized
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{"jobId": "ghost-job"})
|
|
m.handleDelete(context.Background(), client, req)
|
|
// No panic = unauthorized path hit
|
|
}
|
|
|
|
// ---- Tests: handleToggle ----
|
|
|
|
func TestCronToggle_MissingJobID_ReturnsInvalidRequest(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"enabled": true})
|
|
m.handleToggle(context.Background(), client, req)
|
|
// No panic = jobId-required error path hit
|
|
}
|
|
|
|
func TestCronToggle_BlocksCredentialBoundEnableByDifferentUser(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.jobs["job-1"] = &store.CronJob{
|
|
ID: "job-1",
|
|
UserID: "",
|
|
Payload: store.CronPayload{
|
|
CredentialUserID: "tenant-user-a",
|
|
},
|
|
}
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
|
|
|
|
req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": true})
|
|
m.handleToggle(ctx, client, req)
|
|
|
|
if svc.enableCnt != 0 {
|
|
t.Fatalf("EnableJob called %d times, want 0", svc.enableCnt)
|
|
}
|
|
}
|
|
|
|
func TestCronToggle_AllowsCredentialBoundDisableByDifferentUser(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.jobs["job-1"] = &store.CronJob{
|
|
ID: "job-1",
|
|
UserID: "",
|
|
Payload: store.CronPayload{
|
|
CredentialUserID: "tenant-user-a",
|
|
},
|
|
}
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
|
|
|
|
req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": false})
|
|
m.handleToggle(ctx, client, req)
|
|
|
|
if svc.enableCnt != 1 {
|
|
t.Fatalf("EnableJob called %d times, want 1", svc.enableCnt)
|
|
}
|
|
}
|
|
|
|
func TestCronUpdate_BlocksCredentialBoundJobByDifferentUser(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.jobs["job-1"] = &store.CronJob{
|
|
ID: "job-1",
|
|
UserID: "",
|
|
Payload: store.CronPayload{
|
|
CredentialUserID: "tenant-user-a",
|
|
},
|
|
}
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
|
|
|
|
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
|
|
"jobId": "job-1",
|
|
"patch": map[string]any{"message": "run gh issue list"},
|
|
})
|
|
m.handleUpdate(ctx, client, req)
|
|
|
|
if svc.updateCnt != 0 {
|
|
t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt)
|
|
}
|
|
}
|
|
|
|
func TestCronRun_BlocksCredentialBoundJobByDifferentUser(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.jobs["job-1"] = &store.CronJob{
|
|
ID: "job-1",
|
|
UserID: "",
|
|
Payload: store.CronPayload{
|
|
CredentialUserID: "tenant-user-a",
|
|
},
|
|
}
|
|
m := buildCronMethods(t, svc)
|
|
client := nullClient()
|
|
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
|
|
|
|
req := cronReqFrame(t, protocol.MethodCronRun, map[string]any{"jobId": "job-1", "mode": "force"})
|
|
m.handleRun(ctx, client, req)
|
|
|
|
if svc.runCnt != 0 {
|
|
t.Fatalf("RunJob called %d times, want 0", svc.runCnt)
|
|
}
|
|
}
|
|
|
|
// ---- Tests: handleUpdate command gate ----
|
|
|
|
// A normal job must not be mutable into a command job when the gateway has
|
|
// command cron disabled.
|
|
func TestCronUpdate_BlocksCommandWhenDisabled(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""}
|
|
m := buildCronMethods(t, svc) // cfg.Cron.CommandEnabled defaults to false
|
|
client := nullClient()
|
|
|
|
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
|
|
"jobId": "job-1",
|
|
"patch": map[string]any{"command": map[string]any{"argv": []any{"echo", "hi"}}},
|
|
})
|
|
m.handleUpdate(context.Background(), client, req)
|
|
|
|
if svc.updateCnt != 0 {
|
|
t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt)
|
|
}
|
|
}
|
|
|
|
// Update must validate the command spec; an empty argv must be rejected even
|
|
// when command cron is enabled.
|
|
func TestCronUpdate_RejectsInvalidCommandSpec(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""}
|
|
cfg := &config.Config{}
|
|
cfg.Cron.CommandEnabled = true
|
|
m := NewCronMethods(svc, &stubEventPub{}, cfg)
|
|
client := nullClient()
|
|
|
|
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
|
|
"jobId": "job-1",
|
|
"patch": map[string]any{"command": map[string]any{"argv": []any{}}}, // empty argv → invalid
|
|
})
|
|
m.handleUpdate(context.Background(), client, req)
|
|
|
|
if svc.updateCnt != 0 {
|
|
t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt)
|
|
}
|
|
}
|
|
|
|
// A valid command payload on update is accepted when command cron is enabled.
|
|
func TestCronUpdate_EnabledValidCommand_Updates(t *testing.T) {
|
|
svc := newStubCronStore()
|
|
svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""}
|
|
cfg := &config.Config{}
|
|
cfg.Cron.CommandEnabled = true
|
|
m := NewCronMethods(svc, &stubEventPub{}, cfg)
|
|
client := nullClient()
|
|
|
|
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
|
|
"jobId": "job-1",
|
|
"patch": map[string]any{"command": map[string]any{"argv": []any{"df", "-h"}}},
|
|
})
|
|
m.handleUpdate(context.Background(), client, req)
|
|
|
|
if svc.updateCnt != 1 {
|
|
t.Fatalf("UpdateJob called %d times, want 1", svc.updateCnt)
|
|
}
|
|
}
|