Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/graph/base.resolvers.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 32 additions & 28 deletions internal/graph/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion internal/graph/mcp_tools_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

130 changes: 130 additions & 0 deletions internal/graph/null_propagation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package graph

import (
"context"
"math/big"
"net/http"
"testing"
"time"

"github.com/99designs/gqlgen/client"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/transport"
chconfig "github.com/DIMO-Network/clickhouse-infra/pkg/connect/config"
"github.com/DIMO-Network/clickhouse-infra/pkg/container"
"github.com/DIMO-Network/cloudevent"
chindexer "github.com/DIMO-Network/cloudevent/clickhouse"
"github.com/DIMO-Network/cloudevent/clickhouse/migrations"
"github.com/DIMO-Network/fetch-api/pkg/eventrepo"
"github.com/DIMO-Network/token-exchange-api/pkg/tokenclaims"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestLatestCloudEvent_AliasedEmptySourceDoesNotTankSiblings is the regression
// test for the nickname-summary bug: getName batches per-source `latestCloudEvent`
// lookups as GraphQL aliases in one request. When `latestCloudEvent` (and
// `latestIndex`) were non-nullable, a source with no matching event returned an
// error which GraphQL null-propagated up to the root `data`, wiping every
// sibling alias that DID resolve — so a vehicle whose owner had a nickname but
// whose dev-license source did not returned no name at all.
//
// The fields are now nullable and the resolvers return null (not an error) on
// no-rows, so one empty source must leave its siblings intact. Exercised against
// `latestIndex` because it needs only ClickHouse (no blob buckets), but the fix
// and codepath are identical for `latestCloudEvent`.
func TestLatestCloudEvent_AliasedEmptySourceDoesNotTankSiblings(t *testing.T) {
ctx := context.Background()

ch, err := container.CreateClickHouseContainer(ctx, chconfig.Settings{User: "default", Database: "dimo"})
require.NoError(t, err)
t.Cleanup(func() { ch.Terminate(context.Background()) })

chDB, err := ch.GetClickhouseAsDB()
require.NoError(t, err)
require.NoError(t, migrations.RunGoose(ctx, []string{"up"}, chDB))

conn, err := ch.GetClickHouseAsConn()
require.NoError(t, err)

did := cloudevent.ERC721DID{
ChainID: 137,
ContractAddress: common.HexToAddress("0xbA5738a18d83D41847dfFbDC6101d37C69c9B0cF"),
TokenID: big.NewInt(22892),
}.String()

const (
nickType = "dimo.document.nickname"
ownerSource = "0x3Afb7b82D912743F8Fd15f67a9b2095e9d5AD576" // has an event
devSource = "0x299671D2b32ED62Cc61ce65D8f2b9e4f78486B37" // no event
)

// Only the owner source has a nickname event.
ownerEvent := &cloudevent.CloudEventHeader{
ID: "owner-nickname-evt",
Source: ownerSource,
Subject: did,
Type: nickType,
Time: time.Now(),
DataVersion: "nickname/v1.0.0",
}
require.NoError(t, conn.Exec(ctx, chindexer.InsertStmt, chindexer.CloudEventToSlice(ownerEvent)...))

svc := eventrepo.New(conn, nil, nil, "", "")
es := NewExecutableSchema(Config{Resolvers: &Resolver{EventService: svc}})
srv := handler.New(es)
srv.AddTransport(transport.Options{})
srv.AddTransport(transport.POST{})

gql := client.New(injectClaims(srv, did))

const query = `
query($did: String!, $owner: CloudEventFilter, $dev: CloudEventFilter) {
owner: latestIndex(did: $did, filter: $owner) { header { source type } }
dev: latestIndex(did: $did, filter: $dev) { header { source } }
}`

var resp struct {
Owner *struct {
Header struct {
Source string
Type string
}
}
Dev *struct {
Header struct{ Source string }
}
}

err = gql.Post(
query,
&resp,
client.Var("did", did),
client.Var("owner", map[string]any{"source": ownerSource, "type": nickType}),
client.Var("dev", map[string]any{"source": devSource, "type": nickType}),
)

// Before the fix: the empty `dev` alias erred on a non-null field, the null
// propagated to root `data`, and this Post returned a GraphQL error with no
// data — the owner nickname was lost.
require.NoError(t, err, "empty source must not tank sibling aliases")
require.NotNil(t, resp.Owner, "owner's event must survive a sibling empty source")
assert.Equal(t, ownerSource, resp.Owner.Header.Source)
assert.Equal(t, nickType, resp.Owner.Header.Type)
assert.Nil(t, resp.Dev, "a source with no event must resolve to null, not error")
}

// injectClaims wraps a handler with the raw-data token the resolvers require,
// scoped to `asset` (the DID under test) so requireSubjectOptsByDID admits it.
func injectClaims(next http.Handler, asset string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok := &tokenclaims.Token{
CustomClaims: tokenclaims.CustomClaims{
Asset: asset,
Permissions: []string{tokenclaims.PermissionGetRawData},
},
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ClaimsContextKey{}, tok)))
})
}
20 changes: 12 additions & 8 deletions schema/base.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ The root query type for the Fetch API GraphQL schema. ERC721 DID (e.g. did:eth:c
"""
type Query {
"""
Latest cloud event index matching filters. Tombstoned attestations are
hidden from the result by default; pass includeDeleted: true to disable
tombstone suppression.
Latest cloud event index matching filters, or null when none match.
Nullable so an aliased multi-source query with an empty source resolves to
null instead of erroring and null-propagating over its siblings. Tombstoned
attestations are hidden from the result by default; pass includeDeleted:
true to disable tombstone suppression.
"""
latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex!
latestIndex(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEventIndex
@mcpTool(
name: "get_latest_index"
description: "Get the latest CloudEvent index entry (header + storage key) for a DID-scoped subject, optionally filtered by event type, source, producer, or time range. Returns metadata only — use fetch_get_latest_cloud_event for the full payload."
Expand All @@ -65,11 +67,13 @@ type Query {
)

"""
Latest full cloud event. Tombstoned attestations are hidden from the
result by default; pass includeDeleted: true to disable tombstone
suppression.
Latest full cloud event, or null when the subject has no matching event.
Nullable so that, in an aliased multi-source query, a source with no event
resolves to null instead of erroring and null-propagating over its siblings.
Tombstoned attestations are hidden from the result by default; pass
includeDeleted: true to disable tombstone suppression.
"""
latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent!
latestCloudEvent(did: String!, filter: CloudEventFilter, includeDeleted: Boolean = false): CloudEvent
@mcpTool(
name: "get_latest_cloud_event"
description: "Get the latest full CloudEvent (header + JSON payload) for a DID-scoped subject, optionally filtered. Returns dataUrl (presigned S3 link) for large binary payloads instead of inlining them."
Expand Down
Loading