package main import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "time" "git.cin.su/m/Assistant/contracts/authority" "git.cin.su/m/Assistant/contracts/capability" commitcontract "git.cin.su/m/Assistant/contracts/commit" "git.cin.su/m/Assistant/contracts/continuity" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/work" "git.cin.su/m/Assistant/platform/ids" ) type fakeCreatorCore struct { session identity.CreatorSession target work.TaskTarget binding identity.PrincipalBinding capabilityBinding capability.Binding capabilityErr error sessionErr error } func (f fakeCreatorCore) Session(context.Context, string) (identity.CreatorSession, error) { return f.session, f.sessionErr } func (f fakeCreatorCore) Task(context.Context, work.TaskID) (work.TaskTarget, error) { return f.target, nil } func (f fakeCreatorCore) Binding(context.Context, identity.ActorID, identity.PrincipalID) (identity.PrincipalBinding, error) { return f.binding, nil } func (f fakeCreatorCore) ActiveCapabilityBinding(context.Context, capability.CapabilityID, identity.PrincipalID) (capability.Binding, error) { return f.capabilityBinding, f.capabilityErr } type fakeCreatorContinuity struct { item continuity.ProspectiveItem err error } func (f fakeCreatorContinuity) Item(context.Context, string, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error) { return f.item, f.err } type fakeCreatorAuthority struct { grant authority.AuthorityGrant scope authority.Scope } func (f fakeCreatorAuthority) LoadGrant(context.Context, authority.GrantID) (authority.AuthorityGrant, authority.Scope, error) { return f.grant, f.scope, nil } func (f *fakeExecutionRepository) ListAuthorizedByOwner(context.Context, identity.ActorID, identity.PrincipalID, int) ([]commitcontract.AuthorizedAction, error) { if f.action.ID == "" { return []commitcontract.AuthorizedAction{}, nil } return []commitcontract.AuthorizedAction{f.action}, nil } func (f *fakeExecutionRepository) FindLatestAttempt(_ context.Context, id commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error) { for index := len(f.attempts) - 1; index >= 0; index-- { if f.attempts[index].AuthorizedID == id { return f.attempts[index], true, nil } } return commitcontract.ActionAttempt{}, false, nil } type fakeCreatorHistoryRepository struct { fakeExecutionRepository actions []commitcontract.AuthorizedAction latest map[commitcontract.AuthorizedActionID]commitcontract.ActionAttempt actorID identity.ActorID principalID identity.PrincipalID limit int listCalls int } func (f *fakeCreatorHistoryRepository) ListAuthorizedByOwner(_ context.Context, actorID identity.ActorID, principalID identity.PrincipalID, limit int) ([]commitcontract.AuthorizedAction, error) { f.actorID, f.principalID, f.limit = actorID, principalID, limit f.listCalls++ return f.actions, nil } func (f *fakeCreatorHistoryRepository) FindLatestAttempt(_ context.Context, id commitcontract.AuthorizedActionID) (commitcontract.ActionAttempt, bool, error) { attempt, ok := f.latest[id] return attempt, ok, nil } func TestCreatorCommandDerivesAuthorityAndActionFieldsServerSide(t *testing.T) { handler, repository, executor := validCreatorCommandHandler() mux := http.NewServeMux() registerCreatorCommandRoutes(mux, handler) request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/satisfy-delivered", strings.NewReader(`{ "Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3}, "ProspectiveItemID":"prospective-1","ExpectedRevision":4,"EvidenceRef":" artifact://evidence/1 " }`)) request.Header.Set("Authorization", "Bearer creator-session") response := httptest.NewRecorder() mux.ServeHTTP(response, request) if response.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) } var result creatorActionResponse if err := json.NewDecoder(response.Body).Decode(&result); err != nil { t.Fatal(err) } if result.State != commitcontract.ActionSucceeded || executor.calls != 1 { t.Fatalf("result=%#v calls=%d", result, executor.calls) } action := repository.action if action.Proposal.ActorID != "creator-actor" || action.PrincipalID != "creator-principal" || action.GrantID != creatorGrantID || action.Proposal.CapabilityID != commitcontract.CapabilityContinuityTransition || action.Proposal.Operation != commitcontract.OperationSatisfyDelivered || action.Proposal.ContinuitySubject.FromState != continuity.ProspectiveDelivered || action.Proposal.ContinuitySubject.ToState != continuity.ProspectiveSatisfied || action.Proposal.ContinuitySubject.EvidenceRef != "artifact://evidence/1" { t.Fatalf("server-derived envelope is invalid: %#v", action) } } func TestCreatorNotificationDerivesTypedEnvelopeAndLeavesContinuityState(t *testing.T) { handler, repository, executor := validCreatorCommandHandler() mux := http.NewServeMux() registerCreatorCommandRoutes(mux, handler) request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/notify-delivered", strings.NewReader(`{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"Confirm":true}`)) request.Header.Set("Authorization", "Bearer creator-session") response := httptest.NewRecorder() mux.ServeHTTP(response, request) if response.Code != http.StatusCreated || executor.calls != 1 { t.Fatalf("status=%d calls=%d body=%s", response.Code, executor.calls, response.Body.String()) } action := repository.action subject := action.Proposal.NotificationSubject if action.Proposal.Operation != commitcontract.OperationDeliverProspectiveNotification || action.Proposal.CapabilityID != commitcontract.CapabilityProspectiveNotification || subject.BindingID != "notification-binding" || subject.BindingRevision != 1 || len(subject.RequestDigest) != 64 || subject.RenderedTitle == "" || subject.RenderedMessage == "" || action.Proposal.ContinuitySubject.ProspectiveItemID != "" { t.Fatalf("invalid notification action: %#v", action) } } func TestCreatorNotificationRejectsMissingConfirmationAndHostileFields(t *testing.T) { for name, body := range map[string]string{"no confirmation": `{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4}`, "hostile destination": `{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"Confirm":true,"URL":"https://evil.invalid","Body":"arbitrary"}`} { t.Run(name, func(t *testing.T) { handler, repository, executor := validCreatorCommandHandler() mux := http.NewServeMux() registerCreatorCommandRoutes(mux, handler) request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/notify-delivered", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer creator-session") response := httptest.NewRecorder() mux.ServeHTTP(response, request) if response.Code != http.StatusBadRequest || executor.calls != 0 || repository.action.ID != "" { t.Fatalf("status=%d calls=%d", response.Code, executor.calls) } }) } } func TestCreatorCommandRejectsSessionForeignOwnerStaleStateAndRevokedGrant(t *testing.T) { tests := []struct { name string mutate func(*creatorCommandHandler) status int }{ {name: "session", mutate: func(h *creatorCommandHandler) { core := h.core.(fakeCreatorCore) core.sessionErr = errCreatorSessionDenied h.core = core }, status: http.StatusUnauthorized}, {name: "foreign owner", mutate: func(h *creatorCommandHandler) { source := h.continuity.(fakeCreatorContinuity) source.item.OwnerPrincipalID = "foreign" h.continuity = source }, status: http.StatusForbidden}, {name: "stale state", mutate: func(h *creatorCommandHandler) { source := h.continuity.(fakeCreatorContinuity) source.item.State = continuity.ProspectiveArmed h.continuity = source }, status: http.StatusConflict}, {name: "revoked grant", mutate: func(h *creatorCommandHandler) { source := h.authority.(fakeCreatorAuthority) source.grant.Active = false h.authority = source }, status: http.StatusConflict}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { handler, repository, executor := validCreatorCommandHandler() test.mutate(&handler) mux := http.NewServeMux() registerCreatorCommandRoutes(mux, handler) request := httptest.NewRequest(http.MethodPost, "/v1/creator/continuity/satisfy-delivered", strings.NewReader(`{"Target":{"TaskID":"task-1","GoalID":"goal-1","ScopeID":"scope-1","Revision":3},"ProspectiveItemID":"prospective-1","ExpectedRevision":4,"EvidenceRef":"evidence"}`)) request.Header.Set("Authorization", "Bearer creator-session") response := httptest.NewRecorder() mux.ServeHTTP(response, request) if response.Code != test.status || executor.calls != 0 || repository.action.ID != "" { t.Fatalf("status=%d calls=%d action=%#v", response.Code, executor.calls, repository.action) } }) } } func TestCreatorActionHistoryUsesSessionOwnerAndShowsMissingAttempt(t *testing.T) { session := identity.CreatorSession{ActorID: "creator-actor", PrincipalID: "creator-principal", ExpiresAtUnix: 1000} repository := &fakeCreatorHistoryRepository{ actions: []commitcontract.AuthorizedAction{ {ID: "authorized-new", PrincipalID: session.PrincipalID, AuthorizedAtUnixMillis: 20, State: commitcontract.ActionAuthorized, Proposal: commitcontract.ActionProposal{ActorID: session.ActorID, IntentID: "intent-new", Operation: commitcontract.OperationSatisfyDelivered, ContinuitySubject: continuity.SatisfyDeliveredSubject{ProspectiveItemID: "prospective-new"}}}, {ID: "authorized-pending", PrincipalID: session.PrincipalID, AuthorizedAtUnixMillis: 10, State: commitcontract.ActionAuthorized, Proposal: commitcontract.ActionProposal{ActorID: session.ActorID, IntentID: "intent-pending", Operation: commitcontract.OperationSatisfyDelivered, ContinuitySubject: continuity.SatisfyDeliveredSubject{ProspectiveItemID: "prospective-pending"}}}, }, latest: map[commitcontract.AuthorizedActionID]commitcontract.ActionAttempt{ "authorized-new": {ID: "attempt-new", AuthorizedID: "authorized-new", IntentID: "intent-new", State: commitcontract.ActionSucceeded}, }, } handler := creatorCommandHandler{core: fakeCreatorCore{session: session}, repository: repository} mux := http.NewServeMux() registerCreatorCommandRoutes(mux, handler) request := httptest.NewRequest(http.MethodGet, "/v1/creator/authorized-actions?limit=2", nil) request.Header.Set("Authorization", "Bearer creator-session") response := httptest.NewRecorder() mux.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status=%d body=%s", response.Code, response.Body.String()) } var result creatorActionHistoryResponse if err := json.NewDecoder(response.Body).Decode(&result); err != nil { t.Fatal(err) } if repository.actorID != session.ActorID || repository.principalID != session.PrincipalID || repository.limit != 2 || repository.listCalls != 1 { t.Fatalf("owner query actor=%q principal=%q limit=%d calls=%d", repository.actorID, repository.principalID, repository.limit, repository.listCalls) } if len(result.Items) != 2 || result.Items[0].State != commitcontract.ActionSucceeded || result.Items[0].ActionAttemptID != "attempt-new" || result.Items[1].State != commitcontract.ActionAuthorized || result.Items[1].ActionAttemptID != "" { t.Fatalf("history=%#v", result.Items) } if strings.Contains(response.Body.String(), "EvidenceRef") || strings.Contains(response.Body.String(), "GrantID") { t.Fatalf("history leaked restricted envelope fields: %s", response.Body.String()) } } func TestCreatorActionHistoryRejectsSessionAndInvalidLimitBeforeQuery(t *testing.T) { for _, test := range []struct { name string sessionErr error path string status int }{ {name: "session", sessionErr: errCreatorSessionDenied, path: "/v1/creator/authorized-actions", status: http.StatusUnauthorized}, {name: "zero", path: "/v1/creator/authorized-actions?limit=0", status: http.StatusBadRequest}, {name: "too large", path: "/v1/creator/authorized-actions?limit=51", status: http.StatusBadRequest}, {name: "invalid", path: "/v1/creator/authorized-actions?limit=many", status: http.StatusBadRequest}, } { t.Run(test.name, func(t *testing.T) { repository := &fakeCreatorHistoryRepository{} handler := creatorCommandHandler{core: fakeCreatorCore{session: identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, sessionErr: test.sessionErr}, repository: repository} mux := http.NewServeMux() registerCreatorCommandRoutes(mux, handler) response := httptest.NewRecorder() mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil)) if response.Code != test.status || repository.listCalls != 0 { t.Fatalf("status=%d calls=%d", response.Code, repository.listCalls) } }) } } func validCreatorCommandHandler() (creatorCommandHandler, *fakeExecutionRepository, *knownExecutor) { session := identity.CreatorSession{ActorID: "creator-actor", PrincipalID: "creator-principal", ExpiresAtUnix: 1000} target := work.TaskTarget{TaskID: "task-1", GoalID: "goal-1", ScopeID: "scope-1", Revision: 3} core := fakeCreatorCore{session: session, target: target, binding: identity.PrincipalBinding{ActorID: session.ActorID, PrincipalID: session.PrincipalID, Active: true}, capabilityBinding: capability.Binding{ID: "notification-binding", CapabilityID: commitcontract.CapabilityProspectiveNotification, OwnerPrincipalID: session.PrincipalID, Revision: 1, State: capability.BindingActive, HTTPSOrigin: "https://assistent.cin.su", DeliveryPath: "/delivery/v1/deliveries", ReceiptPath: "/delivery/v1/receipts", Protocol: capability.ProtocolIdempotentReceiptV1, SecretRef: "notification-write-v1", VerifiedAtUnixMillis: 1, Health: capability.HealthAvailable}} item := continuity.ProspectiveItem{ID: "prospective-1", OwnerPrincipalID: session.PrincipalID, ScopeID: target.ScopeID, GoalID: target.GoalID, State: continuity.ProspectiveDelivered, Revision: 4} grant := authority.AuthorityGrant{ID: creatorGrantID, ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: target.ScopeID, Class: authority.GrantClassCreator, Revision: 2, Active: true} repository := &fakeExecutionRepository{} executor := &knownExecutor{} identities := []ids.ID{"intent-generated", "authorized-generated", "attempt-generated"} return creatorCommandHandler{ core: core, continuity: fakeCreatorContinuity{item: item}, authority: fakeCreatorAuthority{grant: grant, scope: authority.Scope{ID: target.ScopeID, Global: true}}, repository: repository, executor: executor, reconciler: executor, newID: func() (ids.ID, error) { if len(identities) == 0 { return "", errors.New("identity exhaustion") } value := identities[0] identities = identities[1:] return value, nil }, now: func() time.Time { return time.UnixMilli(10) }, }, repository, executor }