Unsafe test patterns occur when test-only code, debug functionality, or development shortcuts accidentally make it into production smart contracts. In Sui Move, the #[test_only] attribute should isolate test code, but improper patterns can leak test utilities, create backdoors, or leave vulnerabilities that only manifest in production environments.
Risk Level
High — Can create backdoors, bypass security controls, or cause unexpected production behavior.
OWASP / CWE Mapping
OWASP Top 10
MITRE CWE
A04 (Insecure Design)
CWE-704 (Incorrect Type Conversion or Cast), CWE-665 (Improper Initialization)
The Problem
Common Unsafe Test Patterns
Issue
Risk
Description
Test functions without #[test_only]
Critical
Test code callable in production
test_scenario in production
Critical
Fake context manipulation
Debug mint functions
Critical
Unlimited token creation
Hardcoded test addresses
High
Known addresses exploitable
Disabled security checks
High
Guards removed for testing
Mock oracles in production
Critical
Fake price data accepted
Vulnerable Example
modulevulnerable::token{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::transfer;usesui::coin::{Self,Coin,TreasuryCap};/// VULNERABLE: No #[test_only] - callable in production!
publicfunmint_for_testing(cap: &mutTreasuryCap<TOKEN>,amount: u64,ctx: &mutTxContext): Coin<TOKEN>{// Anyone who has the cap can mint unlimited tokens
// This was meant for tests only!
coin::mint(cap,amount,ctx)}/// VULNERABLE: Debug function left in production
publicentryfundebug_set_balance(vault: &mutVault,new_balance: u64,_ctx: &mutTxContext){// No access control - was "temporary" for debugging
vault.balance=new_balance;}/// VULNERABLE: Test bypass flag
publicentryfuntransfer_tokens(vault: &mutVault,amount: u64,recipient: address,skip_checks: bool,// "For testing" - but callable by anyone!
ctx: &mutTxContext){if(!skip_checks){assert!(vault.owner==tx_context::sender(ctx),E_NOT_OWNER);assert!(amount<=vault.balance,E_INSUFFICIENT);};// Transfer proceeds even without checks if skip_checks = true
vault.balance=vault.balance-amount;// ...
}}modulevulnerable::oracle{/// VULNERABLE: Test oracle mode in production
publicstructOraclehaskey{id: UID,price: u64,/// VULNERABLE: Allows anyone to set price in "test mode"
test_mode: bool,}publicentryfunset_price(oracle: &mutOracle,new_price: u64,_ctx: &mutTxContext){if(oracle.test_mode){// No signature verification in test mode!
oracle.price=new_price;}else{// Normal verification...
};}/// VULNERABLE: Anyone can enable test mode
publicentryfunenable_test_mode(oracle: &mutOracle,_ctx: &mutTxContext){oracle.test_mode=true;}}modulevulnerable::admin{/// VULNERABLE: Hardcoded test admin address
constTEST_ADMIN: address=@0x1234;publicentryfunadmin_action(state: &mutState,ctx: &mutTxContext){letsender=tx_context::sender(ctx);// VULNERABLE: Test admin works in production!
if(sender==TEST_ADMIN||sender==state.admin){// Perform admin action
};}}modulevulnerable::escrow{/// VULNERABLE: Emergency bypass without proper guards
constEMERGENCY_WITHDRAW_ENABLED: bool=true;// Forgot to disable!
publicentryfunemergency_withdraw(escrow: &mutEscrow,ctx: &mutTxContext){// VULNERABLE: This was for testing emergencies
if(EMERGENCY_WITHDRAW_ENABLED){// Anyone can withdraw everything!
letall_funds=withdraw_all(escrow,ctx);transfer::public_transfer(all_funds,tx_context::sender(ctx));};}}// VULNERABLE: Test helper module without #[test_only]
modulevulnerable::test_helpers{usesui::tx_context::TxContext;/// Should be test_only but isn't!
publicfuncreate_fake_admin_cap(ctx: &mutTxContext): AdminCap{AdminCap{id: object::new(ctx)}}/// Should be test_only but isn't!
publicfunset_timestamp(clock: &mutClock,new_time: u64){clock.timestamp_ms=new_time;}}
Attack Scenario
moduleattack::exploit_test_code{usevulnerable::token;usevulnerable::oracle;usevulnerable::test_helpers;/// Attacker uses "test" functions in production
publicentryfunexploit(oracle: &mutOracle,treasury_cap: &mutTreasuryCap<TOKEN>,ctx: &mutTxContext){// Step 1: Enable test mode on oracle
oracle::enable_test_mode(oracle);// Step 2: Set price to manipulate protocol
oracle::set_price(oracle,1,ctx);// Crash the price
// Step 3: Mint unlimited tokens
letfree_money=token::mint_for_testing(treasury_cap,1000000000,ctx);// Step 4: Create fake admin cap
letfake_admin=test_helpers::create_fake_admin_cap(ctx);// Complete protocol takeover!
}}
Secure Example
modulesecure::token{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::coin::{Self,Coin,TreasuryCap};/// SECURE: Test function properly annotated
#[test_only]publicfunmint_for_testing(cap: &mutTreasuryCap<TOKEN>,amount: u64,ctx: &mutTxContext): Coin<TOKEN>{coin::mint(cap,amount,ctx)}/// SECURE: No debug functions in production code
// debug_set_balance doesn't exist at all
/// SECURE: No bypass flags
publicentryfuntransfer_tokens(vault: &mutVault,amount: u64,recipient: address,ctx: &mutTxContext){// Always enforce security checks
assert!(vault.owner==tx_context::sender(ctx),E_NOT_OWNER);assert!(amount<=vault.balance,E_INSUFFICIENT);vault.balance=vault.balance-amount;// Transfer...
}}modulesecure::oracle{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::ed25519;/// SECURE: No test mode in production struct
publicstructOraclehaskey{id: UID,price: u64,last_update: u64,trusted_pubkey: vector<u8>,}/// SECURE: Always requires signature verification
publicentryfunset_price(oracle: &mutOracle,new_price: u64,timestamp: u64,signature: vector<u8>,_ctx: &mutTxContext){// Always verify signature - no test mode bypass
letmessage=create_price_message(new_price,timestamp);letvalid=ed25519::ed25519_verify(&signature,&oracle.trusted_pubkey,&message);assert!(valid,E_INVALID_SIGNATURE);oracle.price=new_price;oracle.last_update=timestamp;}/// SECURE: Test-only mock oracle
#[test_only]publicfuncreate_test_oracle(price: u64,ctx: &mutTxContext): Oracle{Oracle{id: object::new(ctx),price,last_update: 0,trusted_pubkey: vector::empty(),// Not used in tests
}}#[test_only]publicfunset_price_for_testing(oracle: &mutOracle,new_price: u64){oracle.price=new_price;}}modulesecure::admin{usesui::object::{Self,UID,ID};usesui::tx_context::{Self,TxContext};/// SECURE: No hardcoded addresses
publicstructAdminCaphaskey{id: UID,state_id: ID,}/// SECURE: Capability-based access control
publicentryfunadmin_action(cap: &AdminCap,state: &mutState,_ctx: &mutTxContext){assert!(cap.state_id==object::id(state),E_WRONG_STATE);// Perform admin action
}/// SECURE: Test admin cap creation is test-only
#[test_only]publicfuncreate_admin_cap_for_testing(state: &State,ctx: &mutTxContext): AdminCap{AdminCap{id: object::new(ctx),state_id: object::id(state),}}}modulesecure::escrow{usesui::object::{Self,UID};usesui::tx_context::{Self,TxContext};usesui::clock::{Self,Clock};constEMERGENCY_DELAY_MS: u64=86400000;// 24 hours
/// SECURE: Emergency withdraw with proper controls
publicstructEscrowhaskey{id: UID,owner: address,balance: u64,emergency_requested_at: Option<u64>,}/// SECURE: Emergency requires time delay and ownership
publicentryfunrequest_emergency_withdraw(escrow: &mutEscrow,clock: &Clock,ctx: &mutTxContext){assert!(tx_context::sender(ctx)==escrow.owner,E_NOT_OWNER);escrow.emergency_requested_at=option::some(clock::timestamp_ms(clock));}publicentryfunexecute_emergency_withdraw(escrow: &mutEscrow,clock: &Clock,ctx: &mutTxContext){assert!(tx_context::sender(ctx)==escrow.owner,E_NOT_OWNER);assert!(option::is_some(&escrow.emergency_requested_at),E_NOT_REQUESTED);letrequested_at=*option::borrow(&escrow.emergency_requested_at);letnow=clock::timestamp_ms(clock);// SECURE: Must wait 24 hours
assert!(now>=requested_at+EMERGENCY_DELAY_MS,E_TOO_EARLY);// Proceed with withdrawal
escrow.emergency_requested_at=option::none();// ...
}}/// SECURE: Test helpers are properly isolated
#[test_only]modulesecure::test_helpers{usesui::object::{Self,UID};usesui::tx_context::TxContext;usesui::test_scenario;publicfunsetup_test_env(sender: address): test_scenario::Scenario{test_scenario::begin(sender)}publicfuncreate_test_clock(timestamp: u64,ctx: &mutTxContext): Clock{// Only available in tests
Clock{id: object::new(ctx),timestamp_ms: timestamp,}}}
Test Pattern Guidelines
Pattern 1: Proper Test Annotation
/// Production code - always present
publicfuncalculate_fee(amount: u64): u64{(amount*FEE_BPS)/10000}/// Test code - only compiled in tests
#[test_only]publicfuncalculate_fee_for_testing(amount: u64,custom_bps: u64): u64{(amount*custom_bps)/10000}#[test]funtest_fee_calculation(){assert!(calculate_fee(1000)==30,0);// 0.3% fee
}
Pattern 2: Test-Only Module
/// Production module
modulemyprotocol::core{publicstructStatehaskey{id: UID,value: u64,}publicfunget_value(state: &State): u64{state.value}// No test utilities here
}/// Completely separate test module
#[test_only]modulemyprotocol::core_tests{usemyprotocol::core::{Self,State};usesui::test_scenario;funcreate_test_state(ctx: &mutTxContext): State{// Test-only state creation
}#[test]funtest_get_value(){// Test implementation
}}
Pattern 3: Feature Flags (Compile-Time)
/// Use Move.toml for environment-specific builds
/// NOT runtime flags that can be toggled
// In Move.toml:
// [package]
// name = "MyProtocol"
//
// [dev-addresses]
// myprotocol = "0x0"
//
// [addresses]
// myprotocol = "0xPRODUCTION_ADDRESS"
/// Code uses compile-time address
modulemyprotocol::config{// Address is set at compile time, not runtime
constADMIN: address=@myprotocol;}
Pattern 4: Separate Test Scenarios
#[test_only]modulemyprotocol::integration_tests{usesui::test_scenario::{Self,Scenario};usesui::test_utils;constALICE: address=@0xA;constBOB: address=@0xB;funsetup(): Scenario{letmutscenario=test_scenario::begin(ALICE);// Setup test environment
scenario}#[test]funtest_full_flow(){letmutscenario=setup();// Test as Alice
test_scenario::next_tx(&mutscenario,ALICE);{// Alice's actions
};// Test as Bob
test_scenario::next_tx(&mutscenario,BOB);{// Bob's actions
};test_scenario::end(scenario);}}
Pre-Deployment Checklist
Code Review Items
1. [ ] Search for "test" in function names - all should be #[test_only]
2. [ ] Search for "debug" in function names - should not exist
3. [ ] Search for "mock" - should be #[test_only]
4. [ ] Search for "skip" or "bypass" parameters - should not exist
5. [ ] Search for hardcoded addresses - should use capabilities
6. [ ] Search for boolean flags that disable security - remove them
7. [ ] Verify no `test_scenario` usage outside #[test_only]
8. [ ] Check for "TODO" or "FIXME" comments about security
// Production code in src/
modulemyprotocol::core{}// Test code with #[test_only]
#[test_only]modulemyprotocol::core_tests{}
5. Pre-Deployment Audit
// Before mainnet:
// 1. Remove all #[test_only] modules from build
// 2. Verify no test patterns in production code
// 3. Check for debug/mock functions
// 4. Review all public functions