diff --git a/.mockery.yaml b/.mockery.yaml index 877ad49..6105c3d 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -9,3 +9,6 @@ packages: QuoteService: ChallengeGenerator: ChallengeVerifier: + hash-of-wisdom/internal/controller: + interfaces: + WisdomService: diff --git a/internal/application/application.go b/internal/application/application.go new file mode 100644 index 0000000..3ea5722 --- /dev/null +++ b/internal/application/application.go @@ -0,0 +1,88 @@ +package application + +import ( + "context" + "fmt" + "io" + + "hash-of-wisdom/internal/pow/challenge" + "hash-of-wisdom/internal/protocol" + "hash-of-wisdom/internal/quotes" +) + +// Response represents an encodable response that can write itself to a connection +type Response interface { + Encode(w io.Writer) error +} + +// WisdomService defines the interface for the wisdom service +type WisdomService interface { + GenerateChallenge(ctx context.Context, resource string) (*challenge.Challenge, error) + VerifySolution(ctx context.Context, solution *challenge.Solution) error + GetQuote(ctx context.Context) (*quotes.Quote, error) +} + +// WisdomApplication handles the Word of Wisdom application logic +type WisdomApplication struct { + wisdomService WisdomService +} + +// NewWisdomApplication creates a new wisdom application handler +func NewWisdomApplication(wisdomService WisdomService) *WisdomApplication { + return &WisdomApplication{ + wisdomService: wisdomService, + } +} + +// HandleMessage processes a protocol message and returns an encodable response +func (a *WisdomApplication) HandleMessage(ctx context.Context, msg *protocol.Message) (Response, error) { + switch msg.Type { + case protocol.ChallengeRequestType: + return a.handleChallengeRequest(ctx) + case protocol.SolutionRequestType: + return a.handleSolutionRequest(ctx, msg) + default: + return &protocol.ErrorResponse{ + Code: protocol.ErrMalformedMessage, + Message: fmt.Sprintf("unsupported message type: 0x%02x", msg.Type), + }, nil + } +} + +// handleChallengeRequest processes challenge requests +func (a *WisdomApplication) handleChallengeRequest(ctx context.Context) (Response, error) { + challenge, err := a.wisdomService.GenerateChallenge(ctx, "quotes") + if err != nil { + return &protocol.ErrorResponse{Code: protocol.ErrServerError, Message: "Contact administrator"}, nil + } + + return &protocol.ChallengeResponse{Challenge: challenge}, nil +} + +// handleSolutionRequest processes solution requests +func (a *WisdomApplication) handleSolutionRequest(ctx context.Context, msg *protocol.Message) (Response, error) { + // Parse solution request + var solutionReq protocol.SolutionRequest + if err := solutionReq.Decode(msg.PayloadStream); err != nil { + return &protocol.ErrorResponse{Code: protocol.ErrMalformedMessage, Message: "invalid solution format"}, nil + } + + // Create solution object + solution := &challenge.Solution{ + Challenge: solutionReq.Challenge, + Nonce: solutionReq.Nonce, + } + + // Verify solution + if err := a.wisdomService.VerifySolution(ctx, solution); err != nil { + return &protocol.ErrorResponse{Code: protocol.ErrInvalidSolution, Message: "solution verification failed"}, nil + } + + // Get quote + quote, err := a.wisdomService.GetQuote(ctx) + if err != nil { + return &protocol.ErrorResponse{Code: protocol.ErrServerError, Message: "Contact administrator"}, nil + } + + return &protocol.SolutionResponse{Quote: quote}, nil +} diff --git a/internal/application/application_test.go b/internal/application/application_test.go new file mode 100644 index 0000000..6017a28 --- /dev/null +++ b/internal/application/application_test.go @@ -0,0 +1,324 @@ +package application + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "hash-of-wisdom/internal/application/mocks" + "hash-of-wisdom/internal/pow/challenge" + "hash-of-wisdom/internal/protocol" + "hash-of-wisdom/internal/quotes" + "hash-of-wisdom/internal/service" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestNewWisdomApplication(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + assert.NotNil(t, app) + assert.Equal(t, mockService, app.wisdomService) +} + +func TestWisdomApplication_HandleMessage_UnsupportedType(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + ctx := context.Background() + msg := &protocol.Message{ + Type: protocol.MessageType(0xFF), // Invalid type + PayloadLength: 0, + PayloadStream: nil, + } + + response, err := app.HandleMessage(ctx, msg) + require.NoError(t, err) + + // Type assert to ErrorResponse + errorResponse, ok := response.(*protocol.ErrorResponse) + require.True(t, ok, "Expected ErrorResponse") + assert.Equal(t, protocol.ErrMalformedMessage, errorResponse.Code) + assert.Contains(t, errorResponse.Message, "unsupported message type: 0xff") +} + +func TestWisdomApplication_HandleChallengeRequest_Success(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + // Mock successful challenge generation + testChallenge := &challenge.Challenge{ + Resource: "quotes", + Timestamp: 12345, + Difficulty: 4, + Random: []byte("test"), + HMAC: []byte("signature"), + } + + mockService.On("GenerateChallenge", mock.Anything, "quotes").Return(testChallenge, nil) + + ctx := context.Background() + msg := &protocol.Message{ + Type: protocol.ChallengeRequestType, + PayloadLength: 0, + PayloadStream: nil, + } + + response, err := app.HandleMessage(ctx, msg) + require.NoError(t, err) + + // Type assert to ChallengeResponse + challengeResponse, ok := response.(*protocol.ChallengeResponse) + require.True(t, ok, "Expected ChallengeResponse") + assert.Equal(t, testChallenge, challengeResponse.Challenge) + + mockService.AssertExpectations(t) +} + +func TestWisdomApplication_HandleChallengeRequest_ServiceError(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + // Mock service error + mockService.On("GenerateChallenge", mock.Anything, "quotes").Return(nil, errors.New("service error")) + + ctx := context.Background() + msg := &protocol.Message{ + Type: protocol.ChallengeRequestType, + PayloadLength: 0, + PayloadStream: nil, + } + + response, err := app.HandleMessage(ctx, msg) + require.NoError(t, err) + + // Type assert to ErrorResponse + errorResponse, ok := response.(*protocol.ErrorResponse) + require.True(t, ok, "Expected ErrorResponse") + assert.Equal(t, protocol.ErrServerError, errorResponse.Code) + assert.Equal(t, "Contact administrator", errorResponse.Message) + + mockService.AssertExpectations(t) +} + +func TestWisdomApplication_HandleSolutionRequest_Success(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + // Create test solution request + testChallenge := challenge.Challenge{ + Resource: "quotes", + Timestamp: 12345, + Difficulty: 4, + Random: []byte("test"), + HMAC: []byte("signature"), + } + + solutionPayload := protocol.SolutionRequest{ + Challenge: testChallenge, + Nonce: 12345, + } + + payloadJSON, err := json.Marshal(solutionPayload) + require.NoError(t, err) + + testQuote := "es.Quote{ + Text: "Test quote", + Author: "Test Author", + } + + // Mock successful verification and quote retrieval + mockService.On("VerifySolution", mock.Anything, mock.AnythingOfType("*challenge.Solution")).Return(nil) + mockService.On("GetQuote", mock.Anything).Return(testQuote, nil) + + ctx := context.Background() + msg := &protocol.Message{ + Type: protocol.SolutionRequestType, + PayloadLength: uint32(len(payloadJSON)), + PayloadStream: bytes.NewReader(payloadJSON), + } + + response, err := app.HandleMessage(ctx, msg) + require.NoError(t, err) + + // Type assert to SolutionResponse + solutionResponse, ok := response.(*protocol.SolutionResponse) + require.True(t, ok, "Expected SolutionResponse") + assert.Equal(t, testQuote, solutionResponse.Quote) + + mockService.AssertExpectations(t) +} + +func TestWisdomApplication_HandleSolutionRequest_InvalidJSON(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + invalidJSON := []byte("invalid json") + ctx := context.Background() + msg := &protocol.Message{ + Type: protocol.SolutionRequestType, + PayloadLength: uint32(len(invalidJSON)), + PayloadStream: bytes.NewReader(invalidJSON), + } + + response, err := app.HandleMessage(ctx, msg) + require.NoError(t, err) + + // Type assert to ErrorResponse + errorResponse, ok := response.(*protocol.ErrorResponse) + require.True(t, ok, "Expected ErrorResponse") + assert.Equal(t, protocol.ErrMalformedMessage, errorResponse.Code) + assert.Equal(t, "invalid solution format", errorResponse.Message) + + mockService.AssertNotCalled(t, "VerifySolution") + mockService.AssertNotCalled(t, "GetQuote") +} + +func TestWisdomApplication_HandleSolutionRequest_VerificationFailed(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + // Create test solution request + testChallenge := challenge.Challenge{ + Resource: "quotes", + Timestamp: 12345, + Difficulty: 4, + Random: []byte("test"), + HMAC: []byte("signature"), + } + + solutionPayload := protocol.SolutionRequest{ + Challenge: testChallenge, + Nonce: 12345, + } + + payloadJSON, err := json.Marshal(solutionPayload) + require.NoError(t, err) + + // Mock verification failure + mockService.On("VerifySolution", mock.Anything, mock.AnythingOfType("*challenge.Solution")).Return(service.ErrInvalidSolution) + + ctx := context.Background() + msg := &protocol.Message{ + Type: protocol.SolutionRequestType, + PayloadLength: uint32(len(payloadJSON)), + PayloadStream: bytes.NewReader(payloadJSON), + } + + response, err := app.HandleMessage(ctx, msg) + require.NoError(t, err) + + // Type assert to ErrorResponse + errorResponse, ok := response.(*protocol.ErrorResponse) + require.True(t, ok, "Expected ErrorResponse") + assert.Equal(t, protocol.ErrInvalidSolution, errorResponse.Code) + assert.Equal(t, "solution verification failed", errorResponse.Message) + + mockService.AssertExpectations(t) + mockService.AssertNotCalled(t, "GetQuote") +} + +func TestWisdomApplication_HandleSolutionRequest_QuoteServiceError(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + // Create test solution request + testChallenge := challenge.Challenge{ + Resource: "quotes", + Timestamp: 12345, + Difficulty: 4, + Random: []byte("test"), + HMAC: []byte("signature"), + } + + solutionPayload := protocol.SolutionRequest{ + Challenge: testChallenge, + Nonce: 12345, + } + + payloadJSON, err := json.Marshal(solutionPayload) + require.NoError(t, err) + + // Mock successful verification but quote service error + mockService.On("VerifySolution", mock.Anything, mock.AnythingOfType("*challenge.Solution")).Return(nil) + mockService.On("GetQuote", mock.Anything).Return(nil, errors.New("quote service error")) + + ctx := context.Background() + msg := &protocol.Message{ + Type: protocol.SolutionRequestType, + PayloadLength: uint32(len(payloadJSON)), + PayloadStream: bytes.NewReader(payloadJSON), + } + + response, err := app.HandleMessage(ctx, msg) + require.NoError(t, err) + + // Type assert to ErrorResponse + errorResponse, ok := response.(*protocol.ErrorResponse) + require.True(t, ok, "Expected ErrorResponse") + assert.Equal(t, protocol.ErrServerError, errorResponse.Code) + assert.Equal(t, "Contact administrator", errorResponse.Message) + + mockService.AssertExpectations(t) +} + +func TestWisdomApplication_HandleMessage_ContextCancellation(t *testing.T) { + mockService := mocks.NewMockWisdomService(t) + app := NewWisdomApplication(mockService) + + // Create cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Mock service to respect context cancellation + mockService.On("GenerateChallenge", mock.Anything, "quotes").Return(nil, context.Canceled) + + msg := &protocol.Message{ + Type: protocol.ChallengeRequestType, + PayloadLength: 0, + PayloadStream: nil, + } + + response, err := app.HandleMessage(ctx, msg) + require.NoError(t, err) + + // Type assert to ErrorResponse + errorResponse, ok := response.(*protocol.ErrorResponse) + require.True(t, ok, "Expected ErrorResponse") + assert.Equal(t, protocol.ErrServerError, errorResponse.Code) + + mockService.AssertExpectations(t) +} + +func TestResponseEncoding(t *testing.T) { + // Test ChallengeResponse encoding produces valid binary format + testChallenge := &challenge.Challenge{ + Resource: "quotes", + Timestamp: 12345, + Difficulty: 4, + Random: []byte("test"), + HMAC: []byte("signature"), + } + + challengeResponse := &protocol.ChallengeResponse{Challenge: testChallenge} + var buf bytes.Buffer + err := challengeResponse.Encode(&buf) + require.NoError(t, err) + + // Verify binary format + data := buf.Bytes() + assert.GreaterOrEqual(t, len(data), 5) // At least header size + + // Check message type + assert.Equal(t, byte(protocol.ChallengeResponseType), data[0]) + + // Check payload contains expected data + payload := string(data[5:]) // Skip header + assert.Contains(t, payload, "quotes") + assert.Contains(t, payload, "12345") +} diff --git a/internal/application/mocks/mock_wisdom_service.go b/internal/application/mocks/mock_wisdom_service.go new file mode 100644 index 0000000..eb039bb --- /dev/null +++ b/internal/application/mocks/mock_wisdom_service.go @@ -0,0 +1,203 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + challenge "hash-of-wisdom/internal/pow/challenge" + + mock "github.com/stretchr/testify/mock" + + quotes "hash-of-wisdom/internal/quotes" +) + +// MockWisdomService is an autogenerated mock type for the WisdomService type +type MockWisdomService struct { + mock.Mock +} + +type MockWisdomService_Expecter struct { + mock *mock.Mock +} + +func (_m *MockWisdomService) EXPECT() *MockWisdomService_Expecter { + return &MockWisdomService_Expecter{mock: &_m.Mock} +} + +// GenerateChallenge provides a mock function with given fields: ctx, resource +func (_m *MockWisdomService) GenerateChallenge(ctx context.Context, resource string) (*challenge.Challenge, error) { + ret := _m.Called(ctx, resource) + + if len(ret) == 0 { + panic("no return value specified for GenerateChallenge") + } + + var r0 *challenge.Challenge + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*challenge.Challenge, error)); ok { + return rf(ctx, resource) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *challenge.Challenge); ok { + r0 = rf(ctx, resource) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*challenge.Challenge) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, resource) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockWisdomService_GenerateChallenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GenerateChallenge' +type MockWisdomService_GenerateChallenge_Call struct { + *mock.Call +} + +// GenerateChallenge is a helper method to define mock.On call +// - ctx context.Context +// - resource string +func (_e *MockWisdomService_Expecter) GenerateChallenge(ctx interface{}, resource interface{}) *MockWisdomService_GenerateChallenge_Call { + return &MockWisdomService_GenerateChallenge_Call{Call: _e.mock.On("GenerateChallenge", ctx, resource)} +} + +func (_c *MockWisdomService_GenerateChallenge_Call) Run(run func(ctx context.Context, resource string)) *MockWisdomService_GenerateChallenge_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockWisdomService_GenerateChallenge_Call) Return(_a0 *challenge.Challenge, _a1 error) *MockWisdomService_GenerateChallenge_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockWisdomService_GenerateChallenge_Call) RunAndReturn(run func(context.Context, string) (*challenge.Challenge, error)) *MockWisdomService_GenerateChallenge_Call { + _c.Call.Return(run) + return _c +} + +// GetQuote provides a mock function with given fields: ctx +func (_m *MockWisdomService) GetQuote(ctx context.Context) (*quotes.Quote, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetQuote") + } + + var r0 *quotes.Quote + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*quotes.Quote, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *quotes.Quote); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*quotes.Quote) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockWisdomService_GetQuote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetQuote' +type MockWisdomService_GetQuote_Call struct { + *mock.Call +} + +// GetQuote is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockWisdomService_Expecter) GetQuote(ctx interface{}) *MockWisdomService_GetQuote_Call { + return &MockWisdomService_GetQuote_Call{Call: _e.mock.On("GetQuote", ctx)} +} + +func (_c *MockWisdomService_GetQuote_Call) Run(run func(ctx context.Context)) *MockWisdomService_GetQuote_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockWisdomService_GetQuote_Call) Return(_a0 *quotes.Quote, _a1 error) *MockWisdomService_GetQuote_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockWisdomService_GetQuote_Call) RunAndReturn(run func(context.Context) (*quotes.Quote, error)) *MockWisdomService_GetQuote_Call { + _c.Call.Return(run) + return _c +} + +// VerifySolution provides a mock function with given fields: ctx, solution +func (_m *MockWisdomService) VerifySolution(ctx context.Context, solution *challenge.Solution) error { + ret := _m.Called(ctx, solution) + + if len(ret) == 0 { + panic("no return value specified for VerifySolution") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *challenge.Solution) error); ok { + r0 = rf(ctx, solution) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockWisdomService_VerifySolution_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'VerifySolution' +type MockWisdomService_VerifySolution_Call struct { + *mock.Call +} + +// VerifySolution is a helper method to define mock.On call +// - ctx context.Context +// - solution *challenge.Solution +func (_e *MockWisdomService_Expecter) VerifySolution(ctx interface{}, solution interface{}) *MockWisdomService_VerifySolution_Call { + return &MockWisdomService_VerifySolution_Call{Call: _e.mock.On("VerifySolution", ctx, solution)} +} + +func (_c *MockWisdomService_VerifySolution_Call) Run(run func(ctx context.Context, solution *challenge.Solution)) *MockWisdomService_VerifySolution_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*challenge.Solution)) + }) + return _c +} + +func (_c *MockWisdomService_VerifySolution_Call) Return(_a0 error) *MockWisdomService_VerifySolution_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockWisdomService_VerifySolution_Call) RunAndReturn(run func(context.Context, *challenge.Solution) error) *MockWisdomService_VerifySolution_Call { + _c.Call.Return(run) + return _c +} + +// NewMockWisdomService creates a new instance of MockWisdomService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockWisdomService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockWisdomService { + mock := &MockWisdomService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +}