Skip to content

Commit

Permalink
Subscription tests (#6)
Browse files Browse the repository at this point in the history
This PR adds basic tests and linter for our subscription app.
- Configured linter rule to demonstrate the usage of DBOS recommended
settings.
- Added unit tests to make sure our CORS setting is correct for our
website.
- Added a staging test to make sure we can correctly upgrade/downgrade
users when they subscribe/unsubscribe from Stripe. Whenever we
add/cancel subscriptions, Stripe sends events to our webhook hosted on
staging cluster, the webhook in turn talks to staging to enforce
entitlements.
- Created new Github workflows for both tests. Staging test runs every 6
hours.
- Currently, we don't have a good solution to tell if a user is pro or
free tier. I use "link db" to differentiate different tiers.

Next step:
- Add a user info endpoint in DBOS Cloud to return user subscription
status.
- Use refresh token instead of our current login flow.
  • Loading branch information
qianl15 authored Apr 22, 2024
1 parent e093232 commit 79505b4
Show file tree
Hide file tree
Showing 14 changed files with 317 additions and 79 deletions.
3 changes: 3 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist
*.test.ts
jest.config.js
22 changes: 22 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"root": true,
"extends": [
"plugin:@dbos-inc/dbosRecommendedConfig"
],
"plugins": [
"@dbos-inc"
],
"env": {
"node": true,
"es6": true
},
"rules": {
"@typescript-eslint/unbound-method": ["error", {
"ignoreStatic": true
}],
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
}
}
1 change: 1 addition & 0 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ jobs:
STRIPE_SECRET_KEY: ${{secrets.STRIPE_PROD_SECRET_KEY}}
STRIPE_WEBHOOK_SECRET: ${{secrets.STRIPE_PROD_WEBHOOK_SECRET}}
STRIPE_DBOS_PRO_PRICE: ${{secrets.STRIPE_PROD_PRO_PRICE}}
DBOS_TEST_PASSWORD: ${{secrets.DBOS_TEST_PASSWORD}}
3 changes: 3 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
name: Deploy Subscription App to Staging

on:
push:
branches: [ "main" ]
workflow_dispatch:

jobs:
Expand All @@ -26,3 +28,4 @@ jobs:
STRIPE_SECRET_KEY: ${{secrets.STRIPE_TEST_SECRET_KEY}}
STRIPE_WEBHOOK_SECRET: ${{secrets.STRIPE_TEST_WEBHOOK_SECRET}}
STRIPE_DBOS_PRO_PRICE: ${{secrets.STRIPE_TEST_PRO_PRICE}}
DBOS_TEST_PASSWORD: ${{secrets.DBOS_TEST_PASSWORD}}
31 changes: 31 additions & 0 deletions .github/workflows/test-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Test Subscription App on Staging

on:
schedule:
# Runs every six hours
- cron: '0 */6 * * *'
push:
branches: [ "main" ]
workflow_dispatch:

jobs:
test:
runs-on: self-hosted

steps:
- uses: actions/checkout@v4

- name: Install dependencies
run: |
npm install @dbos-inc/dbos-cloud@preview
- name: Test on staging
run: python3 scripts/staging_test.py
env:
DBOS_DOMAIN: staging.dev.dbos.dev
DBOS_AUTH0_CLIENT_SECRET: ${{secrets.DBOS_AUTH0_TEST_CLIENT_SECRET}}
DBOS_DEPLOY_PASSWORD: ${{secrets.DBOS_CLOUD_SUBSCRIPTION_PASSWORD}}
DBOS_APP_DB_NAME: testsubscription
STRIPE_SECRET_KEY: ${{secrets.STRIPE_TEST_SECRET_KEY}}
STRIPE_WEBHOOK_SECRET: ${{secrets.STRIPE_TEST_WEBHOOK_SECRET}}
STRIPE_DBOS_PRO_PRICE: ${{secrets.STRIPE_TEST_PRO_PRICE}}
DBOS_TEST_PASSWORD: ${{secrets.DBOS_TEST_PASSWORD}}
54 changes: 54 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Run unit tests

on:
push:
branches: [ "main" ]
pull_request:
types:
- ready_for_review
- opened
- reopened
- synchronize
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest

# Service container for Postgres
services:
# Label used to access the service container.
postgres:
image: postgres:16
env:
# Specify the password for Postgres superuser.
POSTGRES_PASSWORD: dbos
# Set health checks to wait until postgres has started
options: >-
--name postgres
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps tcp port 5432 on service container to the host
- 5432:5432

steps:
- name: Checkout app
uses: actions/checkout@v4
with:
path: cloud-subscription
- name: Use Node.js 20
uses: actions/setup-node@v3
with:
node-version: 20
- name: Compile and test
working-directory: cloud-subscription
run: |
npm ci
npm run build
npm run lint
npm run test
env:
PGPASSWORD: dbos
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.1",
"scripts": {
"build": "tsc",
"test": "npx knex migrate:rollback && npx knex migrate:up && jest",
"test": "npx dbos migrate && jest --detectOpenHandles",
"lint": "eslint src",
"lint-fix": "eslint --fix src"
},
Expand Down
31 changes: 31 additions & 0 deletions scripts/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os

class Config:
def __init__(self):
self.dbos_domain = os.getenv('DBOS_DOMAIN', 'staging.dev.dbos.dev')
if 'DBOS_DOMAIN' not in os.environ:
os.environ['DBOS_DOMAIN'] = self.dbos_domain
self.test_username = "testsubscription"
self.test_email = "[email protected]"
self.deploy_username = "subscribe"
self.deploy_email = "[email protected]"
self.client_secret = os.environ['DBOS_AUTH0_CLIENT_SECRET']
if not self.client_secret:
raise Exception('DBOS_AUTH0_CLIENT_SECRET not set')
self.dbos_test_password = os.environ['DBOS_TEST_PASSWORD']
if not self.dbos_test_password:
raise Exception('DBOS_TEST_PASSWORD not set')
self.deploy_password = os.environ['DBOS_DEPLOY_PASSWORD']
if not self.deploy_password:
raise Exception('DBOS_DEPLOY_PASSWORD not set')
self.db_name = os.environ['DBOS_APP_DB_NAME']
if not self.db_name:
raise Exception('DBOS_APP_DB_NAME not set')
self.stripe_pro_price = os.environ['STRIPE_DBOS_PRO_PRICE']
if not self.stripe_pro_price:
raise Exception('STRIPE_DBOS_PRO_PRICE not set')
self.stripe_secret_key = os.environ['STRIPE_SECRET_KEY']
if not self.stripe_secret_key:
raise Exception('STRIPE_SECRET_KEY not set')

config = Config()
78 changes: 5 additions & 73 deletions scripts/dbos_deploy.py
Original file line number Diff line number Diff line change
@@ -1,89 +1,21 @@
# This script is used to automatically deploy this subscription app to DBOS Cloud
import subprocess
import string
import zipfile
import requests
import random
import os
import json
import stat
import shutil
from utils import (login, run_subprocess)
from config import config

script_dir = os.path.dirname(os.path.abspath(__file__))
app_dir = os.path.join(script_dir, "..")

DBOS_DOMAIN = os.environ.get('DBOS_DOMAIN')
if not DBOS_DOMAIN:
raise Exception("DBOS_DOMAIN not set")

CLIENT_SECRET = os.environ['DBOS_AUTH0_CLIENT_SECRET']
if not CLIENT_SECRET:
raise Exception("DBOS_AUTH0_CLIENT_SECRET not set")

DEPLOY_PASSWORD = os.environ['DBOS_DEPLOY_PASSWORD']
if not DEPLOY_PASSWORD:
raise Exception("DBOS_DEPLOY_PASSWORD not set")

DEPLOY_USERNAME = 'subscribe'
DB_NAME = os.environ['DBOS_APP_DB_NAME']
if not DB_NAME:
raise Exception("DBOS_APP_DB_NAME not set")

def login(path: str):
# Perform an automated login using the Resource Owner Password Flow
# https://auth0.com/docs/get-started/authentication-and-authorization-flow/resource-owner-password-flow
auth0_domain = 'login.dbos.dev' if DBOS_DOMAIN == 'cloud.dbos.dev' else 'dbos-inc.us.auth0.com'
username = '[email protected]'
password = DEPLOY_PASSWORD
audience = 'dbos-cloud-api'
client_id = 'LJlSE9iqRBPzeonar3LdEad7zdYpwKsW' if DBOS_DOMAIN == 'cloud.dbos.dev' else 'XPMrfZwUL6O8VZjc4XQWEUHORbT1ykUm'
client_secret = CLIENT_SECRET

url = f'https://{auth0_domain}/oauth/token'

data = {
'grant_type': 'password',
'username': username,
'password': password,
'audience': audience,
'scope': 'read:sample',
'client_id': client_id,
'client_secret': client_secret
}

headers = {
'content-type': 'application/x-www-form-urlencoded'
}

response = requests.post(url, headers=headers, data=data)
access_token = response.json().get('access_token', '')
os.makedirs(os.path.join(path, '.dbos'), exist_ok=True)
with open(os.path.join(path, '.dbos', 'credentials'), 'w') as file:
json.dump({'userName': DEPLOY_USERNAME, 'token': access_token}, file)

def deploy(path: str):
output = run_subprocess(['npx', 'dbos-cloud', 'database', 'status', DB_NAME], path, check=False)
output = run_subprocess(['npx', 'dbos-cloud', 'database', 'status', config.db_name], path, check=False)
if "error" in output:
raise Exception(f"Database {DB_NAME} errored!")
raise Exception(f"Database {config.db_name} errored!")

# run_subprocess(['npx', 'dbos-cloud', 'applications', 'register', '--database', DB_NAME], path, check=False)
run_subprocess(['npx', 'dbos-cloud', 'applications', 'deploy'], path)

def run_subprocess(command, path: str, check: bool = True, silent: bool = False):
process = subprocess.Popen(command, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output = ""
for line in iter(process.stdout.readline, ''):
if not silent:
print(line, end='')
output += line
process.wait()
process.stdout.close()
if check and process.returncode != 0:
raise Exception(f"Command {command} failed with return code {process.returncode}. Output {output}")
return output

if __name__ == "__main__":
login(app_dir)
login(app_dir, is_deploy=True)
print("Successfully login to DBOS Cloud, deploying app...")

deploy(app_dir)
Expand Down
59 changes: 59 additions & 0 deletions scripts/staging_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# This script is used to automatically deploy this subscription app to DBOS Cloud
import os
import time
from utils import (login, run_subprocess)
from config import config
import stripe

script_dir = os.path.dirname(os.path.abspath(__file__))
app_dir = os.path.join(script_dir, "..")
stripe.api_key = config.stripe_secret_key

def test_endpoints(path: str):
print("Testing endpoints on DBOS Cloud...")
# Register on DBOS Cloud
run_subprocess(['npx', 'dbos-cloud', 'register', '-u', config.test_username], path, check=False)
login(path, is_deploy=False) # Login again because register command logs out

# Test database linking, should fail because we're free user
run_subprocess(['npx', 'dbos-cloud', 'db', 'list'], path, check=True)
output = run_subprocess(['npx', 'dbos-cloud', 'db', 'link', 'testlinkdb', '-H', 'localhost', '-W', 'fakepassword'], path, check=False)
# TODO: add a real user subscription status check.
if not "database linking is only available for paying users" in output:
raise Exception("Free tier check failed")

# Look up customer ID
customers = stripe.Customer.list(email=config.test_email, limit=1)
if len(customers) == 0:
raise Exception("No Stripe customer found for test email")
customer_id = customers.data[0].id

# Create a subscription that uses the default test payment
subscription = stripe.Subscription.create(
customer=customer_id,
items=[{"price": config.stripe_pro_price}],
)

# Now test linking a database should fail with a different message
# TODO: better check
time.sleep(5) # Wait for subscription to take effect
output = run_subprocess(['npx', 'dbos-cloud', 'db', 'link', 'testlinkdb', '-H', 'localhost', '-W', 'fakepassword'], path, check=False)
if not "failed to connect to linked database" in output:
raise Exception("Pro tier check failed")

# Cancel the subscription and check we're free tier again
stripe.Subscription.cancel(subscription.id)
time.sleep(5) # Wait for subscription to take effect
# Test database linking, should fail because we're free user
run_subprocess(['npx', 'dbos-cloud', 'db', 'list'], path, check=True)
output = run_subprocess(['npx', 'dbos-cloud', 'db', 'link', 'testlinkdb', '-H', 'localhost', '-W', 'fakepassword'], path, check=False)
# TODO: add a real user subscription status check.
if not "database linking is only available for paying users" in output:
raise Exception("Free tier check failed")

if __name__ == "__main__":
login(app_dir, is_deploy=False)
print("Successfully login to DBOS Cloud")

test_endpoints(app_dir)
print("Successfully tested endpoints on DBOS Cloud")
53 changes: 53 additions & 0 deletions scripts/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import json
import os
import subprocess

import requests

from config import config

def run_subprocess(command, path: str, check: bool = True, silent: bool = False):
process = subprocess.Popen(command, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output = ""
for line in iter(process.stdout.readline, ''):
if not silent:
print(line, end='')
output += line
process.wait()
process.stdout.close()
if check and process.returncode != 0:
raise Exception(f"Command {command} failed with return code {process.returncode}. Output {output}")
return output

def login(path: str, is_deploy: bool = False):
# Perform an automated login using the Resource Owner Password Flow
# https://auth0.com/docs/get-started/authentication-and-authorization-flow/resource-owner-password-flow
auth0_domain = 'login.dbos.dev' if config.dbos_domain == 'cloud.dbos.dev' else 'dbos-inc.us.auth0.com'
username = config.deploy_email if is_deploy else config.test_email
password = config.deploy_password if is_deploy else config.dbos_test_password
audience = 'dbos-cloud-api'
client_id = 'LJlSE9iqRBPzeonar3LdEad7zdYpwKsW' if config.dbos_domain == 'cloud.dbos.dev' else 'XPMrfZwUL6O8VZjc4XQWEUHORbT1ykUm'
client_secret = config.client_secret

url = f'https://{auth0_domain}/oauth/token'

data = {
'grant_type': 'password',
'username': username,
'password': password,
'audience': audience,
'scope': 'read:sample',
'client_id': client_id,
'client_secret': client_secret
}

headers = {
'content-type': 'application/x-www-form-urlencoded'
}

response = requests.post(url, headers=headers, data=data)
access_token = response.json().get('access_token', '')
dbos_username = config.deploy_username if is_deploy else config.test_username
os.makedirs(os.path.join(path, '.dbos'), exist_ok=True)
with open(os.path.join(path, '.dbos', 'credentials'), 'w') as file:
json.dump({'userName': dbos_username, 'token': access_token}, file)
Loading

0 comments on commit 79505b4

Please sign in to comment.