mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
201 lines
7.3 KiB
Go
201 lines
7.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package event
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/cmd/event/render"
|
|
"github.com/larksuite/cli/errs"
|
|
appconsume "github.com/larksuite/cli/internal/event/application/consume"
|
|
"github.com/larksuite/cli/internal/event/catalog"
|
|
)
|
|
|
|
const blockedTestKey = "im.message.receive_v1"
|
|
|
|
// blockingPreflight reports one blocked precondition, the shape a real
|
|
// preflight produces when credentials cannot be used.
|
|
type blockingPreflight struct {
|
|
name string
|
|
blockErr error
|
|
}
|
|
|
|
func (p *blockingPreflight) Read(context.Context, *catalog.Entry, string) ([]appconsume.Precondition, error) {
|
|
return []appconsume.Precondition{
|
|
{Name: "console_event_published", Status: appconsume.PreconditionOK},
|
|
{
|
|
Name: p.name,
|
|
Status: appconsume.PreconditionBlocked,
|
|
Detail: p.blockErr.Error(),
|
|
BlockErr: p.blockErr,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
type fixedIdentity string
|
|
|
|
func (i fixedIdentity) Resolve(context.Context, *catalog.Entry) (string, error) {
|
|
return string(i), nil
|
|
}
|
|
|
|
// spyRunner records whether the delivery stream was ever started.
|
|
type spyRunner struct{ started bool }
|
|
|
|
func (r *spyRunner) Run(context.Context, appconsume.PrepareFunc) error {
|
|
r.started = true
|
|
return nil
|
|
}
|
|
|
|
func blockedDecisionFixture(t *testing.T) (*catalog.Entry, *appconsume.Service, *appconsume.Decision, error, error) {
|
|
t.Helper()
|
|
snap := compileCatalog()
|
|
entry, ok := snap.Resolve(blockedTestKey)
|
|
if !ok {
|
|
t.Fatalf("catalog has no %s", blockedTestKey)
|
|
}
|
|
blockErr := errs.NewPermissionError(errs.SubtypeMissingScope,
|
|
"missing scopes for %s", blockedTestKey).
|
|
WithMissingScopes("im:message", "im:chat:readonly").
|
|
WithHint("run `lark-cli auth login --scope im:message` and retry")
|
|
|
|
svc := &appconsume.Service{
|
|
Strategies: appconsume.DefaultRegistry(),
|
|
Identity: fixedIdentity("bot"),
|
|
Preflight: &blockingPreflight{name: "credentials_available", blockErr: blockErr},
|
|
}
|
|
decision, err := svc.Decide(context.Background(), entry, appconsume.Request{EventKey: blockedTestKey}, appconsume.ExecutionContext{})
|
|
return entry, svc, decision, blockErr, err
|
|
}
|
|
|
|
// The dry-run envelope for a blocked decision is a contract an orchestrator
|
|
// reads: ok stays true and the exit code stays 0, because the preview itself
|
|
// succeeded, and "would this run" is answered inside the payload. Anything that
|
|
// only checks ok or the exit code would treat a refusal as a green light, so
|
|
// the status and the blocked precondition must be present and named.
|
|
func TestBlockedDecision_DryRunEnvelopeStatesTheRefusal(t *testing.T) {
|
|
_, _, decision, _, err := blockedDecisionFixture(t)
|
|
if err != nil {
|
|
t.Fatalf("deciding must succeed even when a precondition blocks, got: %v", err)
|
|
}
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
if err := render.WriteDecisionJSON(&stdout, &stderr, "bot", decision.View()); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
DryRun bool `json:"dry_run"`
|
|
Data struct {
|
|
Decision struct {
|
|
Status string `json:"status"`
|
|
Preconditions []struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Detail string `json:"detail"`
|
|
Subtype string `json:"subtype"`
|
|
Hint string `json:"hint"`
|
|
MissingScopes []string `json:"missing_scopes"`
|
|
} `json:"preconditions"`
|
|
WouldWrite []string `json:"would_write"`
|
|
} `json:"decision"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("stdout is not a decision envelope: %v\n%s", err, stdout.String())
|
|
}
|
|
|
|
if !envelope.OK || !envelope.DryRun {
|
|
t.Errorf("the preview succeeded, so ok and dry_run stay true; got: %s", stdout.String())
|
|
}
|
|
if envelope.Data.Decision.Status != "blocked" {
|
|
t.Errorf("status = %q, want \"blocked\": this is the only field that tells a caller the real run would refuse",
|
|
envelope.Data.Decision.Status)
|
|
}
|
|
|
|
var blocked *struct {
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Detail string `json:"detail"`
|
|
Subtype string `json:"subtype"`
|
|
Hint string `json:"hint"`
|
|
MissingScopes []string `json:"missing_scopes"`
|
|
}
|
|
for i := range envelope.Data.Decision.Preconditions {
|
|
if envelope.Data.Decision.Preconditions[i].Status == "blocked" {
|
|
blocked = &envelope.Data.Decision.Preconditions[i]
|
|
}
|
|
}
|
|
if blocked == nil {
|
|
t.Fatalf("a blocked decision must name the precondition that blocks it, got: %s", stdout.String())
|
|
}
|
|
if blocked.Name != "credentials_available" {
|
|
t.Errorf("blocked precondition = %q, want the one the preflight reported", blocked.Name)
|
|
}
|
|
if blocked.Detail == "" {
|
|
t.Error("a blocked precondition must carry a detail; without it the caller knows only that something failed")
|
|
}
|
|
// The preview is read before acting, so it has to say what to do about the
|
|
// refusal — the same recovery information a real run puts in its error
|
|
// envelope, in the same machine-readable shape.
|
|
if blocked.Subtype != string(errs.SubtypeMissingScope) {
|
|
t.Errorf("subtype = %q, want the classification callers branch on", blocked.Subtype)
|
|
}
|
|
if blocked.Hint == "" {
|
|
t.Error("a blocked precondition must carry the recovery hint; the preview is the surface an agent reads before acting")
|
|
}
|
|
if len(blocked.MissingScopes) != 2 {
|
|
t.Errorf("missing_scopes = %v, want the concrete scopes to grant", blocked.MissingScopes)
|
|
}
|
|
// A precondition that passed has nothing to recover from and must stay bare.
|
|
for _, pc := range envelope.Data.Decision.Preconditions {
|
|
if pc.Status == "ok" && (pc.Subtype != "" || pc.Hint != "" || len(pc.MissingScopes) > 0) {
|
|
t.Errorf("a passing precondition must carry no recovery fields, got %+v", pc)
|
|
}
|
|
}
|
|
// Declared write side effects stay declarations in a preview.
|
|
if len(envelope.Data.Decision.WouldWrite) == 0 {
|
|
t.Error("the preview must still declare what a real run would write")
|
|
}
|
|
}
|
|
|
|
// Executing a blocked decision returns the preflight's own error and never
|
|
// starts the stream. Everything the command sets up for a live run — the stdin
|
|
// EOF watcher included — hangs off the runner for this reason: started earlier,
|
|
// it announced "stdin closed — shutting down" on a run that was actually
|
|
// refused for an unmet precondition.
|
|
func TestBlockedDecision_ExecuteNeverStartsTheStream(t *testing.T) {
|
|
entry, svc, decision, blockErr, err := blockedDecisionFixture(t)
|
|
if err != nil {
|
|
t.Fatalf("decide: %v", err)
|
|
}
|
|
|
|
runner := &spyRunner{}
|
|
execErr := svc.Execute(context.Background(), entry, decision, runner, appconsume.ExecutionContext{})
|
|
|
|
if runner.started {
|
|
t.Error("a blocked decision must not start the delivery stream")
|
|
}
|
|
problem, ok := errs.ProblemOf(execErr)
|
|
if !ok {
|
|
t.Fatalf("executing a blocked decision must return the preflight's typed error, got: %v", execErr)
|
|
}
|
|
if problem.Subtype != errs.SubtypeMissingScope {
|
|
t.Errorf("subtype = %q, want the preflight's own subtype preserved", problem.Subtype)
|
|
}
|
|
// The caller must receive the preflight's own error, not a copy of its
|
|
// message: the hint is what tells an operator how to recover, and a rewrap
|
|
// would drop it.
|
|
if !errors.Is(execErr, blockErr) {
|
|
t.Errorf("execute returned %v, want the preflight's own error", execErr)
|
|
}
|
|
if problem.Hint == "" {
|
|
t.Error("the recovery hint the preflight attached must survive to the caller")
|
|
}
|
|
}
|