-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
Showing
14 changed files
with
317 additions
and
79 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
dist | ||
*.test.ts | ||
jest.config.js |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
Oops, something went wrong.