package controlplane import ( "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "time" ) func TestCancellationLeaseRenewalAndExpiredGeneration(t *testing.T) { server, err := NewServer(ServerConfig{ DataFile: filepath.Join(t.TempDir(), "state.json"), NodeTokens: map[string]string{"node-1": "node-secret"}, WebUsers: map[string]string{"admin": "web-secret"}, LeaseTTL: time.Minute, }) if err != nil { t.Fatal(err) } httpServer := httptest.NewServer(server.Handler()) defer httpServer.Close() client := httpServer.Client() login := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/auth/login", "", map[string]string{"username": "admin", "password": "web-secret"}) var session struct { AccessToken string `json:"access_token"` } decodeBody(t, login, &session) webAuth := "Bearer " + session.AccessToken registration := NodeRegistration{NodeID: "node-1", AgentVersion: "test", ProtocolVersion: ProtocolVersion, Accounts: []AccountSummary{{AccountID: "account-a", Active: true, Verified: true}}} if response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/register", "Bearer node-secret", registration); response.Code != http.StatusOK { t.Fatal(response.Code) } create := func(key string) TaskSubmissionResponse { response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks", webAuth, TaskSubmission{ NodeID: "node-1", AccountID: "account-a", Kind: "send-text", IdempotencyKey: key, Payload: json.RawMessage(`{"target_id":"target","text":"text","confirmed":true}`), }) var result TaskSubmissionResponse decodeBody(t, response, &result) return result } cancelled := create("cancel-before-ack") poll := doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/node-1/tasks?account_id=account-a", "Bearer node-secret", nil) var batch TaskBatch decodeBody(t, poll, &batch) if len(batch.Tasks) != 1 { t.Fatalf("expected one task, got %d", len(batch.Tasks)) } lease := batch.Tasks[0] cancel := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/tasks/"+cancelled.TaskID+"/cancel", webAuth, nil) var cancelledTask Task decodeBody(t, cancel, &cancelledTask) if cancelledTask.Status != TaskPending || cancelledTask.CancelRequestedAt == nil { t.Fatalf("cancel mutated primary state: %+v", cancelledTask) } ack := TaskAck{TaskID: lease.TaskID, AccountID: lease.AccountID, LeaseGeneration: lease.LeaseGeneration} ackResponse := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+lease.TaskID+"/ack", "Bearer node-secret", ack) var acked Task decodeBody(t, ackResponse, &acked) if acked.Status != TaskCancelled { t.Fatalf("cancel was not confirmed at safe ack point: %+v", acked) } running := create("renew-and-stale") poll = doJSON(t, client, http.MethodGet, httpServer.URL+"/v1/nodes/node-1/tasks?account_id=account-a", "Bearer node-secret", nil) batch = TaskBatch{} decodeBody(t, poll, &batch) var runningLease Task for _, task := range batch.Tasks { if task.TaskID == running.TaskID { runningLease = task } } if runningLease.TaskID == "" { t.Fatal("running task was not offered") } ack = TaskAck{TaskID: runningLease.TaskID, AccountID: runningLease.AccountID, LeaseGeneration: runningLease.LeaseGeneration} for _, phase := range []string{"ack", "start", "renew"} { response := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+runningLease.TaskID+"/"+phase, "Bearer node-secret", ack) if response.Code != http.StatusOK { t.Fatalf("%s status = %d: %s", phase, response.Code, response.Body.String()) } } stale := ack stale.LeaseGeneration++ staleResponse := doJSON(t, client, http.MethodPost, httpServer.URL+"/v1/nodes/node-1/tasks/"+runningLease.TaskID+"/result", "Bearer node-secret", TaskResult{ TaskID: runningLease.TaskID, AccountID: runningLease.AccountID, LeaseGeneration: stale.LeaseGeneration, Status: TaskSucceeded, CorrelationID: "stale", }) if staleResponse.Code != http.StatusConflict { t.Fatalf("stale result status = %d", staleResponse.Code) } }