Skip to content

Commit 9978765

Browse files
committed
test(receipt): assert receipt bytes decode as an invocation
Locks in the invariant the receipt API rests on: a receipt is its own Go type, but on the wire it is byte-identical to a /ucan/assert/receipt invocation. TestWireFormatIsInvocation encodes a receipt and decodes the same bytes via invocation.Decode, checking the command, issuer, CID, signed bytes, and that Ran/Out are recoverable from the args. If the receipt encoding ever drifts from the invocation encoding, this fails. - the invocation view re-encodes to byte-identical output - the executor is issuer, subject, and audience (per ucan-wg/receipt#1) - the bytes verify through invocation.VerifySignature, not just parse - the Out value decodes back to the original, not just non-nil - Adds TestNotAReceipt in the invocation package as the mirror: a plain invocation with a non-receipt command must be rejected by receipt.Decode. Together the two tests pin the discrimination boundary in both directions.
1 parent 5aaec90 commit 9978765

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

ucan/invocation/invocation_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/fil-forge/ucantone/ucan"
1212
"github.com/fil-forge/ucantone/ucan/command"
1313
"github.com/fil-forge/ucantone/ucan/invocation"
14+
"github.com/fil-forge/ucantone/ucan/receipt"
1415
"github.com/stretchr/testify/require"
1516
cbg "github.com/whyrusleeping/cbor-gen"
1617
)
@@ -257,3 +258,30 @@ func TestInvokeRejectsNonMapArgs(t *testing.T) {
257258
require.Error(t, err)
258259
require.Contains(t, err.Error(), "args must encode as a CBOR map")
259260
}
261+
262+
// TestNotAReceipt is the mirror of receipt.TestWireFormatIsInvocation. A
263+
// receipt is wire-identical to a /ucan/assert/receipt invocation, but the
264+
// discrimination must hold in the other direction too: a plain invocation
265+
// with any other command MUST NOT decode as a receipt. This pins the
266+
// boundary so receipt.Decode can't silently accept arbitrary invocations.
267+
func TestNotAReceipt(t *testing.T) {
268+
issuer := testutil.RandomSigner(t)
269+
subject := testutil.RandomDID(t)
270+
command := testutil.Must(command.Parse("/test/invoke"))(t)
271+
272+
inv, err := invocation.Invoke(issuer, subject, command, datamodel.Map{})
273+
require.NoError(t, err)
274+
275+
encoded, err := invocation.Encode(inv)
276+
require.NoError(t, err)
277+
278+
// The invocation decodes fine as an invocation...
279+
_, err = invocation.Decode(encoded)
280+
require.NoError(t, err)
281+
282+
// ...but must be rejected as a receipt: its command isn't the receipt
283+
// command, so there are no Ran/Out fields to recover.
284+
_, err = receipt.Decode(encoded)
285+
require.Error(t, err, "a non-receipt invocation must not decode as a receipt")
286+
require.Contains(t, err.Error(), "invalid receipt command")
287+
}

ucan/receipt/receipt_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99

1010
"github.com/fil-forge/ucantone/testutil"
1111
"github.com/fil-forge/ucantone/ucan"
12+
"github.com/fil-forge/ucantone/ucan/invocation"
1213
"github.com/fil-forge/ucantone/ucan/receipt"
14+
rdm "github.com/fil-forge/ucantone/ucan/receipt/datamodel"
1315
)
1416

1517
func TestIssueOK(t *testing.T) {
@@ -126,3 +128,63 @@ func TestNotInvocation(t *testing.T) {
126128
_, isInv := any(rcpt).(ucan.Invocation)
127129
require.False(t, isInv, "Receipt must not be assignable to ucan.Invocation")
128130
}
131+
132+
// TestWireFormatIsInvocation locks in the invariant the receipt API rests on:
133+
// although Receipt is its own Go type, on the wire a receipt is byte-identical
134+
// to a /ucan/assert/receipt invocation. The exact bytes produced by
135+
// receipt.Encode MUST decode cleanly via invocation.Decode, with Ran/Out
136+
// recoverable from the invocation's args. If the receipt encoding ever drifts
137+
// from the invocation encoding, this test fails.
138+
func TestWireFormatIsInvocation(t *testing.T) {
139+
executor := testutil.RandomSigner(t)
140+
ran := testutil.RandomCID(t)
141+
ok := cbg.CborInt(42)
142+
143+
rcpt, err := receipt.IssueOK(executor, ran, &ok)
144+
require.NoError(t, err)
145+
146+
encoded, err := receipt.Encode(rcpt)
147+
require.NoError(t, err)
148+
149+
// The same bytes must decode as an invocation...
150+
inv, err := invocation.Decode(encoded)
151+
require.NoError(t, err, "receipt bytes must be a valid invocation on the wire")
152+
153+
// ...and the invocation view must re-encode to byte-identical output, so
154+
// the two views are not just compatible but the same wire object.
155+
reencoded, err := invocation.Encode(inv)
156+
require.NoError(t, err)
157+
require.Equal(t, encoded, reencoded, "invocation view must re-encode verbatim")
158+
159+
// It is the /ucan/assert/receipt invocation shape, with the executor as
160+
// issuer, subject, and audience (per ucan-wg/receipt#1).
161+
require.Equal(t, receipt.Command, inv.Command())
162+
require.Equal(t, executor.DID(), inv.Issuer().DID())
163+
require.Equal(t, executor.DID(), inv.Subject().DID())
164+
require.NotNil(t, inv.Audience())
165+
require.Equal(t, executor.DID(), inv.Audience().DID())
166+
167+
// The receipt and invocation views agree on identity and signed bytes.
168+
require.Equal(t, rcpt.Link(), inv.Link(), "receipt and invocation views share one CID")
169+
require.Equal(t, rcpt.SignedBytes(), inv.SignedBytes())
170+
171+
// The bytes verify as a signed invocation — not merely parse as one.
172+
verified, err := invocation.VerifySignature(inv, executor.Verifier())
173+
require.NoError(t, err)
174+
require.True(t, verified, "receipt bytes must verify through the invocation path")
175+
176+
// Ran/Out are carried in the invocation's args, value intact.
177+
var args rdm.ArgsModel
178+
require.NoError(t, args.UnmarshalCBOR(bytes.NewReader(inv.ArgumentsBytes())))
179+
require.Equal(t, ran, args.Ran)
180+
require.NotNil(t, args.Out.Ok)
181+
var gotOut cbg.CborInt
182+
require.NoError(t, gotOut.UnmarshalCBOR(bytes.NewReader(args.Out.Ok.Bytes())))
183+
require.Equal(t, cbg.CborInt(42), gotOut)
184+
185+
// And the receipt view round-trips back from those same bytes.
186+
redecoded, err := receipt.Decode(encoded)
187+
require.NoError(t, err)
188+
require.Equal(t, rcpt.Link(), redecoded.Link())
189+
require.Equal(t, rcpt.Ran(), redecoded.Ran())
190+
}

0 commit comments

Comments
 (0)