package main import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "testing" "git.cin.su/m/Assistant/adapters/notificationexecutor" "git.cin.su/m/Assistant/contracts/capability" commitcontract "git.cin.su/m/Assistant/contracts/commit" "git.cin.su/m/Assistant/contracts/identity" capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain" ) func TestCandidateNotificationCredentialsDoNotFallbackToProductionCredential(t *testing.T) { path := t.TempDir() + "/candidate-token" if err := os.WriteFile(path, []byte("candidate-only-token"), 0o600); err != nil { t.Fatal(err) } t.Setenv("CANDIDATE_NOTIFICATION_WRITE_TOKEN_FILE", path) t.Setenv("CANDIDATE_NOTIFICATION_WRITE_SECRET_REF", "credential://domovoy/candidate/notification-write/v1") ref, secret, err := notificationCredentialsFromRuntime(true) if err != nil || ref == "" || secret != "candidate-only-token" { t.Fatalf("candidate credential resolution failed: ref=%q err=%v", ref, err) } t.Setenv("CANDIDATE_NOTIFICATION_WRITE_TOKEN_FILE", "") if _, _, err := notificationCredentialsFromRuntime(true); err == nil { t.Fatal("candidate path fell back to production notification credential") } } type fakeNotificationBindings struct { exact capability.Binding active capability.Binding exactErr error activeErr error candidate capability.CandidateBindingResolution candidateErr error } func (f fakeNotificationBindings) CapabilityBinding(context.Context, capability.CapabilityBindingID, int64) (capability.Binding, error) { return f.exact, f.exactErr } func (f fakeNotificationBindings) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) { return f.active, f.activeErr } func (f fakeNotificationBindings) ResolveCandidateCapabilityBinding(context.Context, capability.CandidateReleaseContext) (capability.CandidateBindingResolution, error) { return f.candidate, f.candidateErr } func dispatcherAction(t *testing.T, binding capability.Binding) commitcontract.AuthorizedAction { t.Helper() payload := notificationexecutor.Payload{ActionIntentID: "intent-1", TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: "item-1", ExpectedRevision: 1, Title: "Домовой", Message: "Пора вернуться"} digest, err := notificationexecutor.Digest(payload) if err != nil { t.Fatal(err) } return commitcontract.AuthorizedAction{PrincipalID: binding.OwnerPrincipalID, Proposal: commitcontract.ActionProposal{IntentID: payload.ActionIntentID, CapabilityID: commitcontract.CapabilityProspectiveNotification, Operation: commitcontract.OperationDeliverProspectiveNotification, NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: "item-1", ExpectedRevision: 1, TemplateCode: payload.TemplateCode, BindingID: binding.ID, BindingRevision: binding.Revision, RenderedTitle: payload.Title, RenderedMessage: payload.Message, RequestDigest: digest}}} } func dispatcherBinding(origin string) capability.Binding { return capability.Binding{ID: "binding-1", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: "owner-1", Revision: 1, State: capability.BindingActive, HTTPSOrigin: origin, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable} } func TestOperationDispatcherRequiresExactCurrentBinding(t *testing.T) { var action commitcontract.AuthorizedAction server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1}) })) defer server.Close() binding := dispatcherBinding(server.URL) action = dispatcherAction(t, binding) fallback := &knownExecutor{} dispatcher := operationDispatcher{continuityExecutor: fallback, continuityReconciler: fallback, bindings: fakeNotificationBindings{exact: binding, active: binding}, secretRef: "write-v1", secret: "secret", client: server.Client()} if result := dispatcher.Execute(action); !result.Known || !result.Succeeded { t.Fatalf("execute=%+v", result) } stale := binding stale.Revision = 2 dispatcher.bindings = fakeNotificationBindings{exact: binding, active: stale} if result := dispatcher.Execute(action); !result.Known || result.Succeeded { t.Fatalf("stale binding=%+v", result) } } func TestOperationDispatcherReconcilesRetiredHistoricalBinding(t *testing.T) { var action commitcontract.AuthorizedAction server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Fatal("reconciliation mutated receiver") } _ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "receipt-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1}) })) defer server.Close() binding := dispatcherBinding(server.URL) action = dispatcherAction(t, binding) binding.State = capability.BindingRetired binding.Revision = 1 fallback := &knownExecutor{} dispatcher := operationDispatcher{continuityExecutor: fallback, continuityReconciler: fallback, bindings: fakeNotificationBindings{exact: binding}, secretRef: "write-v1", secret: "secret", client: server.Client()} if result := dispatcher.Reconcile(action); !result.Known || !result.Succeeded { t.Fatalf("reconcile=%+v", result) } } func TestCandidateDispatcherUsesShadowBindingWithoutActiveFallback(t *testing.T) { var action commitcontract.AuthorizedAction server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("X-Candidate-Release") != "assistant-slot-b" || r.Header.Get("X-Candidate-Task") != "GEN2-CAPABILITY-001" { t.Fatalf("candidate headers missing") } _ = json.NewEncoder(w).Encode(notificationexecutor.Receipt{ReceiptID: "shadow-1", ActionIntentID: action.Proposal.IntentID, RequestDigest: action.Proposal.NotificationSubject.RequestDigest, AcceptedAt: 1, DeliveryMode: "SHADOW"}) })) defer server.Close() stable := dispatcherBinding("https://stable.invalid") action = dispatcherAction(t, stable) context := capability.CandidateReleaseContext{ReleaseSetID: "assistant-slot-b", Slot: "b", TaskID: "GEN2-CAPABILITY-001", TaskRevision: 7, PrincipalID: stable.OwnerPrincipalID, CapabilityID: stable.CapabilityID, ResourceScope: "synthetic-shadow-notification-v1", Mode: capability.CandidateBindingModeShadow, TargetKind: "NOTIFICATION_SHADOW_RECEIVER", TargetRef: "notification-receiver-candidate", AuthorityGrantID: "grant"} candidate := capability.CandidateBinding{ID: "candidate-1", Revision: 1, BaseActiveBindingID: stable.ID, BaseActiveBindingRevision: stable.Revision, Context: context, TargetOrigin: server.URL, DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, CreatedAtUnixMillis: 1, ExpiresAtUnixMillis: 100, Status: capability.CandidateBindingActive} digest, err := capabilitydomain.CandidateTargetDigest(candidate) if err != nil { t.Fatal(err) } context.TargetDigest, candidate.Context.TargetDigest = digest, digest dispatcher := operationDispatcher{continuityExecutor: &knownExecutor{}, continuityReconciler: &knownExecutor{}, bindings: fakeNotificationBindings{activeErr: errors.New("stable fallback must not run"), candidate: capability.CandidateBindingResolution{Binding: candidate, Mode: capability.CandidateBindingModeShadow}}, secret: "shadow-secret", client: server.Client(), candidateContext: &context} result := dispatcher.Execute(action) if !result.Known || !result.Succeeded || result.Receipt == nil || result.Receipt.DeliveryMode != "SHADOW" { t.Fatalf("candidate result=%+v", result) } }