mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 02:00:19 +08:00
392 lines
11 KiB
Go
392 lines
11 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package schema
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/apicatalog"
|
|
"github.com/larksuite/cli/internal/registry"
|
|
)
|
|
|
|
// validEnvelope builds a baseline valid envelope used as a starting point in
|
|
// negative tests below.
|
|
func validEnvelope() Envelope {
|
|
props := &OrderedProps{Map: map[string]Property{}}
|
|
return Envelope{
|
|
Name: "x y z",
|
|
Description: "ok",
|
|
InputSchema: &InputSchema{
|
|
Type: "object",
|
|
Properties: props,
|
|
},
|
|
OutputSchema: &OutputSchema{
|
|
Type: "object",
|
|
Properties: &OrderedProps{Map: map[string]Property{}},
|
|
},
|
|
Meta: &Meta{
|
|
EnvelopeVersion: "1.0",
|
|
AccessTokens: []string{"user"},
|
|
Risk: "read",
|
|
Danger: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestLintEnvelope_Valid(t *testing.T) {
|
|
env := validEnvelope()
|
|
errs := lintEnvelope(env)
|
|
if len(errs) != 0 {
|
|
t.Errorf("expected no errors, got: %v", errs)
|
|
}
|
|
}
|
|
|
|
func TestLintEnvelope_L1_StructuralChecks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Envelope)
|
|
wantSub string
|
|
}{
|
|
{
|
|
name: "empty name",
|
|
mutate: func(e *Envelope) { e.Name = "" },
|
|
wantSub: "name",
|
|
},
|
|
{
|
|
name: "nil InputSchema",
|
|
mutate: func(e *Envelope) { e.InputSchema = nil },
|
|
wantSub: "inputSchema",
|
|
},
|
|
{
|
|
name: "inputSchema type not object",
|
|
mutate: func(e *Envelope) { e.InputSchema.Type = "string" },
|
|
wantSub: "inputSchema.type",
|
|
},
|
|
{
|
|
name: "nil OutputSchema",
|
|
mutate: func(e *Envelope) { e.OutputSchema = nil },
|
|
wantSub: "outputSchema",
|
|
},
|
|
{
|
|
name: "nil Meta",
|
|
mutate: func(e *Envelope) { e.Meta = nil },
|
|
wantSub: "_meta",
|
|
},
|
|
{
|
|
name: "wrong envelope version",
|
|
mutate: func(e *Envelope) { e.Meta.EnvelopeVersion = "0.9" },
|
|
wantSub: "envelope_version",
|
|
},
|
|
{
|
|
name: "invalid property type",
|
|
mutate: func(e *Envelope) {
|
|
e.InputSchema.Properties.Order = []string{"x"}
|
|
e.InputSchema.Properties.Map["x"] = Property{Type: "unknown_type"}
|
|
},
|
|
wantSub: "invalid type",
|
|
},
|
|
{
|
|
name: "array missing items",
|
|
mutate: func(e *Envelope) {
|
|
e.InputSchema.Properties.Order = []string{"x"}
|
|
e.InputSchema.Properties.Map["x"] = Property{Type: "array"} // no Items
|
|
},
|
|
wantSub: "items",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
env := validEnvelope()
|
|
tt.mutate(&env)
|
|
errs := lintEnvelope(env)
|
|
if len(errs) == 0 {
|
|
t.Fatalf("expected lint error, got none")
|
|
}
|
|
found := false
|
|
for _, e := range errs {
|
|
if strings.Contains(e.Error(), tt.wantSub) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLintEnvelope_L2_TypeChecks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Envelope)
|
|
wantSub string
|
|
}{
|
|
{
|
|
name: "format binary on non-string",
|
|
mutate: func(e *Envelope) {
|
|
e.InputSchema.Properties.Order = []string{"f"}
|
|
e.InputSchema.Properties.Map["f"] = Property{Type: "integer", Format: "binary"}
|
|
},
|
|
wantSub: "format: binary",
|
|
},
|
|
{
|
|
name: "required key not in properties",
|
|
mutate: func(e *Envelope) {
|
|
e.InputSchema.Required = []string{"nonexistent"}
|
|
},
|
|
wantSub: "required",
|
|
},
|
|
{
|
|
name: "minimum >= maximum",
|
|
mutate: func(e *Envelope) {
|
|
min, max := 50.0, 10.0
|
|
e.InputSchema.Properties.Order = []string{"n"}
|
|
e.InputSchema.Properties.Map["n"] = Property{Type: "integer", Minimum: &min, Maximum: &max}
|
|
},
|
|
wantSub: "minimum",
|
|
},
|
|
{
|
|
name: "enumDescriptions length must match enum",
|
|
mutate: func(e *Envelope) {
|
|
e.InputSchema.Properties.Order = []string{"k"}
|
|
e.InputSchema.Properties.Map["k"] = Property{
|
|
Type: "string",
|
|
Enum: []interface{}{"a", "b", "c"},
|
|
EnumDescriptions: []string{"only one"}, // misaligned with 3 enum values
|
|
}
|
|
},
|
|
wantSub: "enumDescriptions",
|
|
},
|
|
{
|
|
// Regression guard: walkForL2 must recurse into the params/data
|
|
// sub-objects introduced by the 4-bucket inputSchema, not only the
|
|
// top-level Properties map.
|
|
name: "format binary on non-string inside params sub-object",
|
|
mutate: func(e *Envelope) {
|
|
e.InputSchema.Properties.Order = []string{"params"}
|
|
e.InputSchema.Properties.Map["params"] = Property{
|
|
Type: "object",
|
|
Properties: &OrderedProps{
|
|
Order: []string{"id"},
|
|
Map: map[string]Property{
|
|
"id": {Type: "integer", Format: "binary"}, // wrong: binary on integer
|
|
},
|
|
},
|
|
}
|
|
},
|
|
wantSub: "format: binary",
|
|
},
|
|
{
|
|
name: "sub-object required references missing property",
|
|
mutate: func(e *Envelope) {
|
|
e.InputSchema.Properties.Order = []string{"data"}
|
|
e.InputSchema.Properties.Map["data"] = Property{
|
|
Type: "object",
|
|
Required: []string{"ghost"}, // not in properties below
|
|
Properties: &OrderedProps{
|
|
Order: []string{"real"},
|
|
Map: map[string]Property{"real": {Type: "string"}},
|
|
},
|
|
}
|
|
},
|
|
wantSub: "ghost",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
env := validEnvelope()
|
|
tt.mutate(&env)
|
|
errs := lintEnvelope(env)
|
|
if len(errs) == 0 {
|
|
t.Fatalf("expected lint error, got none")
|
|
}
|
|
found := false
|
|
for _, e := range errs {
|
|
if strings.Contains(e.Error(), tt.wantSub) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLintEnvelope_L3_CrossFieldChecks(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*Envelope)
|
|
wantSub string
|
|
}{
|
|
{
|
|
name: "danger true but risk read",
|
|
mutate: func(e *Envelope) {
|
|
e.Meta.Danger = true
|
|
e.Meta.Risk = "read"
|
|
},
|
|
wantSub: "danger",
|
|
},
|
|
{
|
|
name: "high-risk-write without yes",
|
|
mutate: func(e *Envelope) {
|
|
e.Meta.Risk = "high-risk-write"
|
|
e.Meta.Danger = true
|
|
// no yes injection
|
|
},
|
|
wantSub: "yes",
|
|
},
|
|
{
|
|
name: "yes injected but risk not high-risk-write",
|
|
mutate: func(e *Envelope) {
|
|
e.InputSchema.Properties.Order = []string{"yes"}
|
|
e.InputSchema.Properties.Map["yes"] = Property{Type: "boolean"}
|
|
},
|
|
wantSub: "yes",
|
|
},
|
|
{
|
|
name: "empty access_tokens",
|
|
mutate: func(e *Envelope) {
|
|
e.Meta.AccessTokens = []string{}
|
|
},
|
|
wantSub: "access_tokens",
|
|
},
|
|
{
|
|
name: "invalid access_token value",
|
|
mutate: func(e *Envelope) {
|
|
e.Meta.AccessTokens = []string{"admin"}
|
|
},
|
|
wantSub: "access_tokens",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
env := validEnvelope()
|
|
tt.mutate(&env)
|
|
errs := lintEnvelope(env)
|
|
if len(errs) == 0 {
|
|
t.Fatalf("expected lint error, got none")
|
|
}
|
|
found := false
|
|
for _, e := range errs {
|
|
if strings.Contains(e.Error(), tt.wantSub) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected error containing %q, got: %v", tt.wantSub, errs)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMeasureCoverage_Counts(t *testing.T) {
|
|
envs := []Envelope{
|
|
{Description: "ok", Meta: &Meta{Scopes: []string{"s"}, Risk: "read", DocURL: "http://x"}},
|
|
{Description: "", Meta: &Meta{Scopes: []string{}, Risk: "", DocURL: ""}},
|
|
{Description: "ok2", Meta: &Meta{Scopes: []string{"s"}, Risk: "write", DocURL: "http://y"}},
|
|
}
|
|
c := measureCoverage(envs)
|
|
// 2/3 have non-empty description = ~0.667
|
|
if c["description"] < 0.66 || c["description"] > 0.67 {
|
|
t.Errorf("description coverage = %v, want ~0.667", c["description"])
|
|
}
|
|
// 2/3 have non-empty scopes
|
|
if c["scopes"] < 0.66 || c["scopes"] > 0.67 {
|
|
t.Errorf("scopes coverage = %v, want ~0.667", c["scopes"])
|
|
}
|
|
// 2/3 have doc_url
|
|
if c["doc_url"] < 0.66 || c["doc_url"] > 0.67 {
|
|
t.Errorf("doc_url coverage = %v, want ~0.667", c["doc_url"])
|
|
}
|
|
// 2/3 have non-empty risk (but our builder always fills risk with "read" default — this test uses raw envs)
|
|
if c["risk"] < 0.66 || c["risk"] > 0.67 {
|
|
t.Errorf("risk coverage = %v, want ~0.667", c["risk"])
|
|
}
|
|
}
|
|
|
|
// isKnownDataInconsistency returns true for lint errors that originate from
|
|
// real meta_data quality issues we still have to ship around in PR-1. With
|
|
// Task 17b the assembler walks embedded data only, so overlay-induced
|
|
// inconsistencies (risk-stripping) no longer appear; only the true embedded
|
|
// meta_data data-quality patterns remain.
|
|
//
|
|
// As meta_data quality improves this filter should be tightened/removed so
|
|
// TestAllEnvelopesPass becomes a hard gate again.
|
|
func isKnownDataInconsistency(msg string) bool {
|
|
switch {
|
|
case strings.Contains(msg, `L3: _meta.danger=false inconsistent with risk="write"`):
|
|
// Embedded meta_data has ~7 envelopes (e.g. attendance.user_tasks.query,
|
|
// drive.user.subscription, mail.user_mailbox.event.subscribe) where
|
|
// `risk="write"` but `danger` is missing (defaults to false). Needs a
|
|
// meta_data fix to set danger=true on these write methods.
|
|
return true
|
|
case strings.Contains(msg, `L3: _meta.danger=true inconsistent with risk="read"`):
|
|
// Embedded meta_data has ~9 envelopes (e.g. calendar.events.search_event,
|
|
// drive.metas.batch_query, mail.user_mailbox.templates.create) where
|
|
// `danger=true` but `risk` is missing (defaults to "read"). Needs a
|
|
// meta_data fix to set the proper risk level on these methods.
|
|
return true
|
|
case strings.Contains(msg, "L2: field") && strings.Contains(msg, "minimum") && strings.Contains(msg, "maximum"):
|
|
// meta_data sets min == max on some fields (e.g.
|
|
// mail.user_mailbox.event.subscribe.event_type), which the lint reads
|
|
// as min >= max. Real fix is in meta_data.
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestAllEnvelopesPass(t *testing.T) {
|
|
failCount := 0
|
|
knownWarnings := 0
|
|
knownEnvelopes := map[string]bool{}
|
|
// Use embedded data only so the gate is deterministic across machines
|
|
// (matches Task 17b: envelope assembly is overlay-independent).
|
|
for _, svc := range registry.EmbeddedServicesTyped() {
|
|
envs := Envelopes(apicatalog.ServiceMethods(svc, nil))
|
|
for _, env := range envs {
|
|
errs := lintEnvelope(env)
|
|
if len(errs) == 0 {
|
|
continue
|
|
}
|
|
var realErrs []error
|
|
for _, e := range errs {
|
|
if isKnownDataInconsistency(e.Error()) {
|
|
t.Logf("env %s skipped: known data-level inconsistency: %v", env.Name, e)
|
|
knownWarnings++
|
|
knownEnvelopes[env.Name] = true
|
|
continue
|
|
}
|
|
realErrs = append(realErrs, e)
|
|
}
|
|
if len(realErrs) > 0 {
|
|
for _, e := range realErrs {
|
|
t.Errorf("%s: %v", env.Name, e)
|
|
}
|
|
failCount++
|
|
}
|
|
}
|
|
}
|
|
t.Logf("L1-L3 known data-level inconsistencies: %d warnings across %d envelopes (danger/risk mismatch + min==max)", knownWarnings, len(knownEnvelopes))
|
|
if failCount > 0 {
|
|
t.Fatalf("%d envelopes failed L1-L3 lint with non-data-level errors", failCount)
|
|
}
|
|
|
|
// L4 coverage report (warn-only via t.Logf)
|
|
all := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil))
|
|
c := measureCoverage(all)
|
|
for metric, rate := range c {
|
|
baseline := coverageBaseline[metric]
|
|
if rate < baseline {
|
|
t.Logf("L4 coverage warn: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
|
|
} else {
|
|
t.Logf("L4 coverage ok: %s = %.1f%% (baseline: %.1f%%)", metric, rate*100, baseline*100)
|
|
}
|
|
}
|
|
}
|