Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
thibaudgg committed Sep 13, 2024
1 parent fc64841 commit 70e0bcb
Show file tree
Hide file tree
Showing 12 changed files with 689 additions and 2 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Tests

on: push

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true

- name: Run tests
run: |
bundle exec ruby test/*_test.rb
1 change: 1 addition & 0 deletions .ruby-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.3.5
17 changes: 17 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
source "https://rubygems.org"

ruby file: ".ruby-version"

gem "sinatra", github: "sinatra", require: "sinatra/base"
gem "rackup"
gem "puma"

gem "activesupport", require: "active_support/all"
gem "logger"
gem "json"

group :test do
gem "minitest"
gem "rack-test"
end

79 changes: 79 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
GIT
remote: https://github.com/sinatra/sinatra.git
revision: 973c936319af9132d7ab2f60985e359d0c75c93e
specs:
rack-protection (4.0.0)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
sinatra (4.0.0)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.0.0)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)

GEM
remote: https://rubygems.org/
specs:
activesupport (7.2.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
base64 (0.2.0)
bigdecimal (3.1.8)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
drb (2.2.1)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
json (2.7.2)
logger (1.6.1)
minitest (5.25.1)
mustermann (3.0.3)
ruby2_keywords (~> 0.0.1)
nio4r (2.7.3)
puma (6.4.2)
nio4r (~> 2.0)
rack (3.1.7)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack (>= 1.3)
rackup (2.1.0)
rack (>= 3)
webrick (~> 1.8)
ruby2_keywords (0.0.5)
securerandom (0.3.1)
tilt (2.4.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
webrick (1.8.1)

PLATFORMS
arm64-darwin-23
ruby

DEPENDENCIES
activesupport
json
logger
minitest
puma
rack-test
rackup
sinatra!

RUBY VERSION
ruby 3.3.5p100

BUNDLED WITH
2.5.18
1 change: 1 addition & 0 deletions Procfile
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
web: bundle exec puma -p $PORT
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,22 @@
# locali-ge.csa-admin
Small Sinatra app to handle locali-ge.ch woocommerce webhooks
# locali-ge.csa-admin.org

A small Sinatra app to handle locali-ge.ch WooCommerce webhooks and automatically create a new member in the CSA Admin organization.

The mapping of the WooCommerce products to the CSA Admin organization resources is handled in the `config/mapping.yml` file. The `api_endpoint` of the CSA Admin API must be set in the `config/config.yml` file for each organization.

## Deployment

The `WEBHOOK_SECRET` environment variable must be set to the WooCommerce webhook secret.

For each organization, the `<ORGANIZATION>_API_TOKEN` environment variable must be set.

## Testing

```sh
bundle install
bundle exec ruby test/*_test.rb
```

## Author

[Thibaud Guillaume-Gentil](https://thibaud.gg)
49 changes: 49 additions & 0 deletions app.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
require 'bundler/setup'
Bundler.require(:default)

require_relative 'lib/webhook'

class App < Sinatra::Base
configure :production, :development do
enable :logging
end

before do
@request_body = request.body.read
end

post '/webhook' do
verify_signature!
payload = parse_payload

logger.info payload
begin
member_params = Webhook.handle!(payload)
logger.info member_params
rescue ArgumentError => e
logger.info e.message
end

status 204
end

private

def verify_signature!
signature = request.env['HTTP_X_WC_WEBHOOK_SIGNATURE']
secret = ENV['WEBHOOK_SECRET']

computed_hmac = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', secret, @request_body))

unless signature && Rack::Utils.secure_compare(computed_hmac, signature)
halt 403, 'Forbidden'
end
end

def parse_payload
JSON.parse(@request_body)
rescue JSON::ParserError
halt 400, 'Invalid JSON'
end
end
2 changes: 2 additions & 0 deletions config.ru
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require './app'
run App
14 changes: 14 additions & 0 deletions config/mapping.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
cocagne:
store_id: 31
api_endpoint: https://admin.cocagne.ch/api/v1/members
basket_sizes:
35087: 1 # Grande part
basket_complements:
34780: 9 # Oeufs 6 pièces
34781: 10 # Pain Cora blé 500g
34782: 12 # Pain Cora épeautre 450g
34783: 6 # Fromage frais
34787: 14 # Jus de pomme regarge
depots:
34088: 43 # 16 ch. Franconis 1290 Versoix
34087: 22 # 15 ch. de Roches 1208 Genève
98 changes: 98 additions & 0 deletions lib/webhook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
require 'yaml'

class Webhook
attr_reader :payload

def self.handle!(payload)
new(payload).handle!
end

def initialize(payload)
@payload = payload
end

def handle!
ensure_mapping!

member_params
end

private

def ensure_mapping!
return if mapping

store_name = @payload.dig("store", "name")
raise ArgumentError, "No mapping found for store: #{store_id} (#{store_name})"
end

def member_params
{
organization: organization,
name: "#{billing["last_name"]} #{billing["first_name"]}",
emails: billing["email"],
phones: billing["phone"],
address: [billing["address_1"], billing["address_2"]].map(&:presence).compact.join(', '),
city: billing["city"],
zip: billing["postcode"],
country_code: billing["country"],
waiting_basket_size_id: mapping_id_for("basket_sizes"),
waiting_depot_id: mapping_id_for("depots"),
members_basket_complements_attributes: basket_complements
}
end

def mapping
@mapping ||= YAML.load_file('./config/mapping.yml').detect { |name, v|
v['store_id'] == store_id
}
end

def organization
mapping.first
end

def basket_complements
mapping_ids_for("basket_complements").map { |id|
{ basket_complement_id: id, quanity: 1 }
}
end

def mapping_id_for(type)
mapping.last[type].each { |product_id, id|
return id if product_id.in?(product_ids)
}
nil
end

def mapping_ids_for(type)
ids = []
mapping.last[type].each { |product_id, id|
ids << id if product_id.in?(product_ids)
}
ids
end

def product_ids
@ids ||= begin
ids =[]
@payload.fetch("line_items").each { |item|
ids << item["product_id"]
item["meta_data"].each { |meta|
if meta["key"] == "selected_item_post_id"
meta["value"].each { |v| v.values.each { |v| ids += Array(v["value"]) } }
end
}
}
ids.map(&:to_i)
end
end

def billing
@billing ||= @payload.fetch("billing")
end

def store_id
@payload.dig("store", "id")
end
end
75 changes: 75 additions & 0 deletions test/app_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
ENV['RACK_ENV'] = 'test'

require 'bundler/setup'
Bundler.require(:default, :test)

require_relative '../app'
require "minitest/autorun"

class AppTest < Minitest::Test
include Rack::Test::Methods

def app; App end

def setup
@secret = 'test_secret'
ENV['WEBHOOK_SECRET'] = @secret
end

def request(payload, secret: nil)
secret ||= @secret
signature = Base64.strict_encode64(
OpenSSL::HMAC.digest('sha256', secret, payload))

header "Content-Type", 'application/json'
header "X-WC-Webhook-Signature", signature

post "/webhook", payload
end

def test_valid_webhook_request
payload = File.read('test/fixtures/order_created.json')

request(payload)

assert_equal 204, last_response.status
assert_empty last_response.body
end

def test_unknown_store
payload = { "store" => { "id" => 999, "name" => "Unknown" } }.to_json

request(payload)

assert_equal 204, last_response.status
assert_empty last_response.body
end

def test_invalid_signature
payload = { "test_key" => "test_value" }.to_json

request(payload, secret: "wrong_secret")

assert_equal 403, last_response.status
assert_equal "Forbidden", last_response.body
end

def test_missing_signature_header
payload = { "test_key" => "test_value" }.to_json

header "Content-Type", 'application/json'
post "/webhook", payload

assert_equal 403, last_response.status
assert_equal "Forbidden", last_response.body
end

def test_invalid_json_payload
payload = "invalid_json"

request(payload)

assert_equal 400, last_response.status
assert_includes last_response.body, "Invalid JSON"
end
end
Loading

0 comments on commit 70e0bcb

Please sign in to comment.