Skip to content

Commit 1fb4420

Browse files
test(generate): gate a solution's committed artifact end to end; name the InterfaceDocument trap
TestTheCommittedArtifactBecomesTools runs generate contracts, runnables and runnable-bindings on the releases backend's artifact exactly as solution-runtime-go's Server.InterfaceArtifact renders it. A document that marks no route although its routes declare read-only policies now names each such read as not an operation. Core pinned to 518714d0, core#852's merge commit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 89555d6 commit 1fb4420

5 files changed

Lines changed: 929 additions & 2 deletions

File tree

‎cmd/generate/runnables.go‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package generate
33
import (
44
"cmp"
55
"context"
6+
"encoding/json"
67
"errors"
78
"fmt"
89
"os"
@@ -55,7 +56,10 @@ A solution publishes its routes as one OpenAPI document (Swagger 2.0 or
5556
OpenAPI 3), and every route marked x-codefly-operation derives the same way:
5657
solution-runtime-go marks each read the backend serves, so a GET read becomes a
5758
read-only tool whose query parameters are its input. Its directory is named by
58-
the route's operationId. No flag names a route.
59+
the route's operationId. No flag names a route. A document that marks no route
60+
although its routes declare read-only policies — rendered by the runtime's
61+
InterfaceDocument rather than Server.InterfaceArtifact, or by a runtime from
62+
before the marker — names each such read as not an operation.
5963
6064
The input is what ` + "`codefly generate contracts`" + ` already wrote — each endpoint's
6165
served procedures in the API catalog and its serialized FileDescriptorSet, or
@@ -405,6 +409,7 @@ func deriveOpenAPIEndpoint(module *resources.Module, endpoint *composition.APICo
405409
paths = append(paths, path)
406410
}
407411
sort.Strings(paths)
412+
marked := 0
408413
for _, path := range paths {
409414
item := parsed.Paths[path]
410415
if item == nil {
@@ -417,14 +422,56 @@ func deriveOpenAPIEndpoint(module *resources.Module, endpoint *composition.APICo
417422
if route.operation == nil || len(route.operation.Marker) == 0 {
418423
continue
419424
}
425+
marked++
420426
if err = deriveRoute(module, endpoint, owner, document, route.method, path, route.operation.OperationID, derived); err != nil {
421427
return err
422428
}
423429
}
424430
}
431+
if marked == 0 {
432+
derived.notices = append(derived.notices, unmarkedReads(document, endpoint)...)
433+
}
425434
return nil
426435
}
427436

437+
// unmarkedReads names the reads of a document that marks no route at all
438+
// although its routes declare policies requiring only read: the signature of
439+
// an artifact rendered by solution-runtime-go's InterfaceDocument rather than
440+
// Server.InterfaceArtifact, or by a runtime from before the marker existed.
441+
// Either way every read is silently no tool, so each is named. A document that
442+
// marks some routes and not others is the owner's choice (Operation.Exclude)
443+
// and says nothing.
444+
func unmarkedReads(document []byte, endpoint *composition.APIContractEndpoint) []string {
445+
type requirement struct {
446+
Actions []string `json:"actions"`
447+
}
448+
var parsed struct {
449+
Paths map[string]map[string]struct {
450+
Policy *struct {
451+
Requires []requirement `json:"requires"`
452+
} `json:"x-codefly-authority-policy"`
453+
} `json:"paths"`
454+
}
455+
if json.Unmarshal(document, &parsed) != nil {
456+
return nil
457+
}
458+
var notices []string
459+
for path, methods := range parsed.Paths {
460+
for method, operation := range methods {
461+
if operation.Policy == nil || len(operation.Policy.Requires) == 0 ||
462+
slices.ContainsFunc(operation.Policy.Requires, func(r requirement) bool {
463+
return len(r.Actions) != 1 || r.Actions[0] != corerunnable.ReadOnlyScopeAction
464+
}) {
465+
continue
466+
}
467+
notices = append(notices, fmt.Sprintf("%s: %s/%s requires only %q but its document marks no route x-codefly-operation; render the artifact with solution-runtime-go's Server.InterfaceArtifact (InterfaceDocument, and a runtime before the marker, mark none)",
468+
corerunnable.Route(method, path), endpoint.Service, endpoint.Endpoint, corerunnable.ReadOnlyScopeAction))
469+
}
470+
}
471+
sort.Strings(notices)
472+
return notices
473+
}
474+
428475
// deriveRoute derives one marked route. Its directory is named by the route's
429476
// operationId — the identity the document publishes it under, as a method's
430477
// is its name — or, when the document gives none, by the route itself.

‎cmd/generate/runnables_solution_test.go‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0"
1414
"github.com/codefly-dev/core/resources"
1515
corerunnable "github.com/codefly-dev/core/runnable"
16+
"github.com/codefly-dev/core/standards"
1617
"github.com/stretchr/testify/require"
1718
)
1819

@@ -263,3 +264,132 @@ func TestGenerateRunnableBindingsPreparesASolutionsReads(t *testing.T) {
263264
require.Equal(t, basev0.RunnableExecution_RECOVERY_RECOMPUTE, prepared.GetPolicy().GetRecovery())
264265
}
265266
}
267+
268+
// committedArtifact is testdata/solution-runnables/api.swagger.json: the
269+
// releases backend's interface artifact exactly as solution-runtime-go's
270+
// Server.InterfaceArtifact renders it (codefly-dev/solution-runtime-go#77) —
271+
// every operation core-solutions' releases solution serves, each with the
272+
// policy it declares on the authority runtime, and its MCP server — with no
273+
// edit. It is a snapshot of that shape; the runtime's own tests hold
274+
// InterfaceArtifact to deriving in core.
275+
func committedArtifact(t *testing.T) []byte {
276+
t.Helper()
277+
return mustRead(t, filepath.Join("testdata", "solution-runnables", "api.swagger.json"))
278+
}
279+
280+
// saveCommittedSolution places the artifact where a solution backend commits
281+
// it (openapi/api.swagger.json beside its service.codefly.yaml), so the whole
282+
// chain runs on it: generate contracts publishes it as the http endpoint's
283+
// contract, extensions and all, and generate runnables reads what that wrote.
284+
func saveCommittedSolution(t *testing.T, ctx context.Context, artifact []byte) (root, moduleDir string) {
285+
t.Helper()
286+
root, moduleDir = saveFixtureWorkspace(t, ctx, "releases",
287+
&resources.ModuleInterface{Endpoints: []*resources.InterfaceEndpoint{
288+
{Service: "backend", Endpoint: "http", Visibility: resources.VisibilityPublic},
289+
}},
290+
&resources.Service{
291+
Name: "backend",
292+
Version: "0.3.0",
293+
Agent: &resources.Agent{Kind: resources.ServiceAgent, Name: "go", Version: "0.0.70", Publisher: "codefly.dev"},
294+
Endpoints: []*resources.Endpoint{
295+
{Name: "http", API: "http", Visibility: resources.VisibilityPublic, Exposure: resources.ExposureNone},
296+
},
297+
},
298+
)
299+
writeFixtureFile(t, filepath.Join(moduleDir, "services", "backend", filepath.FromSlash(standards.OpenAPIPath)), artifact)
300+
writeFixtureFile(t, filepath.Join(moduleDir, composition.PackageManifestFileName), []byte(`schema: codefly/module-package/v2
301+
kind: module-package
302+
id: obin/releases
303+
version: 0.1.0
304+
minimum-codefly-version: ">=0.0.0"
305+
artifact-roots:
306+
- contracts
307+
- services
308+
contracts:
309+
composition: ">=2.0.0 <3.0.0"
310+
services:
311+
- name: backend
312+
endpoints: [http]
313+
`))
314+
t.Chdir(root)
315+
resetContractsFlags(t)
316+
resetRunnablesFlags(t)
317+
resetRunnableBindingsFlags(t)
318+
if out, err := captureStdout(t, func() error { return ContractsCmd.RunE(ContractsCmd, []string{"releases"}) }); err != nil {
319+
t.Fatalf("generate contracts: %v\n%s", err, out)
320+
}
321+
return root, moduleDir
322+
}
323+
324+
// TestTheCommittedArtifactBecomesTools gates the seam end to end rather than
325+
// inferring it: the runtime's own artifact survives generate contracts with its
326+
// vendor extensions, and from what that published, generate runnables derives
327+
// exactly the two reads and generate runnable-bindings prepares exactly two GET
328+
// calls. The writes, the any-checked-caller routes, the datasource read
329+
// (datasource.sources:invoke, not read) and the MCP route become nothing.
330+
func TestTheCommittedArtifactBecomesTools(t *testing.T) {
331+
ctx := context.Background()
332+
artifact := committedArtifact(t)
333+
root, moduleDir := saveCommittedSolution(t, ctx, artifact)
334+
335+
published := mustRead(t, filepath.Join(moduleDir, "contracts", "api", "backend", "http", "openapi.json"))
336+
require.Equal(t, artifact, published, "generate contracts publishes the artifact byte for byte")
337+
338+
require.NoError(t, RunnablesCmd.RunE(RunnablesCmd, []string{"releases"}))
339+
index := readIndex(t, moduleDir)
340+
methods := make([]string, 0, len(index.Operations))
341+
for _, entry := range index.Operations {
342+
methods = append(methods, entry.Method)
343+
}
344+
require.Equal(t, []string{"GET /v1/releases/history", "GET /v1/releases/recorded"}, methods)
345+
346+
require.NoError(t, RunnableBindingsCmd.RunE(RunnableBindingsCmd, nil))
347+
raw := mustRead(t, filepath.Join(root, "configurations", "local", RunnableBindingsGroup+".env"))
348+
var keys []string
349+
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
350+
key, value, found := strings.Cut(line, "=")
351+
if !found || strings.HasPrefix(line, "#") {
352+
continue
353+
}
354+
keys = append(keys, key)
355+
prepared, err := corerunnable.DecodePrepared([]byte(value))
356+
require.NoError(t, err)
357+
require.Equal(t, "GET", prepared.GetCall().GetRest().GetVerb())
358+
tool, err := corerunnable.ToolFromPrepared(prepared)
359+
require.NoError(t, err)
360+
require.Equal(t, runnablev0.ToolExposure_EFFECT_READ_ONLY, tool.GetEffect())
361+
}
362+
require.Equal(t, []string{
363+
"RELEASES__BACKEND_HTTP_RELEASES_GET_V1_RELEASES_HISTORY",
364+
"RELEASES__BACKEND_HTTP_RELEASES_GET_V1_RELEASES_RECORDED",
365+
}, keys)
366+
}
367+
368+
// TestAnArtifactThatMarksNothingNamesItsReads is the InterfaceDocument trap: an
369+
// artifact whose reads declare their policies but carry no marker — rendered by
370+
// InterfaceDocument rather than Server.InterfaceArtifact, or by a runtime from
371+
// before the marker — derives nothing, and the run says so read by read rather
372+
// than writing an empty index in silence. It is a notice, not a refusal: every
373+
// solution still on such a runtime is in exactly this state.
374+
func TestAnArtifactThatMarksNothingNamesItsReads(t *testing.T) {
375+
ctx := context.Background()
376+
var doc map[string]any
377+
require.NoError(t, json.Unmarshal(committedArtifact(t), &doc))
378+
for _, methods := range doc["paths"].(map[string]any) {
379+
for _, operation := range methods.(map[string]any) {
380+
delete(operation.(map[string]any), corerunnable.OpenAPIOperationMarker)
381+
}
382+
}
383+
unmarked, err := json.Marshal(doc)
384+
require.NoError(t, err)
385+
_, moduleDir := saveCommittedSolution(t, ctx, unmarked)
386+
387+
out, err := captureStdout(t, func() error { return RunnablesCmd.RunE(RunnablesCmd, []string{"releases"}) })
388+
require.NoError(t, err)
389+
require.Empty(t, readIndex(t, moduleDir).Operations)
390+
for _, read := range []string{"GET /v1/releases/history", "GET /v1/releases/recorded"} {
391+
require.Contains(t, out, read+": backend/http requires only \"read\" but its document marks no route x-codefly-operation")
392+
}
393+
require.Contains(t, out, "Server.InterfaceArtifact")
394+
require.NotContains(t, out, "POST /v1/releases/record:", "a write is not a read the trap lost")
395+
}

0 commit comments

Comments
 (0)