Skip to content

Commit 96510bd

Browse files
authored
Hide internal error for CCRA and EPS services when some settings is not set (#21562)
1 parent b737068 commit 96510bd

File tree

6 files changed

+279
-38
lines changed

6 files changed

+279
-38
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# frozen_string_literal: true
2+
3+
require 'common/exceptions'
4+
5+
module VAOS
6+
module Exceptions
7+
##
8+
# ConfigurationError represents a configuration issue in the VAOS service
9+
# that prevents it from operating properly. This is used to provide a clean
10+
# API response when there are internal configuration issues.
11+
#
12+
class ConfigurationError < Common::Exceptions::ServiceError
13+
attr_reader :error
14+
15+
##
16+
# Initialize a new ConfigurationError
17+
#
18+
# @param error [StandardError] The original error that occurred
19+
# @param service_name [String] The name of the service that encountered the error
20+
#
21+
def initialize(error, service_name = 'VAOS')
22+
@error = error
23+
24+
super(
25+
detail: "The #{service_name} service is unavailable due to a configuration issue",
26+
source: service_name
27+
)
28+
end
29+
30+
##
31+
# Returns the HTTP status code for this error
32+
#
33+
# @return [Integer] The HTTP status code (503 Service Unavailable)
34+
#
35+
def status
36+
503 # Service Unavailable
37+
end
38+
39+
##
40+
# Returns the error code
41+
#
42+
# @return [String] The error code
43+
#
44+
def code
45+
'VAOS_CONFIG_ERROR'
46+
end
47+
48+
##
49+
# Override i18n_data to provide custom error information
50+
#
51+
# @return [Hash] The error data
52+
#
53+
def i18n_data
54+
{
55+
title: 'Service Configuration Error',
56+
detail: @detail,
57+
code:,
58+
status: status.to_s
59+
}
60+
end
61+
end
62+
end
63+
end

modules/vaos/app/services/common/jwt_wrapper.rb

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,40 @@
11
# frozen_string_literal: true
22

3+
require_relative '../../exceptions/vaos/exceptions/configuration_error'
4+
35
module Common
46
class JwtWrapper
57
SIGNING_ALGORITHM = 'RS512'
68

9+
class ConfigurationError < StandardError; end
10+
711
attr_reader :expiration, :settings
812

913
delegate :key_path, :client_id, :kid, :audience_claim_url, to: :settings
1014

11-
def initialize(service_settings)
15+
def initialize(service_settings, service_config)
1216
@settings = service_settings
17+
@config = service_config
1318
@expiration = 5
1419
end
1520

1621
def sign_assertion
1722
JWT.encode(claims, rsa_key, SIGNING_ALGORITHM, jwt_headers)
23+
rescue ConfigurationError => e
24+
Rails.logger.error("Service Configuration Error: #{e.message}")
25+
raise VAOS::Exceptions::ConfigurationError.new(e, @config.service_name)
1826
end
1927

2028
def rsa_key
21-
@rsa_key ||= OpenSSL::PKey::RSA.new(File.read(key_path))
29+
raise ConfigurationError, 'RSA key path is not configured' if key_path.blank?
30+
31+
raise ConfigurationError, "RSA key file not found at: #{key_path}" unless File.exist?(key_path)
32+
33+
@rsa_key ||= begin
34+
OpenSSL::PKey::RSA.new(File.read(key_path))
35+
rescue => e
36+
raise ConfigurationError, "Failed to load RSA key: #{e.message}"
37+
end
2238
end
2339

2440
private

modules/vaos/app/services/concerns/token_authentication.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def token_headers
7676
#
7777
# @return [Common::JwtWrapper] the JWT wrapper instance.
7878
def jwt_wrapper
79-
@jwt_wrapper ||= Common::JwtWrapper.new(settings)
79+
@jwt_wrapper ||= Common::JwtWrapper.new(settings, config)
8080
end
8181
end
8282

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
require_relative '../../../../app/exceptions/vaos/exceptions/configuration_error'
5+
6+
describe VAOS::Exceptions::ConfigurationError do
7+
let(:original_error) { StandardError.new('Test error message') }
8+
let(:service_name) { 'TestService' }
9+
let(:exception) { described_class.new(original_error, service_name) }
10+
11+
describe '#initialize' do
12+
it 'stores the original error' do
13+
expect(exception.error).to eq(original_error)
14+
end
15+
end
16+
17+
describe '#status' do
18+
it 'returns 503 (Service Unavailable)' do
19+
expect(exception.status).to eq(503)
20+
end
21+
end
22+
23+
describe '#code' do
24+
it 'returns the error code' do
25+
expect(exception.code).to eq('VAOS_CONFIG_ERROR')
26+
end
27+
end
28+
29+
describe '#i18n_data' do
30+
it 'returns the custom error data' do
31+
expect(exception.i18n_data).to include(
32+
title: 'Service Configuration Error',
33+
code: 'VAOS_CONFIG_ERROR',
34+
status: '503'
35+
)
36+
end
37+
end
38+
39+
describe '#errors' do
40+
let(:error_object) { exception.errors.first }
41+
42+
it 'returns an array with a single error object' do
43+
expect(exception.errors).to be_an(Array)
44+
expect(exception.errors.size).to eq(1)
45+
end
46+
47+
it 'formats the error with the correct title' do
48+
expect(error_object.title).to eq('Service Configuration Error')
49+
end
50+
51+
it 'includes the service name in the detail message' do
52+
expect(error_object.detail).to eq("The #{service_name} service is unavailable due to a configuration issue")
53+
end
54+
55+
it 'uses the standard error code' do
56+
expect(error_object.code).to eq('VAOS_CONFIG_ERROR')
57+
end
58+
59+
it 'includes the status code as a string' do
60+
expect(error_object.status).to eq('503')
61+
end
62+
63+
it 'includes the source' do
64+
expect(error_object.source).to eq(service_name)
65+
end
66+
end
67+
end

modules/vaos/spec/requests/v2/referrals_spec.rb

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,47 @@
4848
expect(first_referral['attributes']).to have_key('type_of_care')
4949
end
5050
end
51+
52+
context 'when a configuration error occurs' do
53+
# Note we only test this once as the code is the same for both endpoints
54+
let(:jwt_error) { Common::JwtWrapper::ConfigurationError.new('Configuration error occurred') }
55+
let(:config_error) { VAOS::Exceptions::ConfigurationError.new(jwt_error, 'CCRA') }
56+
57+
before do
58+
sign_in_as(user)
59+
allow(service_double).to receive(:get_vaos_referral_list).and_raise(config_error)
60+
end
61+
62+
it 'returns 503 Service Unavailable with properly formatted error response' do
63+
get '/vaos/v2/referrals'
64+
65+
expect(response).to have_http_status(:service_unavailable)
66+
67+
response_data = JSON.parse(response.body)
68+
69+
expect(response_data).to have_key('errors')
70+
expect(response_data['errors']).to be_an(Array)
71+
expect(response_data['errors'].first).to include(
72+
'title' => 'Service Configuration Error',
73+
'detail' => 'The CCRA service is unavailable due to a configuration issue',
74+
'code' => 'VAOS_CONFIG_ERROR',
75+
'status' => '503'
76+
)
77+
end
78+
79+
it 'does not expose internal error details' do
80+
get '/vaos/v2/referrals'
81+
82+
response_data = JSON.parse(response.body)
83+
84+
# Original error message is not leaked
85+
expect(response_data['errors'].first['detail']).not_to include('Configuration error occurred')
86+
87+
# No stack trace is included
88+
expect(response_data['errors'].first).not_to have_key('meta')
89+
expect(response_data['errors'].first).not_to have_key('backtrace')
90+
end
91+
end
5192
end
5293

5394
describe 'GET /vaos/v2/referrals/:id' do
Lines changed: 89 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
# frozen_string_literal: true
22

33
require 'rails_helper'
4+
require_relative '../../../app/services/common/jwt_wrapper'
45

56
describe Common::JwtWrapper do
6-
subject { described_class.new(settings) }
7+
subject { described_class.new(settings, service_config) }
78

8-
let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
9+
let(:service_name) { 'TestService' }
10+
let(:service_config) { instance_double(VAOS::Configuration, service_name:) }
911
let(:settings) do
10-
OpenStruct.new({
11-
key_path: '/path/to/key.pem',
12-
client_id: 'test_client',
13-
kid: 'test_kid',
14-
audience_claim_url: 'https://test.example.com'
15-
})
12+
OpenStruct.new(
13+
key_path: '/path/to/key.pem',
14+
client_id: 'test_client',
15+
kid: 'test_kid',
16+
audience_claim_url: 'http://test.example.com/token'
17+
)
1618
end
1719

20+
let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
21+
1822
before do
19-
allow(File).to receive(:read).with('/path/to/key.pem').and_return(rsa_key)
23+
allow(File).to receive(:exist?).and_call_original
24+
allow(File).to receive(:exist?).with('/path/to/key.pem').and_return(true)
25+
allow(File).to receive(:read).with('/path/to/key.pem').and_return(rsa_key.to_s)
2026
end
2127

2228
describe 'constants' do
@@ -36,52 +42,100 @@
3642
end
3743

3844
describe '#sign_assertion' do
39-
let(:jwt_token) { subject.sign_assertion }
40-
let(:decoded_token) do
41-
JWT.decode(
42-
jwt_token,
43-
rsa_key.public_key,
44-
true,
45-
algorithm: described_class::SIGNING_ALGORITHM
46-
)
47-
end
45+
let(:encoded_token) { 'encoded.jwt.token' }
46+
let(:test_time) { Time.zone.at(1_234_567_890) }
4847

49-
it 'returns a valid JWT token' do
50-
expect { decoded_token }.not_to raise_error
48+
before do
49+
# Fix the time for deterministic testing
50+
allow(Time.zone).to receive(:now).and_return(test_time)
51+
allow(JWT).to receive(:encode).and_return(encoded_token)
52+
allow(Rails).to receive(:logger).and_return(double('Logger').as_null_object)
53+
allow(OpenSSL::PKey::RSA).to receive(:new).and_return(rsa_key)
5154
end
5255

53-
it 'includes the correct headers' do
54-
headers = decoded_token.last
55-
expect(headers['kid']).to eq('test_kid')
56-
expect(headers['typ']).to eq('JWT')
57-
expect(headers['alg']).to eq('RS512')
56+
context 'when JWT encoding is successful' do
57+
it 'returns the encoded JWT token' do
58+
expect(subject.sign_assertion).to eq(encoded_token)
59+
end
60+
61+
it 'encodes with the correct parameters' do
62+
# Just check that claims hash contains required keys
63+
expect(JWT).to receive(:encode)
64+
.with(
65+
hash_including(:iss, :sub, :aud, :iat, :exp),
66+
kind_of(OpenSSL::PKey::RSA),
67+
'RS512',
68+
hash_including(:kid, :typ, :alg)
69+
)
70+
.and_return(encoded_token)
71+
72+
subject.sign_assertion
73+
end
5874
end
5975

60-
it 'includes the correct claims' do
61-
claims = decoded_token.first
62-
expect(claims['iss']).to eq('test_client')
63-
expect(claims['sub']).to eq('test_client')
64-
expect(claims['aud']).to eq('https://test.example.com')
65-
expect(claims['iat']).to be_within(5).of(Time.zone.now.to_i)
66-
expect(claims['exp']).to be_within(5).of(5.minutes.from_now.to_i)
76+
context 'when RSA key loading fails' do
77+
let(:wrapper_with_error) { described_class.new(settings, service_config) }
78+
79+
context 'when key file does not exist' do
80+
before do
81+
allow(File).to receive(:exist?).with('/path/to/key.pem').and_return(false)
82+
end
83+
84+
it 'logs the error and raises a VAOS::Exceptions::ConfigurationError' do
85+
expect(Rails.logger).to receive(:error).with(/Service Configuration Error: RSA key file not found/)
86+
expect { wrapper_with_error.sign_assertion }.to raise_error(VAOS::Exceptions::ConfigurationError)
87+
end
88+
end
89+
90+
context 'when key path is nil' do
91+
let(:settings_with_nil_path) do
92+
OpenStruct.new(
93+
key_path: nil,
94+
client_id: 'test_client',
95+
kid: 'test_kid',
96+
audience_claim_url: 'http://test.example.com/token'
97+
)
98+
end
99+
100+
let(:wrapper_with_nil_path) { described_class.new(settings_with_nil_path, service_config) }
101+
102+
it 'logs the error and raises a VAOS::Exceptions::ConfigurationError' do
103+
expect(Rails.logger).to receive(:error).with(/Service Configuration Error: RSA key path is not configured/)
104+
expect { wrapper_with_nil_path.sign_assertion }.to raise_error(VAOS::Exceptions::ConfigurationError)
105+
end
106+
end
107+
108+
it 'includes the service name in the raised error' do
109+
allow(File).to receive(:exist?).with('/path/to/key.pem').and_return(false)
110+
111+
exception = nil
112+
begin
113+
wrapper_with_error.sign_assertion
114+
rescue VAOS::Exceptions::ConfigurationError => e
115+
exception = e
116+
end
117+
expect(exception).not_to be_nil
118+
expect(exception.errors.first.detail).to include(service_name)
119+
end
67120
end
68121
end
69122

70123
describe '#rsa_key' do
71124
it 'reads the key from the specified path' do
72-
expect(File).to receive(:read).with('/path/to/key.pem').once.and_return(rsa_key)
73-
2.times { subject.rsa_key } # Call twice to test memoization
125+
expect(File).to receive(:read).with('/path/to/key.pem').once
126+
subject.rsa_key
74127
end
75128

76129
it 'returns an RSA key instance' do
130+
allow(OpenSSL::PKey::RSA).to receive(:new).and_return(rsa_key)
77131
expect(subject.rsa_key).to be_a(OpenSSL::PKey::RSA)
78132
end
79133

80134
it 'memoizes the RSA key' do
135+
expect(File).to receive(:read).with('/path/to/key.pem').once
81136
first_call = subject.rsa_key
82137
second_call = subject.rsa_key
83138
expect(first_call).to eq(second_call)
84-
expect(File).to have_received(:read).once
85139
end
86140
end
87141
end

0 commit comments

Comments
 (0)