candy_check 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +5 -0
  3. data/.rubocop.yml +6 -0
  4. data/.ruby-version +1 -0
  5. data/.travis.yml +16 -0
  6. data/Gemfile +4 -0
  7. data/LICENSE.txt +22 -0
  8. data/README.md +157 -0
  9. data/Rakefile +15 -0
  10. data/bin/cc_appstore +90 -0
  11. data/bin/cc_playstore +119 -0
  12. data/candy_check.gemspec +31 -0
  13. data/lib/candy_check/app_store/client.rb +57 -0
  14. data/lib/candy_check/app_store/config.rb +30 -0
  15. data/lib/candy_check/app_store/receipt.rb +83 -0
  16. data/lib/candy_check/app_store/verification.rb +49 -0
  17. data/lib/candy_check/app_store/verification_failure.rb +60 -0
  18. data/lib/candy_check/app_store/verifier.rb +69 -0
  19. data/lib/candy_check/app_store.rb +12 -0
  20. data/lib/candy_check/play_store/client.rb +102 -0
  21. data/lib/candy_check/play_store/config.rb +51 -0
  22. data/lib/candy_check/play_store/discovery_repository.rb +33 -0
  23. data/lib/candy_check/play_store/receipt.rb +81 -0
  24. data/lib/candy_check/play_store/verification.rb +46 -0
  25. data/lib/candy_check/play_store/verification_failure.rb +30 -0
  26. data/lib/candy_check/play_store/verifier.rb +52 -0
  27. data/lib/candy_check/play_store.rb +15 -0
  28. data/lib/candy_check/utils/attribute_reader.rb +30 -0
  29. data/lib/candy_check/utils/config.rb +40 -0
  30. data/lib/candy_check/utils.rb +2 -0
  31. data/lib/candy_check/version.rb +4 -0
  32. data/lib/candy_check.rb +8 -0
  33. data/spec/app_store/client_spec.rb +55 -0
  34. data/spec/app_store/config_spec.rb +41 -0
  35. data/spec/app_store/receipt_spec.rb +92 -0
  36. data/spec/app_store/verifcation_failure_spec.rb +28 -0
  37. data/spec/app_store/verification_spec.rb +66 -0
  38. data/spec/app_store/verifier_spec.rb +110 -0
  39. data/spec/candy_check_spec.rb +9 -0
  40. data/spec/fixtures/api_cache.dump +1 -0
  41. data/spec/fixtures/play_store/api_cache.dump +1 -0
  42. data/spec/fixtures/play_store/auth_failure.txt +18 -0
  43. data/spec/fixtures/play_store/auth_success.txt +20 -0
  44. data/spec/fixtures/play_store/discovery.txt +2841 -0
  45. data/spec/fixtures/play_store/dummy.p12 +0 -0
  46. data/spec/fixtures/play_store/empty.txt +17 -0
  47. data/spec/fixtures/play_store/products_failure.txt +29 -0
  48. data/spec/fixtures/play_store/products_success.txt +22 -0
  49. data/spec/play_store/client_spec.rb +104 -0
  50. data/spec/play_store/config_spec.rb +96 -0
  51. data/spec/play_store/discovery_respository_spec.rb +31 -0
  52. data/spec/play_store/receipt_spec.rb +88 -0
  53. data/spec/play_store/verification_failure_spec.rb +35 -0
  54. data/spec/play_store/verification_spec.rb +80 -0
  55. data/spec/play_store/verifier_spec.rb +95 -0
  56. data/spec/spec_helper.rb +35 -0
  57. data/spec/support/with_fixtures.rb +9 -0
  58. data/spec/support/with_temp_file.rb +23 -0
  59. metadata +270 -0
@@ -0,0 +1,51 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ # Configure the usage of the official Google API SDK client
4
+ class Config < Utils::Config
5
+ # @return [String] your application name
6
+ attr_reader :application_name
7
+ # @return [String] your application's version
8
+ attr_reader :application_version
9
+ # @return [String] an optional file to cache the discovery API result
10
+ attr_reader :cache_file
11
+ # @return [String] your issuer's service account e-mail
12
+ attr_reader :issuer
13
+ # @return [String] the path to your local *.p12 certificate file
14
+ attr_reader :key_file
15
+ # @return [String] the secret to load your certificate file
16
+ attr_reader :key_secret
17
+
18
+ # Initializes a new configuration from a hash
19
+ # @param attributes [Hash]
20
+ # @example Initialize with a discovery cache file
21
+ # ClientConfig.new(
22
+ # application_name: 'YourApplication',
23
+ # application_version: '1.0',
24
+ # cache_file: 'tmp/google_api_cache',
25
+ # issuer: 'abcdefg@developer.gserviceaccount.com',
26
+ # key_file: 'local/google.p12',
27
+ # key_secret: 'notasecret'
28
+ # )
29
+ def initialize(attributes)
30
+ super
31
+ end
32
+
33
+ # @return [String] the decrypted API key from Google
34
+ def api_key
35
+ @api_key ||= begin
36
+ Google::APIClient::KeyUtils.load_from_pkcs12(key_file, key_secret)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def validate!
43
+ validates_presence(:application_name)
44
+ validates_presence(:application_version)
45
+ validates_presence(:issuer)
46
+ validates_presence(:key_file)
47
+ validates_presence(:key_secret)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ # A file-based repository to cache a local copy of the Google API
4
+ # discovery as suggested by Google.
5
+ # @see https://github.com/google/google-api-ruby-client
6
+ class DiscoveryRepository
7
+ # Create a new instance bound to a single file path
8
+ # @param file_path [String] to save and load the cached copy
9
+ def initialize(file_path)
10
+ @file_path = file_path
11
+ end
12
+
13
+ # Tries to load a cached copy of the discovery API. Me be nil if
14
+ # no cached version is available
15
+ # @return [Google::APIClient::API]
16
+ def load
17
+ return unless @file_path && File.exist?(@file_path)
18
+ File.open(@file_path, 'rb') do |file|
19
+ return Marshal.load(file)
20
+ end
21
+ end
22
+
23
+ # Tries to save a local copy of the discovery API.
24
+ # @param discovery [Google::APIClient::API]
25
+ def save(discovery)
26
+ return unless @file_path && discovery
27
+ File.open(@file_path, 'wb') do |file|
28
+ Marshal.dump(discovery, file)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,81 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ # Describes a successful response from the Google verification server
4
+ class Receipt
5
+ include Utils::AttributeReader
6
+
7
+ # @return [Hash] the raw attributes returned from the server
8
+ attr_reader :attributes
9
+
10
+ # Purchased product (0 is purchased, don't ask me why)
11
+ # @see https://developers.google.com/android-publisher/api-ref/purchases/products
12
+ PURCHASE_STATE_PURCHASED = 0
13
+
14
+ # A consumed product
15
+ CONSUMPTION_STATE_CONSUMED = 1
16
+
17
+ # Initializes a new instance which bases on a JSON result
18
+ # from Google API servers
19
+ # @param attributes [Hash]
20
+ def initialize(attributes)
21
+ @attributes = attributes
22
+ end
23
+
24
+ # A product may be purchased or canceled. Ensure a receipt
25
+ # is valid before granting some candy
26
+ # @return [Boolean]
27
+ def valid?
28
+ purchase_state == PURCHASE_STATE_PURCHASED
29
+ end
30
+
31
+ # A purchased product may already be consumed. In this case you
32
+ # should grant candy even if it's valid.
33
+ # @return [Boolean]
34
+ def consumed?
35
+ consumption_state == CONSUMPTION_STATE_CONSUMED
36
+ end
37
+
38
+ # The purchase state of the order. Possible values are:
39
+ # * 0: Purchased
40
+ # * 1: Cancelled
41
+ # @return [Fixnum]
42
+ def purchase_state
43
+ read_integer('purchaseState')
44
+ end
45
+
46
+ # The consumption state of the inapp product. Possible values are:
47
+ # * 0: Yet to be consumed
48
+ # * 1: Consumed
49
+ # @return [Fixnum]
50
+ def consumption_state
51
+ read_integer('consumptionState')
52
+ end
53
+
54
+ # The developer payload which was used when buying the product
55
+ # @return [String]
56
+ def developer_payload
57
+ read('developerPayload')
58
+ end
59
+
60
+ # This kind represents an inappPurchase object in the androidpublisher
61
+ # service.
62
+ # @return [String]
63
+ def kind
64
+ read('kind')
65
+ end
66
+
67
+ # The time the product was purchased, in milliseconds since the
68
+ # epoch (Jan 1, 1970)
69
+ # @return [Fixnum]
70
+ def purchase_time_millis
71
+ read_integer('purchaseTimeMillis')
72
+ end
73
+
74
+ # The date and time the product was purchased
75
+ # @return [DateTime]
76
+ def purchased_at
77
+ read_datetime_from_millis('purchaseTimeMillis')
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,46 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ # Verifies a purchase token against the Google API
4
+ # The call return either an {Receipt} or an {VerificationFailure}
5
+ class Verification
6
+ # @return [String] the package which will be queried
7
+ attr_reader :package
8
+ # @return [String] the item id which will be queried
9
+ attr_reader :product_id
10
+ # @return [String] the token for authentication
11
+ attr_reader :token
12
+
13
+ # Initializes a new call to the API
14
+ # @param client [Client] a shared client instance
15
+ # @param package [String]
16
+ # @param product_id [String]
17
+ # @param token [String]
18
+ def initialize(client, package, product_id, token)
19
+ @client = client
20
+ @package, @product_id, @token = package, product_id, token
21
+ end
22
+
23
+ # Performs the verification against the remote server
24
+ # @return [Receipt] if successful
25
+ # @return [VerificationFailure] otherwise
26
+ def call!
27
+ verify!
28
+ if valid?
29
+ Receipt.new(@response)
30
+ else
31
+ VerificationFailure.new(@response['error'])
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def valid?
38
+ @response && @response['purchaseState'] && @response['consumptionState']
39
+ end
40
+
41
+ def verify!
42
+ @response = @client.verify(package, product_id, token)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,30 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ # Represents a failing call against the Google API server
4
+ class VerificationFailure
5
+ include Utils::AttributeReader
6
+
7
+ # @return [Hash] the raw attributes returned from the server
8
+ attr_reader :attributes
9
+
10
+ # Initializes a new instance which bases on a JSON result
11
+ # from Google API servers
12
+ # @param attributes [Hash]
13
+ def initialize(attributes)
14
+ @attributes = attributes || {}
15
+ end
16
+
17
+ # The code of the failure
18
+ # @return [Fixnum]
19
+ def code
20
+ read('code') || -1
21
+ end
22
+
23
+ # The message of the failure
24
+ # @return [String]
25
+ def message
26
+ read('message') || 'Unknown error'
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,52 @@
1
+ module CandyCheck
2
+ module PlayStore
3
+ # Verifies purchase tokens against the Google API.
4
+ # The call return either an {Receipt} or a {VerificationFailure}
5
+ class Verifier
6
+ # Error thrown when the verifier isn't booted before the first
7
+ # verification check or on double invocation
8
+ class BootRequiredError < RuntimeError; end
9
+
10
+ # @return [Config] the current configuration
11
+ attr_reader :config
12
+
13
+ # Initializes a new verifier for the application which is bound
14
+ # to a configuration
15
+ # @param config [Config]
16
+ def initialize(config)
17
+ @config = config
18
+ end
19
+
20
+ # Boot the module
21
+ def boot!
22
+ boot_error('You\'re only allowed to boot the verifier once') if @client
23
+ @client = Client.new(config)
24
+ @client.boot!
25
+ end
26
+
27
+ # Contacts the Google API and requests the product state
28
+ # @param package [String] to query
29
+ # @param product_id [String] to query
30
+ # @param token [String] to use for authentication
31
+ # @return [Receipt] if successful
32
+ # @return [VerificationFailure] otherwise
33
+ def verify(package, product_id, token)
34
+ check_boot!
35
+ verification = Verification.new(@client, package, product_id, token)
36
+ verification.call!
37
+ end
38
+
39
+ private
40
+
41
+ def check_boot!
42
+ return if @client
43
+ boot_error 'You need to boot the verifier service first: '\
44
+ 'CandyCheck::PlayStore::Verifier#boot!'
45
+ end
46
+
47
+ def boot_error(message)
48
+ fail BootRequiredError, message
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,15 @@
1
+ require 'google/api_client'
2
+
3
+ require 'candy_check/play_store/discovery_repository'
4
+ require 'candy_check/play_store/client'
5
+ require 'candy_check/play_store/config'
6
+ require 'candy_check/play_store/receipt'
7
+ require 'candy_check/play_store/verification'
8
+ require 'candy_check/play_store/verification_failure'
9
+ require 'candy_check/play_store/verifier'
10
+
11
+ module CandyCheck
12
+ # Module to request and verify a AppStore receipt
13
+ module PlayStore
14
+ end
15
+ end
@@ -0,0 +1,30 @@
1
+ require 'date'
2
+
3
+ module CandyCheck
4
+ module Utils
5
+ # @private
6
+ module AttributeReader
7
+ protected
8
+
9
+ def read(field)
10
+ attributes[field]
11
+ end
12
+
13
+ def has?(field)
14
+ attributes.key?(field)
15
+ end
16
+
17
+ def read_integer(field)
18
+ (val = read(field)) && val.to_i
19
+ end
20
+
21
+ def read_datetime_from_string(field)
22
+ (val = read(field)) && DateTime.parse(val)
23
+ end
24
+
25
+ def read_datetime_from_millis(field)
26
+ (val = read_integer(field)) && Time.at(val / 1000).utc.to_datetime
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,40 @@
1
+ module CandyCheck
2
+ module Utils
3
+ # Very basic base implementation to store and validate a configuration
4
+ class Config
5
+ # Initializes a new configuration from a hash
6
+ # @param attributes [Hash]
7
+ def initialize(attributes)
8
+ attributes.each do |k, v|
9
+ instance_variable_set "@#{k}", v
10
+ end if attributes.is_a? Hash
11
+ validate!
12
+ end
13
+
14
+ protected
15
+
16
+ # Hook to check for validation error in the sub classes
17
+ # should raise an error if not passed
18
+ def validate!
19
+ # pass
20
+ end
21
+
22
+ # Check for the presence of an attribute
23
+ # @param name [String]
24
+ # @raise [ArgumentError] if attribute is missing
25
+ def validates_presence(name)
26
+ return if send(name)
27
+ fail ArgumentError, "Configuration field #{name} is missing"
28
+ end
29
+
30
+ # Checks for the inclusion of an attribute
31
+ # @param name [String]
32
+ # @param values [Array] of possible values
33
+ def validates_inclusion(name, *values)
34
+ return if values.include?(send(name))
35
+ fail ArgumentError, "Configuration field #{name} should be "\
36
+ "one of: #{values.join(', ')}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,2 @@
1
+ require 'candy_check/utils/attribute_reader'
2
+ require 'candy_check/utils/config'
@@ -0,0 +1,4 @@
1
+ module CandyCheck
2
+ # The current gem's version
3
+ VERSION = '0.0.1'
4
+ end
@@ -0,0 +1,8 @@
1
+ require 'candy_check/version'
2
+ require 'candy_check/utils'
3
+ require 'candy_check/app_store'
4
+ require 'candy_check/play_store'
5
+
6
+ # Module to check and verify in-app receipts
7
+ module CandyCheck
8
+ end
@@ -0,0 +1,55 @@
1
+ require 'spec_helper'
2
+
3
+ describe CandyCheck::AppStore::Client do
4
+ let(:endpoint_url) { 'https://some.endpoint.com/verify' }
5
+ let(:receipt_data) do
6
+ 'some_very_long_receipt_information_which_is_normaly_base64_encoded'
7
+ end
8
+ let(:password) do
9
+ 'some_secret_password'
10
+ end
11
+ let(:response) do
12
+ '{"status": 0}'
13
+ end
14
+
15
+ subject { CandyCheck::AppStore::Client.new(endpoint_url) }
16
+
17
+ describe 'valid response' do
18
+ it 'sends JSON and parses the JSON response without a secret' do
19
+ stub_endpoint
20
+ .with(
21
+ body: {
22
+ 'receipt-data' => receipt_data
23
+ }
24
+ )
25
+ .to_return(
26
+ body: response
27
+ )
28
+ result = subject.verify(receipt_data)
29
+ expected = { 'status' => 0 }
30
+ result.must_equal expected
31
+ end
32
+
33
+ it 'sends JSON and parses the JSON response with a secret' do
34
+ stub_endpoint
35
+ .with(
36
+ body: {
37
+ 'receipt-data' => receipt_data,
38
+ 'password' => password
39
+ }
40
+ )
41
+ .to_return(
42
+ body: response
43
+ )
44
+ result = subject.verify(receipt_data, password)
45
+ expected = { 'status' => 0 }
46
+ result.must_equal expected
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def stub_endpoint
53
+ stub_request(:post, endpoint_url)
54
+ end
55
+ end