authz_test.gno
36.50 Kb · 948 lines
1package authz
2
3import (
4 "chain"
5 "errors"
6 "strings"
7 "testing"
8
9 "gno.land/p/nt/testutils/v0"
10 "gno.land/p/nt/uassert/v0"
11)
12
13func TestNewWithCurrent(cur realm, t *testing.T) {
14 alice := testutils.TestAddress("alice")
15 testing.SetRealm(testing.NewUserRealm(alice))
16
17 auth := NewWithMembers(cur.Address())
18
19 // Check that the current authority is a MemberAuthority
20 memberAuth, ok := auth.Authority().(*MemberAuthority)
21 uassert.True(t, ok, "expected MemberAuthority")
22
23 // Check that the caller is a member
24 uassert.True(t, memberAuth.Has(alice), "caller should be a member")
25
26 // Check string representation
27 uassert.True(t, strings.Contains(auth.String(), alice.String()))
28}
29
30func TestNewWithAuthority(cur realm, t *testing.T) {
31 alice := testutils.TestAddress("alice")
32 memberAuth := NewMemberAuthority(alice)
33
34 auth := NewWithAuthority(memberAuth)
35
36 // Check that the current authority is the one we provided
37 uassert.True(t, auth.Authority() == memberAuth, "expected provided authority")
38}
39
40func TestAuthorizerAuthorize(cur realm, t *testing.T) {
41 alice := testutils.TestAddress("alice")
42 testing.SetRealm(testing.NewUserRealm(alice))
43
44 auth := NewWithMembers(cur.Address())
45
46 // Test successful action with args
47 executed := false
48 args := []any{"test_arg", 123}
49 err := auth.DoByCurrent(0, cur, "test_action", func() error {
50 executed = true
51 return nil
52 }, args...)
53
54 uassert.True(t, err == nil, "expected no error")
55 uassert.True(t, executed, "action should have been executed")
56
57 // Test unauthorized action with args
58 testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("bob")))
59
60 executed = false
61 err = auth.DoByCurrent(0, cur, "test_action", func() error {
62 executed = true
63 return nil
64 }, "unauthorized_arg")
65
66 uassert.True(t, err != nil, "expected error")
67 uassert.False(t, executed, "action should not have been executed")
68
69 // Test action returning error
70 testing.SetRealm(testing.NewUserRealm(alice))
71 expectedErr := errors.New("test error")
72
73 err = auth.DoByCurrent(0, cur, "test_action", func() error {
74 return expectedErr
75 })
76
77 uassert.True(t, err == expectedErr, "expected specific error")
78}
79
80func TestAuthorizerTransfer(cur realm, t *testing.T) {
81 alice := testutils.TestAddress("alice")
82 testing.SetRealm(testing.NewUserRealm(alice))
83
84 auth := NewWithMembers(cur.Address())
85
86 // Test transfer to new member authority
87 bob := testutils.TestAddress("bob")
88 newAuth := NewMemberAuthority(bob)
89
90 var err error
91 func(cur realm) { err = auth.Transfer(0, cur, newAuth) }(cross(cur))
92 uassert.True(t, err == nil, "expected no error")
93 uassert.True(t, auth.Authority() == newAuth, "expected new authority")
94
95 // Test unauthorized transfer: principal is not a member of newAuth.
96 carol := testutils.TestAddress("carol")
97 testing.SetRealm(testing.NewUserRealm(carol))
98
99 func(cur realm) { err = auth.Transfer(0, cur, NewMemberAuthority(alice)) }(cross(cur))
100 uassert.True(t, err != nil, "expected error")
101
102 // Test transfer to contract authority — bob is the current authority.
103 testing.SetRealm(testing.NewUserRealm(bob))
104 contractAuth := NewContractAuthority("gno.land/r/test", func(title string, action PrivilegedAction) error {
105 return action()
106 })
107
108 func(cur realm) { err = auth.Transfer(0, cur, contractAuth) }(cross(cur))
109 uassert.True(t, err == nil, "expected no error")
110 uassert.True(t, auth.Authority() == contractAuth, "expected contract authority")
111}
112
113func TestAuthorizerTransferChain(cur realm, t *testing.T) {
114 alice := testutils.TestAddress("alice")
115 testing.SetRealm(testing.NewUserRealm(alice))
116
117 // Create a chain of transfers
118 auth := NewWithMembers(cur.Address())
119
120 // First transfer to a new member authority
121 bob := testutils.TestAddress("bob")
122 memberAuth := NewMemberAuthority(bob)
123
124 var err error
125 func(cur realm) { err = auth.Transfer(0, cur, memberAuth) }(cross(cur))
126 uassert.True(t, err == nil, "unexpected error in first transfer")
127
128 // Then transfer to a contract authority — bob is now the authority.
129 testing.SetRealm(testing.NewUserRealm(bob))
130 contractAuth := NewContractAuthority("gno.land/r/test", func(title string, action PrivilegedAction) error {
131 return action()
132 })
133 func(cur realm) { err = auth.Transfer(0, cur, contractAuth) }(cross(cur))
134 uassert.True(t, err == nil, "unexpected error in second transfer")
135
136 // Finally transfer to an auto-accept authority. The authority is now
137 // the contract authority, whose default proposer is the contract
138 // itself, so the transfer's caller —
139 // cur.Previous() — must be the contract. Set the outer realm to the
140 // contract, then cross into a closure so cur.Previous() resolves to
141 // it.
142 autoAuth := NewAutoAcceptAuthority()
143 testing.SetRealm(testing.NewCodeRealm("gno.land/r/test"))
144 func(cur realm) { err = auth.Transfer(0, cur, autoAuth) }(cross(cur))
145 uassert.True(t, err == nil, "unexpected error in final transfer")
146 uassert.True(t, auth.Authority() == autoAuth, "expected auto-accept authority")
147}
148
149func TestAuthorizerTransferUnauthorizedRejected(cur realm, t *testing.T) {
150 // Regression for the address-parameter forgery: previously Transfer
151 // took a caller-supplied `caller address` that an attacker realm could
152 // set to the real owner. After the IsCurrent + rlm.Previous() fix, the
153 // principal is the captured cur's previous and cannot be forged.
154 admin := testutils.TestAddress("admin")
155 attacker := testutils.TestAddress("attacker")
156
157 testing.SetRealm(testing.NewUserRealm(admin))
158 auth := NewWithMembers(cur.Address()) // admin is the initial authority
159
160 initialAuth, ok := auth.Authority().(*MemberAuthority)
161 uassert.True(t, ok)
162 uassert.True(t, initialAuth.Has(admin))
163 uassert.False(t, initialAuth.Has(attacker))
164
165 // Attacker context: cur.Previous() inside the closure will be attacker.
166 testing.SetRealm(testing.NewUserRealm(attacker))
167 attackerAuth := NewMemberAuthority(attacker)
168 var err error
169 func(cur realm) { err = auth.Transfer(0, cur, attackerAuth) }(cross(cur))
170
171 uassert.True(t, err != nil, "attacker transfer must be rejected")
172
173 // Authority unchanged: still the initial admin-only MemberAuthority.
174 finalAuth, ok := auth.Authority().(*MemberAuthority)
175 uassert.True(t, ok)
176 uassert.True(t, finalAuth == initialAuth, "authority must not have changed")
177 uassert.True(t, finalAuth.Has(admin))
178 uassert.False(t, finalAuth.Has(attacker))
179}
180
181func TestAuthorizerWithDroppedAuthority(cur realm, t *testing.T) {
182 alice := testutils.TestAddress("alice")
183 testing.SetRealm(testing.NewUserRealm(alice))
184
185 auth := NewWithMembers(cur.Address())
186
187 // Transfer to dropped authority
188 var err error
189 func(cur realm) { err = auth.Transfer(0, cur, NewDroppedAuthority()) }(cross(cur))
190 uassert.True(t, err == nil, "expected no error")
191
192 // Try to execute action
193 err = auth.DoByCurrent(0, cur, "test_action", func() error {
194 return nil
195 })
196 uassert.True(t, err != nil, "expected error from dropped authority")
197
198 // Try to transfer again
199 func(cur realm) { err = auth.Transfer(0, cur, NewMemberAuthority(alice)) }(cross(cur))
200 uassert.True(t, err != nil, "expected error when transferring from dropped authority")
201}
202
203func TestContractAuthorityHandlerExecutionOnce(cur realm, t *testing.T) {
204 attempts := 0
205 executed := 0
206
207 contractAuth := NewContractAuthority("gno.land/r/test", func(title string, action PrivilegedAction) error {
208 // Try to execute the action twice in the same handler
209 if err := action(); err != nil {
210 return err
211 }
212 attempts++
213
214 // Second execution should fail
215 if err := action(); err != nil {
216 return err
217 }
218 attempts++
219 return nil
220 })
221
222 // Set caller to contract address
223 codeRealm := testing.NewCodeRealm("gno.land/r/test")
224 testing.SetRealm(codeRealm)
225 code := codeRealm.Address()
226
227 testArgs := []any{"proposal_id", 42, "metadata", map[string]string{"key": "value"}}
228 err := contractAuth.Authorize(code, "test_action", func() error {
229 executed++
230 return nil
231 }, testArgs...)
232
233 uassert.True(t, err == nil, "handler execution should succeed")
234 uassert.True(t, attempts == 2, "handler should have attempted execution twice")
235 uassert.True(t, executed == 1, "handler should have executed once")
236}
237
238func TestContractAuthorityExecutionTwice(cur realm, t *testing.T) {
239 executed := 0
240
241 contractAuth := NewContractAuthority("gno.land/r/test", func(title string, action PrivilegedAction) error {
242 return action()
243 })
244
245 // Set caller to contract address
246 codeRealm := testing.NewCodeRealm("gno.land/r/test")
247 testing.SetRealm(codeRealm)
248 code := codeRealm.Address()
249 testArgs := []any{"proposal_id", 42, "metadata", map[string]string{"key": "value"}}
250
251 err := contractAuth.Authorize(code, "test_action", func() error {
252 executed++
253 return nil
254 }, testArgs...)
255
256 uassert.True(t, err == nil, "handler execution should succeed")
257 uassert.True(t, executed == 1, "handler should have executed once")
258
259 // A new action, even with the same title, should be executed
260 err = contractAuth.Authorize(code, "test_action", func() error {
261 executed++
262 return nil
263 }, testArgs...)
264
265 uassert.True(t, err == nil, "handler execution should succeed")
266 uassert.True(t, executed == 2, "handler should have executed twice")
267}
268
269func TestContractAuthorityWithProposer(cur realm, t *testing.T) {
270 alice := testutils.TestAddress("alice")
271 memberAuth := NewMemberAuthority(alice)
272
273 handlerCalled := false
274 actionExecuted := false
275
276 contractAuth := NewRestrictedContractAuthority("gno.land/r/test", func(title string, action PrivilegedAction) error {
277 handlerCalled = true
278 // Set caller to contract address before executing action
279 testing.SetRealm(testing.NewCodeRealm("gno.land/r/test"))
280 return action()
281 }, memberAuth)
282
283 // Test authorized member
284 testArgs := []any{"proposal_metadata", "test value"}
285 err := contractAuth.Authorize(alice, "test_action", func() error {
286 actionExecuted = true
287 return nil
288 }, testArgs...)
289
290 uassert.True(t, err == nil, "authorized member should be able to propose")
291 uassert.True(t, handlerCalled, "contract handler should be called")
292 uassert.True(t, actionExecuted, "action should be executed")
293
294 // Reset flags for unauthorized test
295 handlerCalled = false
296 actionExecuted = false
297
298 // Test unauthorized proposer
299 bob := testutils.TestAddress("bob")
300 err = contractAuth.Authorize(bob, "test_action", func() error {
301 actionExecuted = true
302 return nil
303 }, testArgs...)
304
305 uassert.True(t, err != nil, "unauthorized member should not be able to propose")
306 uassert.False(t, handlerCalled, "contract handler should not be called for unauthorized proposer")
307 uassert.False(t, actionExecuted, "action should not be executed for unauthorized proposer")
308}
309
310func TestAutoAcceptAuthority(cur realm, t *testing.T) {
311 alice := testutils.TestAddress("alice")
312 auth := NewAutoAcceptAuthority()
313
314 // Test that any action is authorized
315 executed := false
316 err := auth.Authorize(alice, "test_action", func() error {
317 executed = true
318 return nil
319 })
320
321 uassert.True(t, err == nil, "auto-accept should not return error")
322 uassert.True(t, executed, "action should have been executed")
323
324 // Test with different caller
325 random := testutils.TestAddress("random")
326 executed = false
327 err = auth.Authorize(random, "test_action", func() error {
328 executed = true
329 return nil
330 })
331
332 uassert.True(t, err == nil, "auto-accept should not care about caller")
333 uassert.True(t, executed, "action should have been executed")
334}
335
336func TestAutoAcceptAuthorityWithArgs(cur realm, t *testing.T) {
337 auth := NewAutoAcceptAuthority()
338 anyuser := testutils.TestAddress("anyuser")
339
340 // Test that any action is authorized with args
341 executed := false
342 testArgs := []any{"arg1", 42, "arg3"}
343 err := auth.Authorize(anyuser, "test_action", func() error {
344 executed = true
345 return nil
346 }, testArgs...)
347
348 uassert.True(t, err == nil, "auto-accept should not return error")
349 uassert.True(t, executed, "action should have been executed")
350}
351
352func TestMemberAuthorityMultipleMembers(cur realm, t *testing.T) {
353 alice := testutils.TestAddress("alice")
354 bob := testutils.TestAddress("bob")
355 carol := testutils.TestAddress("carol")
356
357 // Create authority with multiple members
358 auth := NewMemberAuthority(alice, bob)
359
360 // Test that both members can execute actions
361 for _, member := range []address{alice, bob} {
362 err := auth.Authorize(member, "test_action", func() error {
363 return nil
364 })
365 uassert.True(t, err == nil, "member should be authorized")
366 }
367
368 // Test that non-member cannot execute
369 err := auth.Authorize(carol, "test_action", func() error {
370 return nil
371 })
372 uassert.True(t, err != nil, "non-member should not be authorized")
373
374 // Verify membership through the public API: the v0 Tree() escape hatch
375 // is intentionally absent here, so the backing store never leaks.
376 uassert.True(t, auth.Has(alice), "alice should be a member")
377 uassert.True(t, auth.Has(bob), "bob should be a member")
378 uassert.False(t, auth.Has(carol), "carol should not be a member")
379}
380
381func TestAuthorizerCurrentNeverNil(cur realm, t *testing.T) {
382 auth := NewWithMembers(cur.Address())
383
384 // Authority should never be nil after initialization
385 uassert.True(t, auth.Authority() != nil, "current authority should not be nil")
386
387 // Authority should not be nil after transfer
388 var err error
389 func(cur realm) { err = auth.Transfer(0, cur, NewAutoAcceptAuthority()) }(cross(cur))
390 uassert.True(t, err == nil, "transfer should succeed")
391 uassert.True(t, auth.Authority() != nil, "current authority should not be nil after transfer")
392}
393
394func TestContractAuthorityValidation(cur realm, t *testing.T) {
395 handler := func(title string, action PrivilegedAction) error {
396 return nil
397 }
398
399 // Empty path panics (consistent with NewRestrictedContractAuthority).
400 uassert.PanicsWithMessage(t, cur, "contract path cannot be empty", func() {
401 NewContractAuthority("", handler)
402 })
403
404 // A nil handler panics at CONSTRUCTION rather than being tolerated and
405 // surfaced at Authorize time. Tolerating it produced a permanent brick:
406 // see TestNilHandlerWouldBeUnrotatable below.
407 uassert.PanicsWithMessage(t, cur, "contract handler cannot be nil", func() {
408 NewContractAuthority("gno.land/r/test", nil)
409 })
410
411 // The Authorize-time guard stays for a zero-value ContractAuthority,
412 // which the constructors cannot produce but a struct literal can.
413 code := testing.NewCodeRealm("gno.land/r/test").Address()
414 zero := &ContractAuthority{contractPath: "gno.land/r/test", contractAddr: code}
415 err := zero.Authorize(code, "test", func() error {
416 return nil
417 })
418 uassert.True(t, err != nil, "nil handler authority should fail to authorize")
419
420 // Test valid configuration
421 contractAuth := NewContractAuthority("gno.land/r/test", handler)
422 err = contractAuth.Authorize(code, "test", func() error {
423 return nil
424 })
425 uassert.True(t, err == nil, "valid contract authority should authorize successfully")
426}
427
428func TestAuthorizerString(cur realm, t *testing.T) {
429 auth := NewWithMembers(cur.Address())
430 addr := cur.Address()
431
432 // Test initial string representation
433 str := auth.String()
434 uassert.Equal(t, str, "member_authority["+string(addr)+"]")
435
436 // Test string after transfer — caller is the current member (cur).
437 autoAuth := NewAutoAcceptAuthority()
438 var err error
439 func(cur realm) { err = auth.Transfer(0, cur, autoAuth) }(cross(cur))
440 uassert.True(t, err == nil, "transfer should succeed")
441 str = auth.String()
442 uassert.Equal(t, str, "auto_accept_authority")
443
444 // Test custom authority — auto-accept lets anyone transfer.
445 customAuth := &mockAuthority{}
446 func(cur realm) { err = auth.Transfer(0, cur, customAuth) }(cross(cur))
447 uassert.True(t, err == nil, "transfer should succeed")
448 str = auth.String()
449 uassert.Equal(t, str, "custom_authority[mock]")
450}
451
452type mockAuthority struct{}
453
454func (c mockAuthority) String() string { return "mock" }
455func (a mockAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
456 // autoaccept
457 return action()
458}
459
460func TestAuthorityString(cur realm, t *testing.T) {
461 alice := testutils.TestAddress("alice")
462
463 // MemberAuthority
464 memberAuth := NewMemberAuthority(alice)
465 memberStr := memberAuth.String()
466 expectedMemberStr := "member_authority[g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh]"
467 uassert.Equal(t, memberStr, expectedMemberStr)
468
469 // ContractAuthority — the proposer is rendered, not just the path.
470 // Without it the two constructors below are indistinguishable, which
471 // is what made consumer-level assertions on this string blind to an
472 // authority being swapped for a wide-open one.
473 contractAuth := NewContractAuthority("gno.land/r/test", func(title string, action PrivilegedAction) error { return nil })
474 contractStr := contractAuth.String()
475 expectedContractStr := "contract_authority[contract=gno.land/r/test,proposer=contract-identity]"
476 uassert.Equal(t, contractStr, expectedContractStr)
477
478 // Same path, same handler, explicit open proposer — must render
479 // differently from the gated default above.
480 openAuth := NewRestrictedContractAuthority(
481 "gno.land/r/test",
482 func(title string, action PrivilegedAction) error { return nil },
483 NewAutoAcceptAuthority(),
484 )
485 uassert.Equal(t,
486 "contract_authority[contract=gno.land/r/test,proposer=auto_accept_authority]",
487 openAuth.String())
488 uassert.NotEqual(t, contractStr, openAuth.String())
489
490 // AutoAcceptAuthority
491 autoAuth := NewAutoAcceptAuthority()
492 autoStr := autoAuth.String()
493 expectedAutoStr := "auto_accept_authority"
494 uassert.Equal(t, autoStr, expectedAutoStr)
495
496 // DroppedAuthority
497 droppedAuth := NewDroppedAuthority()
498 droppedStr := droppedAuth.String()
499 expectedDroppedStr := "dropped_authority"
500 uassert.Equal(t, droppedStr, expectedDroppedStr)
501}
502
503// TestContractAuthorityUnauthorizedCaller verifies the
504// fix: a ContractAuthority's default proposer is the contract itself, so
505// any other caller is rejected UPSTREAM (at the proposer) and the handler
506// and privileged action never run. Previously the default proposer was
507// AutoAcceptAuthority, and the only guard was a handler-side
508// `unsafe.CurrentRealm() == contractAddr` check — which was bypassable
509// and which many consumers (e.g. r/gnops/valopers) omitted entirely,
510// leaving no caller check at all.
511func TestContractAuthorityUnauthorizedCaller(cur realm, t *testing.T) {
512 contractPath := "gno.land/r/testcontract"
513 contractAddr := chain.PackageAddress(contractPath)
514 unauthorizedAddr := testutils.TestAddress("unauthorized")
515
516 // A permissive handler that simply runs the action — the realistic
517 // shape a consumer registers. The package, not the handler, must be
518 // the one that rejects an unauthorized caller.
519 handlerExecuted := false
520 contractHandler := func(title string, action PrivilegedAction) error {
521 handlerExecuted = true
522 return action()
523 }
524 contractAuth := NewContractAuthority(contractPath, contractHandler)
525
526 actionExecuted := false
527 privilegedAction := func() error {
528 actionExecuted = true
529 return nil
530 }
531
532 // 1. Unauthorized caller: rejected by the contract-identity proposer
533 // before the handler or the action can run.
534 err := contractAuth.Authorize(unauthorizedAddr, "test_action", privilegedAction)
535 uassert.Error(t, err, "unauthorized caller must be rejected")
536 uassert.ErrorContains(t, err, "unauthorized", "rejection must come from the contract-identity proposer")
537 uassert.False(t, handlerExecuted, "handler must not run for an unauthorized caller")
538 uassert.False(t, actionExecuted, "privileged action must not run for an unauthorized caller")
539
540 // 2. The contract itself is authorized.
541 err = contractAuth.Authorize(contractAddr, "test_action", privilegedAction)
542 uassert.NoError(t, err, "the contract itself must be authorized")
543 uassert.True(t, handlerExecuted, "handler must run when the contract is the caller")
544 uassert.True(t, actionExecuted, "privileged action must run when the contract is the caller")
545}
546
547// TestAuthorizerDoByPrevious verifies the "calling realm authorizes"
548// pattern: a function (the inner crossing closure) invokes
549// DoByPrevious so the authority check sees cur.Previous() — the realm
550// that crossed into it — not the function's own realm.
551//
552// Each scenario crosses into the inner closure via cross(cur) after
553// SetRealm — inside the closure, cur is the fresh live cur and
554// cur.Previous() is the SetRealm'd outer realm. This is the only way
555// to exercise DoByPrevious correctly under the IsCurrent guard, which
556// rejects synthetic realm values (testing.MakeRealm) and stored
557// stale captures.
558func TestAuthorizerDoByPrevious(cur realm, t *testing.T) {
559 alice := testutils.TestAddress("alice")
560 bob := testutils.TestAddress("bob")
561
562 auth := NewWithMembers(alice)
563
564 // alice (member) crosses in: cur.Previous() == alice inside the inner closure.
565 testing.SetRealm(testing.NewUserRealm(alice))
566 executed := false
567 args := []any{"test_arg", 123}
568 func(cur realm) {
569 err := auth.DoByPrevious(0, cur, "test_action", func() error {
570 executed = true
571 return nil
572 }, args...)
573 uassert.NoError(t, err, "expected no error")
574 uassert.True(t, executed, "action should have been executed")
575 }(cross(cur))
576
577 expectedErr := errors.New("test error")
578 func(cur realm) {
579 err := auth.DoByPrevious(0, cur, "test_action", func() error {
580 return expectedErr
581 })
582 uassert.ErrorContains(t, err, expectedErr.Error(), "expected error")
583 }(cross(cur))
584
585 // bob (not a member) crosses in: Authorize must reject.
586 testing.SetRealm(testing.NewUserRealm(bob))
587 executed = false
588 func(cur realm) {
589 err := auth.DoByPrevious(0, cur, "test_action", func() error {
590 executed = true
591 return nil
592 }, "unauthorized_arg")
593 uassert.ErrorContains(t, err, "unauthorized", "expected error")
594 uassert.False(t, executed, "action should not have been executed")
595 }(cross(cur))
596}
597
598// ---------------------------------------------------------------------------
599// Contract-identity gate: regression suite.
600//
601// A plain NewContractAuthority now defaults its proposer to the contract
602// ITSELF (MemberAuthority of the contract's own package address) instead of
603// AutoAcceptAuthority. These tests pin the security contract: only the
604// contract may drive privileged actions, an external caller can neither
605// drive nor Transfer the authority, the contract can still rotate its own
606// authority, and the escape hatch for a genuinely-open proposer still
607// exists.
608// ---------------------------------------------------------------------------
609
610// The default proposer accepts the contract itself and rejects everyone
611// else, before the handler or action can run.
612func TestDefaultProposerIsContractOnly(cur realm, t *testing.T) {
613 const path = "gno.land/r/defcontract"
614 contractAddr := chain.PackageAddress(path)
615
616 ran := 0
617 ca := NewContractAuthority(path, func(_ string, action PrivilegedAction) error {
618 return action()
619 })
620
621 // The contract itself: authorized.
622 err := ca.Authorize(contractAddr, "action", func() error { ran++; return nil })
623 uassert.NoError(t, err, "the contract itself must be authorized")
624
625 // Anyone else: rejected at the proposer, action never runs.
626 outsider := testutils.TestAddress("outsider")
627 err = ca.Authorize(outsider, "action", func() error { ran++; return nil })
628 uassert.Error(t, err, "a non-contract caller must be rejected")
629 uassert.ErrorContains(t, err, "unauthorized", "rejection must come from the contract-identity proposer")
630
631 uassert.Equal(t, 1, ran, "only the contract's action must have run")
632}
633
634// An external realm that holds another realm's Authorizer cannot Transfer
635// it. The caller derived under IsCurrent is that external realm, not the
636// contract, so the rotation is rejected and the authority is left intact.
637func TestExternalTransferRejected(cur realm, t *testing.T) {
638 authorizer := NewWithAuthority(
639 NewContractAuthority("gno.land/r/victim", func(_ string, action PrivilegedAction) error {
640 return action()
641 }),
642 )
643
644 attacker := testutils.TestAddress("attacker")
645 testing.SetRealm(testing.NewUserRealm(attacker))
646
647 var err error
648 func(cur realm) { err = authorizer.Transfer(0, cur, NewDroppedAuthority()) }(cross(cur))
649
650 uassert.Error(t, err, "external Transfer must be rejected")
651 uassert.ErrorContains(t, err, "unauthorized", "rejection must come from the contract-identity proposer")
652
653 _, ok := authorizer.Authority().(*ContractAuthority)
654 uassert.True(t, ok, "authority must be unchanged after a rejected rotation")
655}
656
657// Same shape via DoByPrevious (a privileged action instead of a
658// Transfer): an external caller is rejected and the action never runs.
659func TestExternalDoByPreviousRejected(cur realm, t *testing.T) {
660 authorizer := NewWithAuthority(
661 NewContractAuthority("gno.land/r/victim2", func(_ string, action PrivilegedAction) error {
662 return action()
663 }),
664 )
665
666 ran := false
667 testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("randomuser")))
668 var err error
669 func(cur realm) {
670 err = authorizer.DoByPrevious(0, cur, "privileged", func() error { ran = true; return nil })
671 }(cross(cur))
672
673 uassert.Error(t, err, "external DoByPrevious must be rejected")
674 uassert.False(t, ran, "privileged action must not run for an external caller")
675}
676
677// The contract-identity model does NOT lock out legitimate governance: the
678// contract can still rotate (Transfer) its own authority when the transfer
679// is driven with the contract as the previous realm (caller == contractAddr).
680func TestContractCanRotateItsOwnAuthority(cur realm, t *testing.T) {
681 const path = "gno.land/r/selfgov"
682 authorizer := NewWithAuthority(
683 NewContractAuthority(path, func(_ string, action PrivilegedAction) error {
684 return action()
685 }),
686 )
687
688 // Drive the transfer with the contract as the caller: set the outer
689 // realm to the contract, then cross into a closure so cur.Previous()
690 // resolves to the contract.
691 testing.SetRealm(testing.NewCodeRealm(path))
692 var err error
693 func(cur realm) { err = authorizer.Transfer(0, cur, NewAutoAcceptAuthority()) }(cross(cur))
694
695 uassert.NoError(t, err, "the contract itself must be able to rotate its authority")
696 _, ok := authorizer.Authority().(*AutoAcceptAuthority)
697 uassert.True(t, ok, "authority should have rotated to auto-accept")
698}
699
700// Boundary test: the raw Authority.Authorize takes `caller` as a plain
701// parameter, so a holder of the raw Authority can forge caller ==
702// contractAddr. This proves that even so, a forged-caller raw Authorize
703// only runs the action the caller itself supplies — it CANNOT mutate an
704// Authorizer's installed authority, because the Transfer mutation lives
705// only inside Authorizer.Transfer (which derives caller non-forgeably).
706// The lesson encoded here: consumers must reach a ContractAuthority through
707// the Authorizer wrapper and must not hand out the raw Authority.
708//
709// WHAT THIS TEST DOES AND DOES NOT FREEZE. Only the final assertion is a
710// security property. That raw Authorize ACCEPTS a forged caller is
711// current behaviour, not a guarantee — it is the consequence of `caller`
712// being a parameter on an exported interface method. If someone later
713// hardens that path so raw Authorize stops accepting a forged caller,
714// this test going red means the test is stale, NOT that the change is
715// wrong: delete the two intermediate assertions and keep the last one.
716// (Recorded because asserting NoError here reads like the forgeability
717// is wanted. It is tolerated, and only because of what follows.)
718func TestForgedCallerCannotTransfer(cur realm, t *testing.T) {
719 const path = "gno.land/r/boundary"
720 contractAddr := chain.PackageAddress(path)
721
722 authorizer := NewWithAuthority(
723 NewContractAuthority(path, func(_ string, action PrivilegedAction) error {
724 return action()
725 }),
726 )
727
728 // Attacker obtains the raw Authority and forges the contract as caller.
729 attackerRan := false
730 err := authorizer.Authority().Authorize(contractAddr, "transfer_authority", func() error {
731 attackerRan = true
732 return nil
733 })
734
735 // Current behaviour, documented above — not a property to preserve.
736 uassert.NoError(t, err, "raw Authorize currently accepts a forged caller (see comment)")
737 uassert.True(t, attackerRan, "only the caller's own inert closure runs")
738
739 // THE property: the installed authority is UNCHANGED. Raw Authorize
740 // cannot perform a Transfer — the mutation closure is private to
741 // Authorizer.Transfer, which derives caller non-forgeably.
742 _, ok := authorizer.Authority().(*ContractAuthority)
743 uassert.True(t, ok, "raw Authorize must not be able to transfer the authority")
744}
745
746// Two contract authorities on different paths never cross-authorize.
747func TestContractPathIsolation(cur realm, t *testing.T) {
748 caB := NewContractAuthority("gno.land/r/pathb", func(_ string, action PrivilegedAction) error {
749 return action()
750 })
751 addrA := chain.PackageAddress("gno.land/r/patha")
752
753 ran := false
754 err := caB.Authorize(addrA, "action", func() error { ran = true; return nil })
755 uassert.Error(t, err, "contract A must not authorize contract B's authority")
756 uassert.False(t, ran, "action must not run across contract identities")
757}
758
759// Escape hatch: a consumer that genuinely wants open proposals can still
760// opt in via NewRestrictedContractAuthority with an AutoAcceptAuthority
761// proposer — restoring the pre-fix "anyone can propose" behavior explicitly.
762func TestRestrictedAutoAcceptEscapeHatch(cur realm, t *testing.T) {
763 ca := NewRestrictedContractAuthority(
764 "gno.land/r/open",
765 func(_ string, action PrivilegedAction) error { return action() },
766 NewAutoAcceptAuthority(),
767 )
768
769 ran := false
770 err := ca.Authorize(testutils.TestAddress("anyone"), "action", func() error { ran = true; return nil })
771 uassert.NoError(t, err, "an explicit AutoAccept proposer restores open proposals")
772 uassert.True(t, ran, "action must run under an explicit AutoAccept proposer")
773}
774
775// Why the nil handler is now rejected at construction rather than
776// tolerated: Authorize checks contractHandler == nil BEFORE consulting the
777// proposer, and Transfer routes through Authorize, so there is no rotation
778// path out. It is a permanent brick reachable by an ordinary deployer
779// typo -- the same bricked-governance failure mode, by accident.
780//
781// The zero value is used because the constructor no longer allows it.
782func TestNilHandlerWouldBeUnrotatable(cur realm, t *testing.T) {
783 const path = "gno.land/r/nilbrick"
784 addr := chain.PackageAddress(path)
785 authorizer := NewWithAuthority(&ContractAuthority{
786 contractPath: path,
787 contractAddr: addr,
788 // Both handler and proposer are nil: a struct literal is the only
789 // way to reach this, and Authorize fails closed on either.
790 })
791
792 // Drive the rotation as the contract itself -- the only principal the
793 // default proposer accepts. If this cannot escape, nobody can.
794 testing.SetRealm(testing.NewCodeRealm(path))
795 var err error
796 func(cur realm) { err = authorizer.Transfer(0, cur, NewAutoAcceptAuthority()) }(cross(cur))
797
798 uassert.Error(t, err, "a nil-handler authority must not be silently rotatable")
799 _, stuck := authorizer.Authority().(*ContractAuthority)
800 uassert.True(t, stuck, "nil-handler authority is unrotatable -- hence rejected at construction")
801}
802
803// A crossing closure declared HERE carries this package's identity, not
804// the contract path's -- testing.SetRealm does not change that, because
805// the VM mints a crossing frame's realm from the callee's declaring
806// package. So the DoByCurrent shape that Example_contractAuthority teaches
807// cannot be exercised from inside this package at all; it needs a real
808// realm. It is covered by filetests/z_contract_authority_shape_filetest.gno.
809//
810// The same property is why an exported crossing closure leaks a realm's
811// authority to any caller -- it is one mechanism seen from two
812// sides. This test pins the half that is observable here: an unrelated
813// package's frame is rejected.
814func TestForeignFrameCannotDriveContractAuthority(cur realm, t *testing.T) {
815 auth := NewWithAuthority(NewContractAuthority("gno.land/r/demo/dao", mockDAOHandler))
816
817 testing.SetRealm(testing.NewCodeRealm("gno.land/r/example"))
818 ran := false
819 var err error
820 func(cur realm) {
821 err = auth.DoByCurrent(0, cur, "update_params", func() error {
822 ran = true
823 return nil
824 })
825 }(cross(cur))
826
827 uassert.Error(t, err, "a frame from another package must not drive this authority")
828 uassert.False(t, ran, "the action must not run")
829}
830
831// A malformed contract path binds the authority to an address no realm can
832// ever present. Because Transfer routes through the same gate, such an
833// authority is also unrotatable: a permanent brick, and the same failure
834// mode the nil-handler panic exists to prevent. Rejected at construction.
835//
836// Pre-fix these all constructed happily; the AutoAccept default made the
837// typo harmless (and insecure), so nothing complained.
838func TestContractPathValidationRejectsMalformed(cur realm, t *testing.T) {
839 handler := func(_ string, action PrivilegedAction) error { return action() }
840
841 for _, path := range []string{
842 "gno.land/r/gov/dao ", // trailing space
843 " gno.land/r/gov/dao", // leading space
844 "gno.land/r/gov/dao\n", // trailing newline
845 "gno.land/r/gov dao", // embedded space
846 " ",
847 "not a path",
848 "GNO.LAND/R/GOV/DAO", // uppercase: not a legal gno pkgpath
849 "gno.land//r/gov/dao",
850 "gno.land/r/gov/dao/",
851 "singlesegment",
852 } {
853 uassert.PanicsContains(t, cur, "contract path", func() {
854 NewContractAuthority(path, handler)
855 }, "malformed path must be rejected: "+path)
856 uassert.PanicsContains(t, cur, "contract path", func() {
857 NewRestrictedContractAuthority(path, handler, NewAutoAcceptAuthority())
858 }, "malformed path must be rejected by the restricted ctor too: "+path)
859 }
860
861 // Real paths still construct, including dashes, digits, underscores and
862 // version suffixes.
863 for _, path := range []string{
864 "gno.land/r/gov/dao",
865 "gno.land/r/gnops/valopers",
866 "gno.land/p/moul/authz/v0",
867 "gno.land/r/sys/validators/v0",
868 "gno.land/r/some-user/my_pkg2",
869 } {
870 uassert.NotPanics(t, cur, func() {
871 NewContractAuthority(path, handler)
872 }, "legitimate path must construct: "+path)
873 }
874}
875
876// spoofProposer is a wide-open Authority that LIES in String(), returning
877// the same text the canonical contract-identity default renders.
878type spoofProposer struct{}
879
880func (spoofProposer) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
881 return action()
882}
883
884func (spoofProposer) String() string { return "contract-identity" }
885
886// A foreign proposer must not be able to impersonate the gated default in
887// the rendered description. ContractAuthority.String() is promoted by this
888// package as the one surface distinguishing a gated authority from a
889// wide-open one, and consumers assert on it (r/gnops/valopers.Auth()). An
890// impl that chooses its own String() text defeated that: a fully permissive
891// authority rendered byte-identical to the default, so every string-based
892// configuration pin stayed green while the gate was gone.
893func TestSpoofedProposerCannotImpersonateContractIdentity(cur realm, t *testing.T) {
894 const path = "gno.land/r/spoofprobe"
895 handler := func(_ string, action PrivilegedAction) error { return action() }
896 outsider := chain.PackageAddress("gno.land/r/outsider")
897
898 gated := NewContractAuthority(path, handler)
899 spoof := NewRestrictedContractAuthority(path, handler, spoofProposer{})
900
901 // The behavioural difference the strings must reflect.
902 uassert.Error(t, gated.Authorize(outsider, "t", func() error { return nil }),
903 "the gated default must reject an outsider")
904 uassert.NoError(t, spoof.Authorize(outsider, "t", func() error { return nil }),
905 "the spoofing proposer is wide open -- that is the point of the test")
906
907 // ...and they do.
908 uassert.NotEqual(t, gated.String(), spoof.String(),
909 "a wide-open authority must not render identically to the gated default")
910 uassert.Equal(t,
911 "contract_authority[contract="+path+",proposer=contract-identity]",
912 gated.String())
913 uassert.Equal(t,
914 "contract_authority[contract="+path+",proposer=custom_authority[contract-identity]]",
915 spoof.String(),
916 "a non-canonical proposer must be wrapped, not trusted to name itself")
917
918 // Same guarantee through the Authorizer wrapper, which is the shape a
919 // consumer realm actually exposes.
920 uassert.NotEqual(t,
921 NewWithAuthority(gated).String(),
922 NewWithAuthority(spoof).String())
923}
924
925// An explicit proposer REPLACES the contract-identity gate; it does not add
926// to it. NewRestrictedContractAuthority(govdaoPath, h, member(alice)) means
927// "alice, and not GovDAO" -- contractAddr is never consulted. Pinned because
928// the rendered string reads like a conjunction and the old godoc said
929// "widen", so a consumer could reasonably have expected "both".
930func TestExplicitProposerReplacesIdentityGate(cur realm, t *testing.T) {
931 const path = "gno.land/r/gov/dao"
932 handler := func(_ string, action PrivilegedAction) error { return action() }
933 govdao := chain.PackageAddress(path)
934 alice := chain.PackageAddress("gno.land/r/alice")
935
936 gated := NewContractAuthority(path, handler)
937 replaced := NewRestrictedContractAuthority(path, handler, NewMemberAuthority(alice))
938
939 uassert.NoError(t, gated.Authorize(govdao, "x", func() error { return nil }),
940 "the default gate accepts the bound contract")
941 uassert.Error(t, gated.Authorize(alice, "x", func() error { return nil }),
942 "the default gate rejects everyone else")
943
944 uassert.Error(t, replaced.Authorize(govdao, "x", func() error { return nil }),
945 "an explicit proposer REPLACES the identity gate: govdao is no longer accepted")
946 uassert.NoError(t, replaced.Authorize(alice, "x", func() error { return nil }),
947 "only the explicit proposer's principal is accepted")
948}