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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions clearnode/api/app_session_v1/create_app_session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1167,3 +1167,148 @@ func TestCreateAppSession_AppRegistryDisabled(t *testing.T) {
mockStore.AssertNotCalled(t, "GetApp", mock.Anything)
mockStore.AssertExpectations(t)
}

// TestCreateAppSession_NonAlphanumericApplicationID_Success verifies that application IDs
// containing characters that would not have matched the old ApplicationIDRegex (e.g. uppercase
// letters, dots, slashes) are now accepted, since the IsValidApplicationID check was removed.
func TestCreateAppSession_NonAlphanumericApplicationID_Success(t *testing.T) {
for _, appID := range []string{
"MY.APP.123", // dots and uppercase
"APP/subpath", // slash
"Test App", // space
"UPPER_CASE_APP", // uppercase with underscores
"app:v2.3.4", // colon and dots
} {
appID := appID // capture range variable
t.Run(appID, func(t *testing.T) {
mockStore := new(MockStore)
storeTxProvider := func(fn StoreTxHandler) error {
return fn(mockStore)
}

mockSigner := NewMockChannelSigner()
mockAssetStore := new(MockAssetStore)
mockStatePacker := new(MockStatePacker)

handler := NewHandler(
storeTxProvider,
mockAssetStore,
&MockActionGateway{},
mockSigner,
core.NewStateAdvancerV1(mockAssetStore),
mockStatePacker,
"0xnode",
true, // appRegistryEnabled
metrics.NewNoopRuntimeMetricExporter(),
32, 1024, 256, 16,
)

wallet1 := NewTestAppSessionWallet(t)
participant1 := wallet1.Address

appDef := app.AppDefinitionV1{
ApplicationID: appID,
Participants: []app.AppParticipantV1{
{WalletAddress: participant1, SignatureWeight: 1},
},
Quorum: 1,
Nonce: 99999,
}
sig1 := wallet1.SignCreateRequest(t, appDef, "")

reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{
Definition: rpc.AppDefinitionV1{
Application: appID,
Participants: []rpc.AppParticipantV1{
{WalletAddress: participant1, SignatureWeight: 1},
},
Quorum: 1,
Nonce: "99999",
},
QuorumSigs: []string{sig1},
}

mockStore.On("GetApp", appID).Return(&app.AppInfoV1{
App: app.AppV1{ID: appID, CreationApprovalNotRequired: true},
}, nil).Once()
mockStore.On("CreateAppSession", mock.Anything).Return(nil).Once()

payload, err := rpc.NewPayload(reqPayload)
require.NoError(t, err)

ctx := &rpc.Context{
Context: context.Background(),
Request: rpc.NewRequest(1, string(rpc.AppSessionsV1CreateAppSessionMethod), payload),
}

handler.CreateAppSession(ctx)

require.NotNil(t, ctx.Response)
if respErr := ctx.Response.Error(); respErr != nil {
t.Fatalf("appID %q: unexpected error: %v", appID, respErr)
}
assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type)

var resp rpc.AppSessionsV1CreateAppSessionResponse
err = ctx.Response.Payload.Translate(&resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.AppSessionID)

mockStore.AssertExpectations(t)
})
}
}

// TestCreateAppSession_EmptyApplicationID_Rejected verifies that an empty application ID
// is still rejected even after the regex validation was removed.
func TestCreateAppSession_EmptyApplicationID_Rejected(t *testing.T) {
mockStore := new(MockStore)
storeTxProvider := func(fn StoreTxHandler) error {
return fn(mockStore)
}

handler := NewHandler(
storeTxProvider,
nil,
&MockActionGateway{},
nil,
nil,
nil,
"0xnode",
true,
metrics.NewNoopRuntimeMetricExporter(),
32, 1024, 256, 16,
)

participant1 := "0x1111111111111111111111111111111111111111"

reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{
Definition: rpc.AppDefinitionV1{
Application: "", // empty — must still be rejected
Participants: []rpc.AppParticipantV1{
{WalletAddress: participant1, SignatureWeight: 1},
},
Quorum: 1,
Nonce: "12345",
},
QuorumSigs: []string{"0x1234"},
}

payload, err := rpc.NewPayload(reqPayload)
require.NoError(t, err)

ctx := &rpc.Context{
Context: context.Background(),
Request: rpc.NewRequest(1, string(rpc.AppSessionsV1CreateAppSessionMethod), payload),
}

handler.CreateAppSession(ctx)

require.NotNil(t, ctx.Response)
err = ctx.Response.Error()
require.Error(t, err)
assert.Contains(t, err.Error(), "application id is required")

mockStore.AssertNotCalled(t, "GetApp", mock.Anything)
mockStore.AssertNotCalled(t, "CreateAppSession", mock.Anything)
}
63 changes: 46 additions & 17 deletions clearnode/api/app_session_v1/rebalance_app_sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ func TestRebalanceAppSessions_Success_TwoSessions(t *testing.T) {
mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "USDC", decimal.NewFromInt(100)).Return(nil)
mockStore.On("RecordTransaction", mock.MatchedBy(func(tx core.Transaction) bool {
return tx.TxType == core.TransactionTypeRebalance && tx.Asset == "USDC"
}), mock.Anything).Return(nil).Twice()
})).Return(nil).Twice()

// Create RPC context
payload, err := rpc.NewPayload(reqPayload)
Expand Down Expand Up @@ -346,7 +346,7 @@ func TestRebalanceAppSessions_Success_MultiAsset(t *testing.T) {
mockStore.On("RecordLedgerEntry", wallet1.Address, sessionID1, "ETH", decimal.RequireFromString("0.5")).Return(nil)
mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "USDC", decimal.NewFromInt(100)).Return(nil)
mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "ETH", decimal.RequireFromString("-0.5")).Return(nil)
mockStore.On("RecordTransaction", mock.Anything, mock.Anything).Return(nil).Times(4) // 2 assets x 2 sessions
mockStore.On("RecordTransaction", mock.Anything).Return(nil).Times(4) // 2 assets x 2 sessions

// Create RPC context
payload, err := rpc.NewPayload(reqPayload)
Expand Down Expand Up @@ -1106,7 +1106,7 @@ func TestRebalanceAppSessions_AppRegistryDisabled(t *testing.T) {
mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "USDC", decimal.NewFromInt(100)).Return(nil)
mockStore.On("RecordTransaction", mock.MatchedBy(func(tx core.Transaction) bool {
return tx.TxType == core.TransactionTypeRebalance && tx.Asset == "USDC"
}), mock.Anything).Return(nil).Twice()
})).Return(nil).Twice()

payload, err := rpc.NewPayload(reqPayload)
require.NoError(t, err)
Expand Down Expand Up @@ -1255,7 +1255,10 @@ func TestRebalanceAppSessions_Error_DuplicateAllocation(t *testing.T) {
mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
}

func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) {
// TestRebalanceAppSessions_Success_DifferentApplications verifies that rebalancing sessions
// belonging to different applications now succeeds. Previously a cross-application check
// prevented this; the check was removed in this PR.
func TestRebalanceAppSessions_Success_DifferentApplications(t *testing.T) {
mockStore := new(MockStore)
storeTxProvider := func(fn StoreTxHandler) error {
return fn(mockStore)
Expand All @@ -1269,7 +1272,7 @@ func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) {
nil,
nil,
"0xNode",
false, // registry disabled — simpler: skip GetApp path
false, // registry disabled — skip GetApp path to keep test focused
metrics.NewNoopRuntimeMetricExporter(),
32, 1024, 256, 16,
)
Expand All @@ -1282,32 +1285,47 @@ func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) {

session1 := &app.AppSessionV1{
SessionID: sessionID1,
ApplicationID: "app-one",
ApplicationID: "app-one", // different app
Participants: []app.AppParticipantV1{{WalletAddress: wallet1.Address, SignatureWeight: 10}},
Quorum: 10,
Status: app.AppSessionStatusOpen,
Version: 5,
}
session2 := &app.AppSessionV1{
SessionID: sessionID2,
ApplicationID: "app-two", // Different application
ApplicationID: "app-two", // different app
Participants: []app.AppParticipantV1{{WalletAddress: wallet2.Address, SignatureWeight: 10}},
Quorum: 10,
Status: app.AppSessionStatusOpen,
Version: 3,
}

// Session 1: loses 100 USDC
currentAllocations1 := map[string]map[string]decimal.Decimal{
wallet1.Address: {"USDC": decimal.NewFromInt(200)},
}
// Session 2: gains 100 USDC
currentAllocations2 := map[string]map[string]decimal.Decimal{
wallet2.Address: {"USDC": decimal.NewFromInt(50)},
}

appStateUpdate1 := app.AppStateUpdateV1{
AppSessionID: sessionID1,
Intent: app.AppStateUpdateIntentRebalance,
Version: 6,
Allocations: []app.AppAllocationV1{
{Participant: wallet1.Address, Asset: "USDC", Amount: decimal.NewFromInt(100)},
},
}
sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1)

appStateUpdate2 := app.AppStateUpdateV1{
AppSessionID: sessionID2,
Intent: app.AppStateUpdateIntentRebalance,
Version: 4,
Allocations: []app.AppAllocationV1{
{Participant: wallet2.Address, Asset: "USDC", Amount: decimal.NewFromInt(150)},
},
}
sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2)

Expand All @@ -1318,6 +1336,9 @@ func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) {
AppSessionID: sessionID1,
Intent: app.AppStateUpdateIntentRebalance,
Version: "6",
Allocations: []rpc.AppAllocationV1{
{Participant: wallet1.Address, Asset: "USDC", Amount: "100"},
},
},
QuorumSigs: []string{sig1},
},
Expand All @@ -1326,6 +1347,9 @@ func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) {
AppSessionID: sessionID2,
Intent: app.AppStateUpdateIntentRebalance,
Version: "4",
Allocations: []rpc.AppAllocationV1{
{Participant: wallet2.Address, Asset: "USDC", Amount: "150"},
},
},
QuorumSigs: []string{sig2},
},
Expand All @@ -1334,10 +1358,19 @@ func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) {

mockStore.On("GetAppSession", sessionID1).Return(session1, nil)
mockStore.On("GetAppSession", sessionID2).Return(session2, nil)
// Session 1 is fully processed (tx rolls back on failure); session 2 trips the cross-app check.
emptyAllocations := map[string]map[string]decimal.Decimal{}
mockStore.On("GetParticipantAllocations", sessionID1).Return(emptyAllocations, nil).Maybe()
mockStore.On("UpdateAppSession", mock.Anything).Return(nil).Maybe()
mockStore.On("GetParticipantAllocations", sessionID1).Return(currentAllocations1, nil)
mockStore.On("GetParticipantAllocations", sessionID2).Return(currentAllocations2, nil)
mockStore.On("UpdateAppSession", mock.MatchedBy(func(s app.AppSessionV1) bool {
return s.SessionID == sessionID1 && s.Version == 6
})).Return(nil).Once()
mockStore.On("UpdateAppSession", mock.MatchedBy(func(s app.AppSessionV1) bool {
return s.SessionID == sessionID2 && s.Version == 4
})).Return(nil).Once()
mockStore.On("RecordLedgerEntry", wallet1.Address, sessionID1, "USDC", decimal.NewFromInt(-100)).Return(nil)
mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "USDC", decimal.NewFromInt(100)).Return(nil)
mockStore.On("RecordTransaction", mock.MatchedBy(func(tx core.Transaction) bool {
return tx.TxType == core.TransactionTypeRebalance && tx.Asset == "USDC"
})).Return(nil).Twice()

payload, err := rpc.NewPayload(reqPayload)
require.NoError(t, err)
Expand All @@ -1349,10 +1382,6 @@ func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) {

handler.RebalanceAppSessions(ctx)

assertError(t, ctx, "cannot rebalance app sessions from different applications")

// Ledger/transaction writes happen only after all per-session validation completes,
// so the cross-app failure on session 2 must prevent them entirely.
mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything)
assertSuccess(t, ctx)
mockStore.AssertExpectations(t)
}
Loading
Loading