294 lines
15 KiB
Go
294 lines
15 KiB
Go
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
|
|
}
|