Files
strike_me-api/spec/strike_me/api/invoices_spec.rb
T
bterkuileandClaude Sonnet 5 ac2558f5ce Fix critical webhook signature case bug; add correlationId + AR type registration
Strike sends X-Webhook-Signature in UPPERCASE hex. OpenSSL::HMAC.
hexdigest always returns lowercase, and valid_signature? compared them
case-sensitively via secure_compare — every genuine Strike webhook was
being silently rejected as an invalid signature. Confirmed live: executed
a real Lightning payment (payment-quotes/lightning + PATCH .../execute),
received a real payment.updated webhook, and recomputing the HMAC over
the captured payload/secret matched case-insensitively but not
case-sensitively — proving the algorithm/secret/body were already
correct and this was purely a case-comparison bug. Their own reference
docs (docs.strike.me/webhooks/signature-verification) don't mention the
case difference. Regression test added using the real captured signature,
upcased.

Also, both found via that same live test:
- Invoices::Invoice didn't declare correlationId, raising
  ActiveModel::UnknownAttributeError the moment a real one (which
  CreateInvoice always sends) round-tripped through a live response —
  no existing spec fixture's stubbed body had included it.
- BtcAttributeType was only registered with ActiveModel::Type, not
  ActiveRecord::Type (a separate registry) — an `attribute :x,
  :strike_btc` declaration on an ActiveRecord model raised "Unknown
  type" without this. Guarded behind defined?(ActiveRecord::Type) since
  this gem only depends on active_model.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:13:03 -05:00

102 lines
6.2 KiB
Ruby

# frozen_string_literal: true
RSpec.describe StrikeMe::API::Invoices do
subject { described_class.new }
describe 'list' do
context 'success response' do
before { stub_request(:get, 'https://api.strike.me/v1/invoices').to_return(status: 200, body: '{"items":[{"invoiceId":"97dca64c-6dde-4a87-b970-279f1a8e0ac5","amount":{"amount":"0.00014","currency":"BTC"},"state":"PAID","created":"2025-08-08T22:28:44.195825+00:00","description":"Hi there","issuerId":"41952e07-483c-4cd7-afa4-b6a8ec729809","receiverId":"41952e07-483c-4cd7-afa4-b6a8ec729809","payerId":"f75c22bf-625f-4c47-acdf-acb9c596bb8c"},{"invoiceId":"aed2cbf7-e2cd-4a1c-874e-a84489342ac2","amount":{"amount":"8.00","currency":"USD"},"state":"PAID","created":"2025-07-31T22:24:19.302922+00:00","description":"","issuerId":"a34a9cdc-1853-4f3b-9bd8-66d6700b1558","receiverId":"a34a9cdc-1853-4f3b-9bd8-66d6700b1558","payerId":"f75c22bf-625f-4c47-acdf-acb9c596bb8c"},{"invoiceId":"4773efeb-9aa7-47a0-a3cc-0a57a3f7718a","amount":{"amount":"0.00047455","currency":"BTC"},"state":"PAID","created":"2025-07-10T23:22:28.324613+00:00","description":"","issuerId":"a34a9cdc-1853-4f3b-9bd8-66d6700b1558","receiverId":"a34a9cdc-1853-4f3b-9bd8-66d6700b1558","payerId":"f75c22bf-625f-4c47-acdf-acb9c596bb8c"},{"invoiceId":"f3853f94-d198-4b83-8b9a-11a836926819","amount":{"amount":"40.00","currency":"USDT"},"state":"UNPAID","created":"2023-05-20T22:44:36.856775+00:00","description":"","issuerId":"f75c22bf-625f-4c47-acdf-acb9c596bb8c","receiverId":"f75c22bf-625f-4c47-acdf-acb9c596bb8c"}],"count":4,"isCountUnknown":false}', headers: {'Content-Type' => 'application/json'}) }
it '...' do
list = subject.list
expect(list.first).to be_paid
expect(list.last ).not_to be_paid
expect(list.first.attributes).to eq(
'invoiceId' => '97dca64c-6dde-4a87-b970-279f1a8e0ac5',
'amount' => {'amount' => '0.00014', 'currency' => 'BTC'},
'state' => 'PAID',
'created' => '2025-08-08 22:28:44.195825 +0000', # is actual Time
'description' => 'Hi there',
'issuerId' => '41952e07-483c-4cd7-afa4-b6a8ec729809',
'receiverId' => '41952e07-483c-4cd7-afa4-b6a8ec729809',
'payerId' => 'f75c22bf-625f-4c47-acdf-acb9c596bb8c',
'correlationId' => nil
)
expect(list.first.id ).to eq '97dca64c-6dde-4a87-b970-279f1a8e0ac5'
expect(list.first.created).to be_a Time
expect(list.last.attributes).to eq(
'invoiceId' => 'f3853f94-d198-4b83-8b9a-11a836926819',
'amount' => {'amount' => '40.00', 'currency' => 'USDT'},
'state' => 'UNPAID',
'created' => '2023-05-20 22:44:36.856775 +0000',
'description' => '',
'issuerId' => 'f75c22bf-625f-4c47-acdf-acb9c596bb8c',
'receiverId' => 'f75c22bf-625f-4c47-acdf-acb9c596bb8c',
'correlationId' => nil,
'payerId' => nil
)
end
end
end
describe '#find' do
before do
stub_request(:get, 'https://api.strike.me/v1/invoices/97dca64c-6dde-4a87-b970-279f1a8e0ac5')
.to_return(status: 200, body: '{"invoiceId":"97dca64c-6dde-4a87-b970-279f1a8e0ac5","amount":{"amount":"0.00014","currency":"BTC"},"state":"PAID","created":"2025-08-08T22:28:44.195825+00:00","description":"Hi there","issuerId":"41952e07-483c-4cd7-afa4-b6a8ec729809","receiverId":"41952e07-483c-4cd7-afa4-b6a8ec729809","payerId":"f75c22bf-625f-4c47-acdf-acb9c596bb8c"}', headers: {'Content-Type' => 'application/json'})
end
it 'finds and returns the proper object' do
invoice = subject.find('97dca64c-6dde-4a87-b970-279f1a8e0ac5')
expect(invoice).to be_a StrikeMe::API::Invoices::Invoice
expect(invoice.id).to eq '97dca64c-6dde-4a87-b970-279f1a8e0ac5'
expect(invoice).to be_paid
end
end
describe '#create' do
before do
stub_request(:post, 'https://api.strike.me/v1/invoices')
.with(body: {amount: {amount: '10.00', currency: 'USD'}, description: 'Table 3'})
.to_return(status: 201, body: '{"invoiceId":"602cc206-6b4a-42dd-bc89-9a534bb04034","amount":{"amount":"10.00","currency":"USD"},"state":"UNPAID","created":"2026-09-15T12:00:00+00:00","description":"Table 3","issuerId":"41952e07-483c-4cd7-afa4-b6a8ec729809","receiverId":"41952e07-483c-4cd7-afa4-b6a8ec729809"}', headers: {'Content-Type' => 'application/json'})
end
it 'issues a new invoice, unpaid, with no bolt11 yet — that only exists once quoted' do
invoice = subject.create(amount: '10.00', currency: 'USD', description: 'Table 3')
expect(invoice).to be_a StrikeMe::API::Invoices::Invoice
expect(invoice.id).to eq '602cc206-6b4a-42dd-bc89-9a534bb04034'
expect(invoice).to be_unpaid
end
it 'omits correlationId/description entirely when not given, rather than sending them null' do
stub = stub_request(:post, 'https://api.strike.me/v1/invoices')
.with(body: {amount: {amount: '10.00', currency: 'USD'}})
.to_return(status: 201, body: '{"invoiceId":"x","amount":{"amount":"10.00","currency":"USD"},"state":"UNPAID","created":"2026-09-15T12:00:00+00:00","issuerId":"a","receiverId":"a"}', headers: {'Content-Type' => 'application/json'})
subject.create(amount: '10.00', currency: 'USD')
expect(stub).to have_been_requested
end
end
describe '#quote' do
before do
stub_request(:post, 'https://api.strike.me/v1/invoices/602cc206-6b4a-42dd-bc89-9a534bb04034/quote')
.with(body: {})
.to_return(status: 201, body: '{"quoteId":"a1b2c3","lnInvoice":"lnbc100n1p...","expiration":"2099-01-01T00:00:00+00:00","expirationInSec":300,"targetAmount":{"amount":"10.00","currency":"USD"},"sourceAmount":{"amount":"0.00014","currency":"BTC"},"conversionRate":{"amount":"71428.57","sourceCurrency":"BTC","targetCurrency":"USD"}}', headers: {'Content-Type' => 'application/json'})
end
it 'returns a Quote carrying the actual payable bolt11' do
quote = subject.quote('602cc206-6b4a-42dd-bc89-9a534bb04034')
expect(quote).to be_a StrikeMe::API::Invoices::Quote
expect(quote.bolt11).to eq 'lnbc100n1p...'
expect(quote.id).to eq 'a1b2c3'
expect(quote.expiration).to be_a Time
expect(quote).not_to be_expired
end
end
end