authz_test.gno
36.83 Kb · 963 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 // Test Tree() functionality
375 tree := auth.Tree()
376 uassert.True(t, tree.Size() == 2, "tree should have 2 members")
377
378 // Verify both members are in the tree
379 found := make(map[address]bool)
380 tree.Iterate("", "", func(key string, _ any) bool {
381 found[address(key)] = true
382 return false
383 })
384 uassert.True(t, found[alice], "alice should be in the tree")
385 uassert.True(t, found[bob], "bob should be in the tree")
386 uassert.False(t, found[carol], "carol should not be in the tree")
387
388 // Test read-only nature of the tree
389 defer func() {
390 r := recover()
391 uassert.True(t, r != nil, "modifying read-only tree should panic")
392 }()
393 tree.Set(string(carol), nil) // This should panic
394}
395
396func TestAuthorizerCurrentNeverNil(cur realm, t *testing.T) {
397 auth := NewWithMembers(cur.Address())
398
399 // Authority should never be nil after initialization
400 uassert.True(t, auth.Authority() != nil, "current authority should not be nil")
401
402 // Authority should not be nil after transfer
403 var err error
404 func(cur realm) { err = auth.Transfer(0, cur, NewAutoAcceptAuthority()) }(cross(cur))
405 uassert.True(t, err == nil, "transfer should succeed")
406 uassert.True(t, auth.Authority() != nil, "current authority should not be nil after transfer")
407}
408
409func TestContractAuthorityValidation(cur realm, t *testing.T) {
410 handler := func(title string, action PrivilegedAction) error {
411 return nil
412 }
413
414 // Empty path panics (consistent with NewRestrictedContractAuthority).
415 uassert.PanicsWithMessage(t, cur, "contract path cannot be empty", func() {
416 NewContractAuthority("", handler)
417 })
418
419 // A nil handler panics at CONSTRUCTION rather than being tolerated and
420 // surfaced at Authorize time. Tolerating it produced a permanent brick:
421 // see TestNilHandlerWouldBeUnrotatable below.
422 uassert.PanicsWithMessage(t, cur, "contract handler cannot be nil", func() {
423 NewContractAuthority("gno.land/r/test", nil)
424 })
425
426 // The Authorize-time guard stays for a zero-value ContractAuthority,
427 // which the constructors cannot produce but a struct literal can.
428 code := testing.NewCodeRealm("gno.land/r/test").Address()
429 zero := &ContractAuthority{contractPath: "gno.land/r/test", contractAddr: code}
430 err := zero.Authorize(code, "test", func() error {
431 return nil
432 })
433 uassert.True(t, err != nil, "nil handler authority should fail to authorize")
434
435 // Test valid configuration
436 contractAuth := NewContractAuthority("gno.land/r/test", handler)
437 err = contractAuth.Authorize(code, "test", func() error {
438 return nil
439 })
440 uassert.True(t, err == nil, "valid contract authority should authorize successfully")
441}
442
443func TestAuthorizerString(cur realm, t *testing.T) {
444 auth := NewWithMembers(cur.Address())
445 addr := cur.Address()
446
447 // Test initial string representation
448 str := auth.String()
449 uassert.Equal(t, str, "member_authority["+string(addr)+"]")
450
451 // Test string after transfer — caller is the current member (cur).
452 autoAuth := NewAutoAcceptAuthority()
453 var err error
454 func(cur realm) { err = auth.Transfer(0, cur, autoAuth) }(cross(cur))
455 uassert.True(t, err == nil, "transfer should succeed")
456 str = auth.String()
457 uassert.Equal(t, str, "auto_accept_authority")
458
459 // Test custom authority — auto-accept lets anyone transfer.
460 customAuth := &mockAuthority{}
461 func(cur realm) { err = auth.Transfer(0, cur, customAuth) }(cross(cur))
462 uassert.True(t, err == nil, "transfer should succeed")
463 str = auth.String()
464 uassert.Equal(t, str, "custom_authority[mock]")
465}
466
467type mockAuthority struct{}
468
469func (c mockAuthority) String() string { return "mock" }
470func (a mockAuthority) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
471 // autoaccept
472 return action()
473}
474
475func TestAuthorityString(cur realm, t *testing.T) {
476 alice := testutils.TestAddress("alice")
477
478 // MemberAuthority
479 memberAuth := NewMemberAuthority(alice)
480 memberStr := memberAuth.String()
481 expectedMemberStr := "member_authority[g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh]"
482 uassert.Equal(t, memberStr, expectedMemberStr)
483
484 // ContractAuthority — the proposer is rendered, not just the path.
485 // Without it the two constructors below are indistinguishable, which
486 // is what made consumer-level assertions on this string blind to an
487 // authority being swapped for a wide-open one.
488 contractAuth := NewContractAuthority("gno.land/r/test", func(title string, action PrivilegedAction) error { return nil })
489 contractStr := contractAuth.String()
490 expectedContractStr := "contract_authority[contract=gno.land/r/test,proposer=contract-identity]"
491 uassert.Equal(t, contractStr, expectedContractStr)
492
493 // Same path, same handler, explicit open proposer — must render
494 // differently from the gated default above.
495 openAuth := NewRestrictedContractAuthority(
496 "gno.land/r/test",
497 func(title string, action PrivilegedAction) error { return nil },
498 NewAutoAcceptAuthority(),
499 )
500 uassert.Equal(t,
501 "contract_authority[contract=gno.land/r/test,proposer=auto_accept_authority]",
502 openAuth.String())
503 uassert.NotEqual(t, contractStr, openAuth.String())
504
505 // AutoAcceptAuthority
506 autoAuth := NewAutoAcceptAuthority()
507 autoStr := autoAuth.String()
508 expectedAutoStr := "auto_accept_authority"
509 uassert.Equal(t, autoStr, expectedAutoStr)
510
511 // DroppedAuthority
512 droppedAuth := NewDroppedAuthority()
513 droppedStr := droppedAuth.String()
514 expectedDroppedStr := "dropped_authority"
515 uassert.Equal(t, droppedStr, expectedDroppedStr)
516}
517
518// TestContractAuthorityUnauthorizedCaller verifies the
519// fix: a ContractAuthority's default proposer is the contract itself, so
520// any other caller is rejected UPSTREAM (at the proposer) and the handler
521// and privileged action never run. Previously the default proposer was
522// AutoAcceptAuthority, and the only guard was a handler-side
523// `unsafe.CurrentRealm() == contractAddr` check — which was bypassable
524// and which many consumers (e.g. r/gnops/valopers) omitted entirely,
525// leaving no caller check at all.
526func TestContractAuthorityUnauthorizedCaller(cur realm, t *testing.T) {
527 contractPath := "gno.land/r/testcontract"
528 contractAddr := chain.PackageAddress(contractPath)
529 unauthorizedAddr := testutils.TestAddress("unauthorized")
530
531 // A permissive handler that simply runs the action — the realistic
532 // shape a consumer registers. The package, not the handler, must be
533 // the one that rejects an unauthorized caller.
534 handlerExecuted := false
535 contractHandler := func(title string, action PrivilegedAction) error {
536 handlerExecuted = true
537 return action()
538 }
539 contractAuth := NewContractAuthority(contractPath, contractHandler)
540
541 actionExecuted := false
542 privilegedAction := func() error {
543 actionExecuted = true
544 return nil
545 }
546
547 // 1. Unauthorized caller: rejected by the contract-identity proposer
548 // before the handler or the action can run.
549 err := contractAuth.Authorize(unauthorizedAddr, "test_action", privilegedAction)
550 uassert.Error(t, err, "unauthorized caller must be rejected")
551 uassert.ErrorContains(t, err, "unauthorized", "rejection must come from the contract-identity proposer")
552 uassert.False(t, handlerExecuted, "handler must not run for an unauthorized caller")
553 uassert.False(t, actionExecuted, "privileged action must not run for an unauthorized caller")
554
555 // 2. The contract itself is authorized.
556 err = contractAuth.Authorize(contractAddr, "test_action", privilegedAction)
557 uassert.NoError(t, err, "the contract itself must be authorized")
558 uassert.True(t, handlerExecuted, "handler must run when the contract is the caller")
559 uassert.True(t, actionExecuted, "privileged action must run when the contract is the caller")
560}
561
562// TestAuthorizerDoByPrevious verifies the "calling realm authorizes"
563// pattern: a function (the inner crossing closure) invokes
564// DoByPrevious so the authority check sees cur.Previous() — the realm
565// that crossed into it — not the function's own realm.
566//
567// Each scenario crosses into the inner closure via cross(cur) after
568// SetRealm — inside the closure, cur is the fresh live cur and
569// cur.Previous() is the SetRealm'd outer realm. This is the only way
570// to exercise DoByPrevious correctly under the IsCurrent guard, which
571// rejects synthetic realm values (testing.MakeRealm) and stored
572// stale captures.
573func TestAuthorizerDoByPrevious(cur realm, t *testing.T) {
574 alice := testutils.TestAddress("alice")
575 bob := testutils.TestAddress("bob")
576
577 auth := NewWithMembers(alice)
578
579 // alice (member) crosses in: cur.Previous() == alice inside the inner closure.
580 testing.SetRealm(testing.NewUserRealm(alice))
581 executed := false
582 args := []any{"test_arg", 123}
583 func(cur realm) {
584 err := auth.DoByPrevious(0, cur, "test_action", func() error {
585 executed = true
586 return nil
587 }, args...)
588 uassert.NoError(t, err, "expected no error")
589 uassert.True(t, executed, "action should have been executed")
590 }(cross(cur))
591
592 expectedErr := errors.New("test error")
593 func(cur realm) {
594 err := auth.DoByPrevious(0, cur, "test_action", func() error {
595 return expectedErr
596 })
597 uassert.ErrorContains(t, err, expectedErr.Error(), "expected error")
598 }(cross(cur))
599
600 // bob (not a member) crosses in: Authorize must reject.
601 testing.SetRealm(testing.NewUserRealm(bob))
602 executed = false
603 func(cur realm) {
604 err := auth.DoByPrevious(0, cur, "test_action", func() error {
605 executed = true
606 return nil
607 }, "unauthorized_arg")
608 uassert.ErrorContains(t, err, "unauthorized", "expected error")
609 uassert.False(t, executed, "action should not have been executed")
610 }(cross(cur))
611}
612
613// ---------------------------------------------------------------------------
614// Contract-identity gate: regression suite.
615//
616// A plain NewContractAuthority now defaults its proposer to the contract
617// ITSELF (MemberAuthority of the contract's own package address) instead of
618// AutoAcceptAuthority. These tests pin the security contract: only the
619// contract may drive privileged actions, an external caller can neither
620// drive nor Transfer the authority, the contract can still rotate its own
621// authority, and the escape hatch for a genuinely-open proposer still
622// exists.
623// ---------------------------------------------------------------------------
624
625// The default proposer accepts the contract itself and rejects everyone
626// else, before the handler or action can run.
627func TestDefaultProposerIsContractOnly(cur realm, t *testing.T) {
628 const path = "gno.land/r/defcontract"
629 contractAddr := chain.PackageAddress(path)
630
631 ran := 0
632 ca := NewContractAuthority(path, func(_ string, action PrivilegedAction) error {
633 return action()
634 })
635
636 // The contract itself: authorized.
637 err := ca.Authorize(contractAddr, "action", func() error { ran++; return nil })
638 uassert.NoError(t, err, "the contract itself must be authorized")
639
640 // Anyone else: rejected at the proposer, action never runs.
641 outsider := testutils.TestAddress("outsider")
642 err = ca.Authorize(outsider, "action", func() error { ran++; return nil })
643 uassert.Error(t, err, "a non-contract caller must be rejected")
644 uassert.ErrorContains(t, err, "unauthorized", "rejection must come from the contract-identity proposer")
645
646 uassert.Equal(t, 1, ran, "only the contract's action must have run")
647}
648
649// An external realm that holds another realm's Authorizer cannot Transfer
650// it. The caller derived under IsCurrent is that external realm, not the
651// contract, so the rotation is rejected and the authority is left intact.
652func TestExternalTransferRejected(cur realm, t *testing.T) {
653 authorizer := NewWithAuthority(
654 NewContractAuthority("gno.land/r/victim", func(_ string, action PrivilegedAction) error {
655 return action()
656 }),
657 )
658
659 attacker := testutils.TestAddress("attacker")
660 testing.SetRealm(testing.NewUserRealm(attacker))
661
662 var err error
663 func(cur realm) { err = authorizer.Transfer(0, cur, NewDroppedAuthority()) }(cross(cur))
664
665 uassert.Error(t, err, "external Transfer must be rejected")
666 uassert.ErrorContains(t, err, "unauthorized", "rejection must come from the contract-identity proposer")
667
668 _, ok := authorizer.Authority().(*ContractAuthority)
669 uassert.True(t, ok, "authority must be unchanged after a rejected rotation")
670}
671
672// Same shape via DoByPrevious (a privileged action instead of a
673// Transfer): an external caller is rejected and the action never runs.
674func TestExternalDoByPreviousRejected(cur realm, t *testing.T) {
675 authorizer := NewWithAuthority(
676 NewContractAuthority("gno.land/r/victim2", func(_ string, action PrivilegedAction) error {
677 return action()
678 }),
679 )
680
681 ran := false
682 testing.SetRealm(testing.NewUserRealm(testutils.TestAddress("randomuser")))
683 var err error
684 func(cur realm) {
685 err = authorizer.DoByPrevious(0, cur, "privileged", func() error { ran = true; return nil })
686 }(cross(cur))
687
688 uassert.Error(t, err, "external DoByPrevious must be rejected")
689 uassert.False(t, ran, "privileged action must not run for an external caller")
690}
691
692// The contract-identity model does NOT lock out legitimate governance: the
693// contract can still rotate (Transfer) its own authority when the transfer
694// is driven with the contract as the previous realm (caller == contractAddr).
695func TestContractCanRotateItsOwnAuthority(cur realm, t *testing.T) {
696 const path = "gno.land/r/selfgov"
697 authorizer := NewWithAuthority(
698 NewContractAuthority(path, func(_ string, action PrivilegedAction) error {
699 return action()
700 }),
701 )
702
703 // Drive the transfer with the contract as the caller: set the outer
704 // realm to the contract, then cross into a closure so cur.Previous()
705 // resolves to the contract.
706 testing.SetRealm(testing.NewCodeRealm(path))
707 var err error
708 func(cur realm) { err = authorizer.Transfer(0, cur, NewAutoAcceptAuthority()) }(cross(cur))
709
710 uassert.NoError(t, err, "the contract itself must be able to rotate its authority")
711 _, ok := authorizer.Authority().(*AutoAcceptAuthority)
712 uassert.True(t, ok, "authority should have rotated to auto-accept")
713}
714
715// Boundary test: the raw Authority.Authorize takes `caller` as a plain
716// parameter, so a holder of the raw Authority can forge caller ==
717// contractAddr. This proves that even so, a forged-caller raw Authorize
718// only runs the action the caller itself supplies — it CANNOT mutate an
719// Authorizer's installed authority, because the Transfer mutation lives
720// only inside Authorizer.Transfer (which derives caller non-forgeably).
721// The lesson encoded here: consumers must reach a ContractAuthority through
722// the Authorizer wrapper and must not hand out the raw Authority.
723//
724// WHAT THIS TEST DOES AND DOES NOT FREEZE. Only the final assertion is a
725// security property. That raw Authorize ACCEPTS a forged caller is
726// current behaviour, not a guarantee — it is the consequence of `caller`
727// being a parameter on an exported interface method. If someone later
728// hardens that path so raw Authorize stops accepting a forged caller,
729// this test going red means the test is stale, NOT that the change is
730// wrong: delete the two intermediate assertions and keep the last one.
731// (Recorded because asserting NoError here reads like the forgeability
732// is wanted. It is tolerated, and only because of what follows.)
733func TestForgedCallerCannotTransfer(cur realm, t *testing.T) {
734 const path = "gno.land/r/boundary"
735 contractAddr := chain.PackageAddress(path)
736
737 authorizer := NewWithAuthority(
738 NewContractAuthority(path, func(_ string, action PrivilegedAction) error {
739 return action()
740 }),
741 )
742
743 // Attacker obtains the raw Authority and forges the contract as caller.
744 attackerRan := false
745 err := authorizer.Authority().Authorize(contractAddr, "transfer_authority", func() error {
746 attackerRan = true
747 return nil
748 })
749
750 // Current behaviour, documented above — not a property to preserve.
751 uassert.NoError(t, err, "raw Authorize currently accepts a forged caller (see comment)")
752 uassert.True(t, attackerRan, "only the caller's own inert closure runs")
753
754 // THE property: the installed authority is UNCHANGED. Raw Authorize
755 // cannot perform a Transfer — the mutation closure is private to
756 // Authorizer.Transfer, which derives caller non-forgeably.
757 _, ok := authorizer.Authority().(*ContractAuthority)
758 uassert.True(t, ok, "raw Authorize must not be able to transfer the authority")
759}
760
761// Two contract authorities on different paths never cross-authorize.
762func TestContractPathIsolation(cur realm, t *testing.T) {
763 caB := NewContractAuthority("gno.land/r/pathb", func(_ string, action PrivilegedAction) error {
764 return action()
765 })
766 addrA := chain.PackageAddress("gno.land/r/patha")
767
768 ran := false
769 err := caB.Authorize(addrA, "action", func() error { ran = true; return nil })
770 uassert.Error(t, err, "contract A must not authorize contract B's authority")
771 uassert.False(t, ran, "action must not run across contract identities")
772}
773
774// Escape hatch: a consumer that genuinely wants open proposals can still
775// opt in via NewRestrictedContractAuthority with an AutoAcceptAuthority
776// proposer — restoring the pre-fix "anyone can propose" behavior explicitly.
777func TestRestrictedAutoAcceptEscapeHatch(cur realm, t *testing.T) {
778 ca := NewRestrictedContractAuthority(
779 "gno.land/r/open",
780 func(_ string, action PrivilegedAction) error { return action() },
781 NewAutoAcceptAuthority(),
782 )
783
784 ran := false
785 err := ca.Authorize(testutils.TestAddress("anyone"), "action", func() error { ran = true; return nil })
786 uassert.NoError(t, err, "an explicit AutoAccept proposer restores open proposals")
787 uassert.True(t, ran, "action must run under an explicit AutoAccept proposer")
788}
789
790// Why the nil handler is now rejected at construction rather than
791// tolerated: Authorize checks contractHandler == nil BEFORE consulting the
792// proposer, and Transfer routes through Authorize, so there is no rotation
793// path out. It is a permanent brick reachable by an ordinary deployer
794// typo -- the same bricked-governance failure mode, by accident.
795//
796// The zero value is used because the constructor no longer allows it.
797func TestNilHandlerWouldBeUnrotatable(cur realm, t *testing.T) {
798 const path = "gno.land/r/nilbrick"
799 addr := chain.PackageAddress(path)
800 authorizer := NewWithAuthority(&ContractAuthority{
801 contractPath: path,
802 contractAddr: addr,
803 // Both handler and proposer are nil: a struct literal is the only
804 // way to reach this, and Authorize fails closed on either.
805 })
806
807 // Drive the rotation as the contract itself -- the only principal the
808 // default proposer accepts. If this cannot escape, nobody can.
809 testing.SetRealm(testing.NewCodeRealm(path))
810 var err error
811 func(cur realm) { err = authorizer.Transfer(0, cur, NewAutoAcceptAuthority()) }(cross(cur))
812
813 uassert.Error(t, err, "a nil-handler authority must not be silently rotatable")
814 _, stuck := authorizer.Authority().(*ContractAuthority)
815 uassert.True(t, stuck, "nil-handler authority is unrotatable -- hence rejected at construction")
816}
817
818// A crossing closure declared HERE carries this package's identity, not
819// the contract path's -- testing.SetRealm does not change that, because
820// the VM mints a crossing frame's realm from the callee's declaring
821// package. So the DoByCurrent shape that Example_contractAuthority teaches
822// cannot be exercised from inside this package at all; it needs a real
823// realm. It is covered by filetests/z_contract_authority_shape_filetest.gno.
824//
825// The same property is why an exported crossing closure leaks a realm's
826// authority to any caller -- it is one mechanism seen from two
827// sides. This test pins the half that is observable here: an unrelated
828// package's frame is rejected.
829func TestForeignFrameCannotDriveContractAuthority(cur realm, t *testing.T) {
830 auth := NewWithAuthority(NewContractAuthority("gno.land/r/demo/dao", mockDAOHandler))
831
832 testing.SetRealm(testing.NewCodeRealm("gno.land/r/example"))
833 ran := false
834 var err error
835 func(cur realm) {
836 err = auth.DoByCurrent(0, cur, "update_params", func() error {
837 ran = true
838 return nil
839 })
840 }(cross(cur))
841
842 uassert.Error(t, err, "a frame from another package must not drive this authority")
843 uassert.False(t, ran, "the action must not run")
844}
845
846// A malformed contract path binds the authority to an address no realm can
847// ever present. Because Transfer routes through the same gate, such an
848// authority is also unrotatable: a permanent brick, and the same failure
849// mode the nil-handler panic exists to prevent. Rejected at construction.
850//
851// Pre-fix these all constructed happily; the AutoAccept default made the
852// typo harmless (and insecure), so nothing complained.
853func TestContractPathValidationRejectsMalformed(cur realm, t *testing.T) {
854 handler := func(_ string, action PrivilegedAction) error { return action() }
855
856 for _, path := range []string{
857 "gno.land/r/gov/dao ", // trailing space
858 " gno.land/r/gov/dao", // leading space
859 "gno.land/r/gov/dao\n", // trailing newline
860 "gno.land/r/gov dao", // embedded space
861 " ",
862 "not a path",
863 "GNO.LAND/R/GOV/DAO", // uppercase: not a legal gno pkgpath
864 "gno.land//r/gov/dao",
865 "gno.land/r/gov/dao/",
866 "singlesegment",
867 } {
868 uassert.PanicsContains(t, cur, "contract path", func() {
869 NewContractAuthority(path, handler)
870 }, "malformed path must be rejected: "+path)
871 uassert.PanicsContains(t, cur, "contract path", func() {
872 NewRestrictedContractAuthority(path, handler, NewAutoAcceptAuthority())
873 }, "malformed path must be rejected by the restricted ctor too: "+path)
874 }
875
876 // Real paths still construct, including dashes, digits, underscores and
877 // version suffixes.
878 for _, path := range []string{
879 "gno.land/r/gov/dao",
880 "gno.land/r/gnops/valopers",
881 "gno.land/p/moul/authz/v0",
882 "gno.land/r/sys/validators/v0",
883 "gno.land/r/some-user/my_pkg2",
884 } {
885 uassert.NotPanics(t, cur, func() {
886 NewContractAuthority(path, handler)
887 }, "legitimate path must construct: "+path)
888 }
889}
890
891// spoofProposer is a wide-open Authority that LIES in String(), returning
892// the same text the canonical contract-identity default renders.
893type spoofProposer struct{}
894
895func (spoofProposer) Authorize(caller address, title string, action PrivilegedAction, args ...any) error {
896 return action()
897}
898
899func (spoofProposer) String() string { return "contract-identity" }
900
901// A foreign proposer must not be able to impersonate the gated default in
902// the rendered description. ContractAuthority.String() is promoted by this
903// package as the one surface distinguishing a gated authority from a
904// wide-open one, and consumers assert on it (r/gnops/valopers.Auth()). An
905// impl that chooses its own String() text defeated that: a fully permissive
906// authority rendered byte-identical to the default, so every string-based
907// configuration pin stayed green while the gate was gone.
908func TestSpoofedProposerCannotImpersonateContractIdentity(cur realm, t *testing.T) {
909 const path = "gno.land/r/spoofprobe"
910 handler := func(_ string, action PrivilegedAction) error { return action() }
911 outsider := chain.PackageAddress("gno.land/r/outsider")
912
913 gated := NewContractAuthority(path, handler)
914 spoof := NewRestrictedContractAuthority(path, handler, spoofProposer{})
915
916 // The behavioural difference the strings must reflect.
917 uassert.Error(t, gated.Authorize(outsider, "t", func() error { return nil }),
918 "the gated default must reject an outsider")
919 uassert.NoError(t, spoof.Authorize(outsider, "t", func() error { return nil }),
920 "the spoofing proposer is wide open -- that is the point of the test")
921
922 // ...and they do.
923 uassert.NotEqual(t, gated.String(), spoof.String(),
924 "a wide-open authority must not render identically to the gated default")
925 uassert.Equal(t,
926 "contract_authority[contract="+path+",proposer=contract-identity]",
927 gated.String())
928 uassert.Equal(t,
929 "contract_authority[contract="+path+",proposer=custom_authority[contract-identity]]",
930 spoof.String(),
931 "a non-canonical proposer must be wrapped, not trusted to name itself")
932
933 // Same guarantee through the Authorizer wrapper, which is the shape a
934 // consumer realm actually exposes.
935 uassert.NotEqual(t,
936 NewWithAuthority(gated).String(),
937 NewWithAuthority(spoof).String())
938}
939
940// An explicit proposer REPLACES the contract-identity gate; it does not add
941// to it. NewRestrictedContractAuthority(govdaoPath, h, member(alice)) means
942// "alice, and not GovDAO" -- contractAddr is never consulted. Pinned because
943// the rendered string reads like a conjunction and the old godoc said
944// "widen", so a consumer could reasonably have expected "both".
945func TestExplicitProposerReplacesIdentityGate(cur realm, t *testing.T) {
946 const path = "gno.land/r/gov/dao"
947 handler := func(_ string, action PrivilegedAction) error { return action() }
948 govdao := chain.PackageAddress(path)
949 alice := chain.PackageAddress("gno.land/r/alice")
950
951 gated := NewContractAuthority(path, handler)
952 replaced := NewRestrictedContractAuthority(path, handler, NewMemberAuthority(alice))
953
954 uassert.NoError(t, gated.Authorize(govdao, "x", func() error { return nil }),
955 "the default gate accepts the bound contract")
956 uassert.Error(t, gated.Authorize(alice, "x", func() error { return nil }),
957 "the default gate rejects everyone else")
958
959 uassert.Error(t, replaced.Authorize(govdao, "x", func() error { return nil }),
960 "an explicit proposer REPLACES the identity gate: govdao is no longer accepted")
961 uassert.NoError(t, replaced.Authorize(alice, "x", func() error { return nil }),
962 "only the explicit proposer's principal is accepted")
963}