Merge branch 'create-reset-sip-account'

Stephen Paul Weber created

* create-reset-sip-account:
  Create or reset SIP account
  Factor out Catapult connection

Change summary

Gemfile                             |   1 
config.dhall.sample                 |   4 
lib/bandwidth_tn_order.rb           |  31 --
lib/catapult.rb                     |  97 ++++++++++
lib/customer.rb                     |  11 +
lib/mn_words.rb                     | 277 +++++++++++++++++++++++++++++++
lib/sip_account.rb                  | 101 +++++++++++
sgx_jmp.rb                          |  20 ++
test/data/catapult_create_sip.json  |   1 
test/data/catapult_import_body.json |   2 
test/test_customer.rb               |  77 ++++++++
test/test_helper.rb                 |   2 
test/test_sip_account.rb            | 121 +++++++++++++
13 files changed, 717 insertions(+), 28 deletions(-)

Detailed changes

Gemfile 🔗

@@ -14,6 +14,7 @@ gem "eventmachine"
 gem "money-open-exchange-rates"
 gem "ruby-bandwidth-iris"
 gem "sentry-ruby"
+gem "value_semantics", git: "https://github.com/singpolyma/value_semantics"
 
 group(:development) do
 	gem "pry-reload"

config.dhall.sample 🔗

@@ -17,7 +17,9 @@
 		user = "",
 		token = "",
 		secret = "",
-		application_id = ""
+		application_id = "",
+		domain = "",
+		sip_host = ""
 	},
 	web_register = {
 		to = "cheogram",

lib/bandwidth_tn_order.rb 🔗

@@ -4,6 +4,8 @@ require "forwardable"
 require "ruby-bandwidth-iris"
 Faraday.default_adapter = :em_synchrony
 
+require_relative "./catapult"
+
 class BandwidthTNOrder
 	def self.get(id)
 		EM.promise_fiber do
@@ -86,35 +88,12 @@ class BandwidthTNOrder
 
 		# After buying, import to catapult and set v1 voice app
 		def catapult_import
-			catapult_request.apost(
-				head: catapult_headers,
-				body: {
-					number: tel,
-					applicationId: catapult[:application_id],
-					provider: dashboard_provider
-				}.to_json
-			)
-		end
-
-		def catapult
-			CONFIG[:catapult]
-		end
-
-		def catapult_request
-			EM::HttpRequest.new(
-				"https://api.catapult.inetwork.com/v1/users/" \
-				"#{catapult[:user]}/phoneNumbers",
-				tls: { verify_peer: true }
+			CATAPULT.import(
+				number: tel,
+				provider: dashboard_provider
 			)
 		end
 
-		def catapult_headers
-			{
-				"Authorization" => [catapult[:token], catapult[:secret]],
-				"Content-Type" => "application/json"
-			}
-		end
-
 		def dashboard_provider
 			{
 				providerName: "bandwidth-dashboard",

lib/catapult.rb 🔗

@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+
+class Catapult
+	value_semantics do
+		user String
+		token String
+		secret String
+		application_id String
+		domain String
+		sip_host String
+	end
+
+	def import(body)
+		post(
+			"phoneNumbers",
+			body: { applicationId: application_id }.merge(body)
+		)
+	end
+
+	def create_endpoint(body)
+		post(
+			"domains/#{@domain}/endpoints",
+			body: { applicationId: @application_id }.merge(body)
+		).then do |http|
+			unless http.response_header.status == 201
+				raise "Create new SIP account failed"
+			end
+			http.response_header["location"]
+		end
+	end
+
+	def endpoint_list(page=0)
+		get(
+			"domains/#{@domain}/endpoints",
+			query: { size: 1000, page: page }
+		).then do |http|
+			next [] if http.response_header.status == 404
+			raise "Could not list endpoints" if http.response_header.status != 200
+
+			JSON.parse(http.response)
+		end
+	end
+
+	def endpoint_find(name, page=0)
+		endpoint_list(page).then do |list|
+			next if list.empty?
+
+			if (found = list.find { |e| e["name"] == name })
+				found.merge("url" => CATAPULT.mkurl(
+					"domains/#{found['domainId']}/endpoints/#{found['id']}"
+				))
+			else
+				endpoint_find(name, page + 1)
+			end
+		end
+	end
+
+	def post(path, body:, head: {})
+		EM::HttpRequest.new(
+			mkurl(path), tls: { verify_peer: true }
+		).apost(
+			head: catapult_headers.merge(head),
+			body: body.to_json
+		)
+	end
+
+	def delete(path, head: {})
+		EM::HttpRequest.new(
+			mkurl(path), tls: { verify_peer: true }
+		).adelete(head: catapult_headers.merge(head))
+	end
+
+	def get(path, head: {}, **kwargs)
+		EM::HttpRequest.new(
+			mkurl(path), tls: { verify_peer: true }
+		).aget(head: catapult_headers.merge(head), **kwargs)
+	end
+
+	def mkurl(path)
+		base = "https://api.catapult.inetwork.com/v1/users/#{@user}/"
+		return path if path.start_with?(base)
+		"#{base}#{path}"
+	end
+
+protected
+
+	def catapult_headers
+		{
+			"Authorization" => [@token, @secret],
+			"Content-Type" => "application/json"
+		}
+	end
+end
+
+CATAPULT = Catapult.new(**CONFIG[:catapult])

lib/customer.rb 🔗

@@ -9,6 +9,7 @@ require_relative "./backend_sgx"
 require_relative "./ibr"
 require_relative "./payment_methods"
 require_relative "./plan"
+require_relative "./sip_account"
 
 class Customer
 	def self.for_jid(jid)
@@ -103,5 +104,15 @@ class Customer
 		BLATHER << @sgx.stanza(stanza)
 	end
 
+	def sip_account
+		SipAccount.find(customer_id)
+	end
+
+	def reset_sip_account
+		SipAccount::New.new(username: customer_id).put.catch do
+			sip_account.then { |acct| acct.with_random_password.put }
+		end
+	end
+
 	protected def_delegator :@plan, :expires_at
 end

lib/mn_words.rb 🔗

@@ -0,0 +1,277 @@
+# frozen_string_literal: true
+
+MN_WORDS = [
+	"academy",  "acrobat",  "active",   "actor",    "adam",     "admiral",
+	"adrian",   "africa",   "agenda",   "agent",    "airline",  "airport",
+	"aladdin",  "alarm",    "alaska",   "albert",   "albino",   "album",
+	"alcohol",  "alex",     "algebra",  "alibi",    "alice",    "alien",
+	"alpha",    "alpine",   "amadeus",  "amanda",   "amazon",   "amber",
+	"america",  "amigo",    "analog",   "anatomy",  "angel",    "animal",
+	"antenna",  "antonio",  "apollo",   "april",    "archive",  "arctic",
+	"arizona",  "arnold",   "aroma",    "arthur",   "artist",   "asia",
+	"aspect",   "aspirin",  "athena",   "athlete",  "atlas",    "audio",
+	"august",   "austria",  "axiom",    "aztec",    "balance",  "ballad",
+	"banana",   "bandit",   "banjo",    "barcode",  "baron",    "basic",
+	"battery",  "belgium",  "berlin",   "bermuda",  "bernard",  "bikini",
+	"binary",   "bingo",    "biology",  "block",    "blonde",   "bonus",
+	"boris",    "boston",   "boxer",    "brandy",   "bravo",    "brazil",
+	"bronze",   "brown",    "bruce",    "bruno",    "burger",   "burma",
+	"cabinet",  "cactus",   "cafe",     "cairo",    "cake",     "calypso",
+	"camel",    "camera",   "campus",   "canada",   "canal",    "cannon",
+	"canoe",    "cantina",  "canvas",   "canyon",   "capital",  "caramel",
+	"caravan",  "carbon",   "cargo",    "carlo",    "carol",    "carpet",
+	"cartel",   "casino",   "castle",   "castro",   "catalog",  "caviar",
+	"cecilia",  "cement",   "center",   "century",  "ceramic",  "chamber",
+	"chance",   "change",   "chaos",    "charlie",  "charm",    "charter",
+	"chef",     "chemist",  "cherry",   "chess",    "chicago",  "chicken",
+	"chief",    "china",    "cigar",    "cinema",   "circus",   "citizen",
+	"city",     "clara",    "classic",  "claudia",  "clean",    "client",
+	"climax",   "clinic",   "clock",    "club",     "cobra",    "coconut",
+	"cola",     "collect",  "colombo",  "colony",   "color",    "combat",
+	"comedy",   "comet",    "command",  "compact",  "company",  "complex",
+	"concept",  "concert",  "connect",  "consul",   "contact",  "context",
+	"contour",  "control",  "convert",  "copy",     "corner",   "corona",
+	"correct",  "cosmos",   "couple",   "courage",  "cowboy",   "craft",
+	"crash",    "credit",   "cricket",  "critic",   "crown",    "crystal",
+	"cuba",     "culture",  "dallas",   "dance",    "daniel",   "david",
+	"decade",   "decimal",  "deliver",  "delta",    "deluxe",   "demand",
+	"demo",     "denmark",  "derby",    "design",   "detect",   "develop",
+	"diagram",  "dialog",   "diamond",  "diana",    "diego",    "diesel",
+	"diet",     "digital",  "dilemma",  "diploma",  "direct",   "disco",
+	"disney",   "distant",  "doctor",   "dollar",   "dominic",  "domino",
+	"donald",   "dragon",   "drama",    "dublin",   "duet",     "dynamic",
+	"east",     "ecology",  "economy",  "edgar",    "egypt",    "elastic",
+	"elegant",  "element",  "elite",    "elvis",    "email",    "energy",
+	"engine",   "english",  "episode",  "equator",  "escort",   "ethnic",
+	"europe",   "everest",  "evident",  "exact",    "example",  "exit",
+	"exotic",   "export",   "express",  "extra",    "fabric",   "factor",
+	"falcon",   "family",   "fantasy",  "fashion",  "fiber",    "fiction",
+	"fidel",    "fiesta",   "figure",   "film",     "filter",   "final",
+	"finance",  "finish",   "finland",  "flash",    "florida",  "flower",
+	"fluid",    "flute",    "focus",    "ford",     "forest",   "formal",
+	"format",   "formula",  "fortune",  "forum",    "fragile",  "france",
+	"frank",    "friend",   "frozen",   "future",   "gabriel",  "galaxy",
+	"gallery",  "gamma",    "garage",   "garden",   "garlic",   "gemini",
+	"general",  "genetic",  "genius",   "germany",  "global",   "gloria",
+	"golf",     "gondola",  "gong",     "good",     "gordon",   "gorilla",
+	"grand",    "granite",  "graph",    "green",    "group",    "guide",
+	"guitar",   "guru",     "hand",     "happy",    "harbor",   "harmony",
+	"harvard",  "havana",   "hawaii",   "helena",   "hello",    "henry",
+	"hilton",   "history",  "horizon",  "hotel",    "human",    "humor",
+	"icon",     "idea",     "igloo",    "igor",     "image",    "impact",
+	"import",   "index",    "india",    "indigo",   "input",    "insect",
+	"instant",  "iris",     "italian",  "jacket",   "jacob",    "jaguar",
+	"janet",    "japan",    "jargon",   "jazz",     "jeep",     "john",
+	"joker",    "jordan",   "jumbo",    "june",     "jungle",   "junior",
+	"jupiter",  "karate",   "karma",    "kayak",    "kermit",   "kilo",
+	"king",     "koala",    "korea",    "labor",    "lady",     "lagoon",
+	"laptop",   "laser",    "latin",    "lava",     "lecture",  "left",
+	"legal",    "lemon",    "level",    "lexicon",  "liberal",  "libra",
+	"limbo",    "limit",    "linda",    "linear",   "lion",     "liquid",
+	"liter",    "little",   "llama",    "lobby",    "lobster",  "local",
+	"logic",    "logo",     "lola",     "london",   "lotus",    "lucas",
+	"lunar",    "machine",  "macro",    "madam",    "madonna",  "madrid",
+	"maestro",  "magic",    "magnet",   "magnum",   "major",    "mama",
+	"mambo",    "manager",  "mango",    "manila",   "marco",    "marina",
+	"market",   "mars",     "martin",   "marvin",   "master",   "matrix",
+	"maximum",  "media",    "medical",  "mega",     "melody",   "melon",
+	"memo",     "mental",   "mentor",   "menu",     "mercury",  "message",
+	"metal",    "meteor",   "meter",    "method",   "metro",    "mexico",
+	"miami",    "micro",    "million",  "mineral",  "minimum",  "minus",
+	"minute",   "miracle",  "mirage",   "miranda",  "mister",   "mixer",
+	"mobile",   "model",    "modem",    "modern",   "modular",  "moment",
+	"monaco",   "monica",   "monitor",  "mono",     "monster",  "montana",
+	"morgan",   "motel",    "motif",    "motor",    "mozart",   "multi",
+	"museum",   "music",    "mustang",  "natural",  "neon",     "nepal",
+	"neptune",  "nerve",    "neutral",  "nevada",   "news",     "ninja",
+	"nirvana",  "normal",   "nova",     "novel",    "nuclear",  "numeric",
+	"nylon",    "oasis",    "object",   "observe",  "ocean",    "octopus",
+	"olivia",   "olympic",  "omega",    "opera",    "optic",    "optimal",
+	"orange",   "orbit",    "organic",  "orient",   "origin",   "orlando",
+	"oscar",    "oxford",   "oxygen",   "ozone",    "pablo",    "pacific",
+	"pagoda",   "palace",   "pamela",   "panama",   "panda",    "panel",
+	"panic",    "paradox",  "pardon",   "paris",    "parker",   "parking",
+	"parody",   "partner",  "passage",  "passive",  "pasta",    "pastel",
+	"patent",   "patriot",  "patrol",   "patron",   "pegasus",  "pelican",
+	"penguin",  "pepper",   "percent",  "perfect",  "perfume",  "period",
+	"permit",   "person",   "peru",     "phone",    "photo",    "piano",
+	"picasso",  "picnic",   "picture",  "pigment",  "pilgrim",  "pilot",
+	"pirate",   "pixel",    "pizza",    "planet",   "plasma",   "plaster",
+	"plastic",  "plaza",    "pocket",   "poem",     "poetic",   "poker",
+	"polaris",  "police",   "politic",  "polo",     "polygon",  "pony",
+	"popcorn",  "popular",  "postage",  "postal",   "precise",  "prefix",
+	"premium",  "present",  "price",    "prince",   "printer",  "prism",
+	"private",  "product",  "profile",  "program",  "project",  "protect",
+	"proton",   "public",   "pulse",    "puma",     "pyramid",  "queen",
+	"radar",    "radio",    "random",   "rapid",    "rebel",    "record",
+	"recycle",  "reflex",   "reform",   "regard",   "regular",  "relax",
+	"report",   "reptile",  "reverse",  "ricardo",  "ringo",    "ritual",
+	"robert",   "robot",    "rocket",   "rodeo",    "romeo",    "royal",
+	"russian",  "safari",   "salad",    "salami",   "salmon",   "salon",
+	"salute",   "samba",    "sandra",   "santana",  "sardine",  "school",
+	"screen",   "script",   "second",   "secret",   "section",  "segment",
+	"select",   "seminar",  "senator",  "senior",   "sensor",   "serial",
+	"service",  "sheriff",  "shock",    "sierra",   "signal",   "silicon",
+	"silver",   "similar",  "simon",    "single",   "siren",    "slogan",
+	"social",   "soda",     "solar",    "solid",    "solo",     "sonic",
+	"soviet",   "special",  "speed",    "spiral",   "spirit",   "sport",
+	"static",   "station",  "status",   "stereo",   "stone",    "stop",
+	"street",   "strong",   "student",  "studio",   "style",    "subject",
+	"sultan",   "super",    "susan",    "sushi",    "suzuki",   "switch",
+	"symbol",   "system",   "tactic",   "tahiti",   "talent",   "tango",
+	"tarzan",   "taxi",     "telex",    "tempo",    "tennis",   "texas",
+	"textile",  "theory",   "thermos",  "tiger",    "titanic",  "tokyo",
+	"tomato",   "topic",    "tornado",  "toronto",  "torpedo",  "total",
+	"totem",    "tourist",  "tractor",  "traffic",  "transit",  "trapeze",
+	"travel",   "tribal",   "trick",    "trident",  "trilogy",  "tripod",
+	"tropic",   "trumpet",  "tulip",    "tuna",     "turbo",    "twist",
+	"ultra",    "uniform",  "union",    "uranium",  "vacuum",   "valid",
+	"vampire",  "vanilla",  "vatican",  "velvet",   "ventura",  "venus",
+	"vertigo",  "veteran",  "victor",   "video",    "vienna",   "viking",
+	"village",  "vincent",  "violet",   "violin",   "virtual",  "virus",
+	"visa",     "vision",   "visitor",  "visual",   "vitamin",  "viva",
+	"vocal",    "vodka",    "volcano",  "voltage",  "volume",   "voyage",
+	"water",    "weekend",  "welcome",  "western",  "window",   "winter",
+	"wizard",   "wolf",     "world",    "xray",     "yankee",   "yoga",
+	"yogurt",   "yoyo",     "zebra",    "zero",     "zigzag",   "zipper",
+	"zodiac",   "zoom",     "abraham",  "action",   "address",  "alabama",
+	"alfred",   "almond",   "ammonia",  "analyze",  "annual",   "answer",
+	"apple",    "arena",    "armada",   "arsenal",  "atlanta",  "atomic",
+	"avenue",   "average",  "bagel",    "baker",    "ballet",   "bambino",
+	"bamboo",   "barbara",  "basket",   "bazaar",   "benefit",  "bicycle",
+	"bishop",   "blitz",    "bonjour",  "bottle",   "bridge",   "british",
+	"brother",  "brush",    "budget",   "cabaret",  "cadet",    "candle",
+	"capitan",  "capsule",  "career",   "cartoon",  "channel",  "chapter",
+	"cheese",   "circle",   "cobalt",   "cockpit",  "college",  "compass",
+	"comrade",  "condor",   "crimson",  "cyclone",  "darwin",   "declare",
+	"degree",   "delete",   "delphi",   "denver",   "desert",   "divide",
+	"dolby",    "domain",   "domingo",  "double",   "drink",    "driver",
+	"eagle",    "earth",    "echo",     "eclipse",  "editor",   "educate",
+	"edward",   "effect",   "electra",  "emerald",  "emotion",  "empire",
+	"empty",    "escape",   "eternal",  "evening",  "exhibit",  "expand",
+	"explore",  "extreme",  "ferrari",  "first",    "flag",     "folio",
+	"forget",   "forward",  "freedom",  "fresh",    "friday",   "fuji",
+	"galileo",  "garcia",   "genesis",  "gold",     "gravity",  "habitat",
+	"hamlet",   "harlem",   "helium",   "holiday",  "house",    "hunter",
+	"ibiza",    "iceberg",  "imagine",  "infant",   "isotope",  "jackson",
+	"jamaica",  "jasmine",  "java",     "jessica",  "judo",     "kitchen",
+	"lazarus",  "letter",   "license",  "lithium",  "loyal",    "lucky",
+	"magenta",  "mailbox",  "manual",   "marble",   "mary",     "maxwell",
+	"mayor",    "milk",     "monarch",  "monday",   "money",    "morning",
+	"mother",   "mystery",  "native",   "nectar",   "nelson",   "network",
+	"next",     "nikita",   "nobel",    "nobody",   "nominal",  "norway",
+	"nothing",  "number",   "october",  "office",   "oliver",   "opinion",
+	"option",   "order",    "outside",  "package",  "pancake",  "pandora",
+	"panther",  "papa",     "patient",  "pattern",  "pedro",    "pencil",
+	"people",   "phantom",  "philips",  "pioneer",  "pluto",    "podium",
+	"portal",   "potato",   "prize",    "process",  "protein",  "proxy",
+	"pump",     "pupil",    "python",   "quality",  "quarter",  "quiet",
+	"rabbit",   "radical",  "radius",   "rainbow",  "ralph",    "ramirez",
+	"ravioli",  "raymond",  "respect",  "respond",  "result",   "resume",
+	"retro",    "richard",  "right",    "risk",     "river",    "roger",
+	"roman",    "rondo",    "sabrina",  "salary",   "salsa",    "sample",
+	"samuel",   "saturn",   "savage",   "scarlet",  "scoop",    "scorpio",
+	"scratch",  "scroll",   "sector",   "serpent",  "shadow",   "shampoo",
+	"sharon",   "sharp",    "short",    "shrink",   "silence",  "silk",
+	"simple",   "slang",    "smart",    "smoke",    "snake",    "society",
+	"sonar",    "sonata",   "soprano",  "source",   "sparta",   "sphere",
+	"spider",   "sponsor",  "spring",   "acid",     "adios",    "agatha",
+	"alamo",    "alert",    "almanac",  "aloha",    "andrea",   "anita",
+	"arcade",   "aurora",   "avalon",   "baby",     "baggage",  "balloon",
+	"bank",     "basil",    "begin",    "biscuit",  "blue",     "bombay",
+	"brain",    "brenda",   "brigade",  "cable",    "carmen",   "cello",
+	"celtic",   "chariot",  "chrome",   "citrus",   "civil",    "cloud",
+	"common",   "compare",  "cool",     "copper",   "coral",    "crater",
+	"cubic",    "cupid",    "cycle",    "depend",   "door",     "dream",
+	"dynasty",  "edison",   "edition",  "enigma",   "equal",    "eric",
+	"event",    "evita",    "exodus",   "extend",   "famous",   "farmer",
+	"food",     "fossil",   "frog",     "fruit",    "geneva",   "gentle",
+	"george",   "giant",    "gilbert",  "gossip",   "gram",     "greek",
+	"grille",   "hammer",   "harvest",  "hazard",   "heaven",   "herbert",
+	"heroic",   "hexagon",  "husband",  "immune",   "inca",     "inch",
+	"initial",  "isabel",   "ivory",    "jason",    "jerome",   "joel",
+	"joshua",   "journal",  "judge",    "juliet",   "jump",     "justice",
+	"kimono",   "kinetic",  "leonid",   "lima",     "maze",     "medusa",
+	"member",   "memphis",  "michael",  "miguel",   "milan",    "mile",
+	"miller",   "mimic",    "mimosa",   "mission",  "monkey",   "moral",
+	"moses",    "mouse",    "nancy",    "natasha",  "nebula",   "nickel",
+	"nina",     "noise",    "orchid",   "oregano",  "origami",  "orinoco",
+	"orion",    "othello",  "paper",    "paprika",  "prelude",  "prepare",
+	"pretend",  "profit",   "promise",  "provide",  "puzzle",   "remote",
+	"repair",   "reply",    "rival",    "riviera",  "robin",    "rose",
+	"rover",    "rudolf",   "saga",     "sahara",   "scholar",  "shelter",
+	"ship",     "shoe",     "sigma",    "sister",   "sleep",    "smile",
+	"spain",    "spark",    "split",    "spray",    "square",   "stadium",
+	"star",     "storm",    "story",    "strange",  "stretch",  "stuart",
+	"subway",   "sugar",    "sulfur",   "summer",   "survive",  "sweet",
+	"swim",     "table",    "taboo",    "target",   "teacher",  "telecom",
+	"temple",   "tibet",    "ticket",   "tina",     "today",    "toga",
+	"tommy",    "tower",    "trivial",  "tunnel",   "turtle",   "twin",
+	"uncle",    "unicorn",  "unique",   "update",   "valery",   "vega",
+	"version",  "voodoo",   "warning",  "william",  "wonder",   "year",
+	"yellow",   "young",    "absent",   "absorb",   "accent",   "alfonso",
+	"alias",    "ambient",  "andy",     "anvil",    "appear",   "apropos",
+	"archer",   "ariel",    "armor",    "arrow",    "austin",   "avatar",
+	"axis",     "baboon",   "bahama",   "bali",     "balsa",    "bazooka",
+	"beach",    "beast",    "beatles",  "beauty",   "before",   "benny",
+	"betty",    "between",  "beyond",   "billy",    "bison",    "blast",
+	"bless",    "bogart",   "bonanza",  "book",     "border",   "brave",
+	"bread",    "break",    "broken",   "bucket",   "buenos",   "buffalo",
+	"bundle",   "button",   "buzzer",   "byte",     "caesar",   "camilla",
+	"canary",   "candid",   "carrot",   "cave",     "chant",    "child",
+	"choice",   "chris",    "cipher",   "clarion",  "clark",    "clever",
+	"cliff",    "clone",    "conan",    "conduct",  "congo",    "content",
+	"costume",  "cotton",   "cover",    "crack",    "current",  "danube",
+	"data",     "decide",   "desire",   "detail",   "dexter",   "dinner",
+	"dispute",  "donor",    "druid",    "drum",     "easy",     "eddie",
+	"enjoy",    "enrico",   "epoxy",    "erosion",  "except",   "exile",
+	"explain",  "fame",     "fast",     "father",   "felix",    "field",
+	"fiona",    "fire",     "fish",     "flame",    "flex",     "flipper",
+	"float",    "flood",    "floor",    "forbid",   "forever",  "fractal",
+	"frame",    "freddie",  "front",    "fuel",     "gallop",   "game",
+	"garbo",    "gate",     "gibson",   "ginger",   "giraffe",  "gizmo",
+	"glass",    "goblin",   "gopher",   "grace",    "gray",     "gregory",
+	"grid",     "griffin",  "ground",   "guest",    "gustav",   "gyro",
+	"hair",     "halt",     "harris",   "heart",    "heavy",    "herman",
+	"hippie",   "hobby",    "honey",    "hope",     "horse",    "hostel",
+	"hydro",    "imitate",  "info",     "ingrid",   "inside",   "invent",
+	"invest",   "invite",   "iron",     "ivan",     "james",    "jester",
+	"jimmy",    "join",     "joseph",   "juice",    "julius",   "july",
+	"justin",   "kansas",   "karl",     "kevin",    "kiwi",     "ladder",
+	"lake",     "laura",    "learn",    "legacy",   "legend",   "lesson",
+	"life",     "light",    "list",     "locate",   "lopez",    "lorenzo",
+	"love",     "lunch",    "malta",    "mammal",   "margo",    "marion",
+	"mask",     "match",    "mayday",   "meaning",  "mercy",    "middle",
+	"mike",     "mirror",   "modest",   "morph",    "morris",   "nadia",
+	"nato",     "navy",     "needle",   "neuron",   "never",    "newton",
+	"nice",     "night",    "nissan",   "nitro",    "nixon",    "north",
+	"oberon",   "octavia",  "ohio",     "olga",     "open",     "opus",
+	"orca",     "oval",     "owner",    "page",     "paint",    "palma",
+	"parade",   "parent",   "parole",   "paul",     "peace",    "pearl",
+	"perform",  "phoenix",  "phrase",   "pierre",   "pinball",  "place",
+	"plate",    "plato",    "plume",    "pogo",     "point",    "polite",
+	"polka",    "poncho",   "powder",   "prague",   "press",    "presto",
+	"pretty",   "prime",    "promo",    "quasi",    "quest",    "quick",
+	"quiz",     "quota",    "race",     "rachel",   "raja",     "ranger",
+	"region",   "remark",   "rent",     "reward",   "rhino",    "ribbon",
+	"rider",    "road",     "rodent",   "round",    "rubber",   "ruby",
+	"rufus",    "sabine",   "saddle",   "sailor",   "saint",    "salt",
+	"satire",   "scale",    "scuba",    "season",   "secure",   "shake",
+	"shallow",  "shannon",  "shave",    "shelf",    "sherman",  "shine",
+	"shirt",    "side",     "sinatra",  "sincere",  "size",     "slalom",
+	"slow",     "small",    "snow",     "sofia",    "song",     "sound",
+	"south",    "speech",   "spell",    "spend",    "spoon",    "stage",
+	"stamp",    "stand",    "state",    "stella",   "stick",    "sting",
+	"stock",    "store",    "sunday",   "sunset",   "support",  "sweden",
+	"swing",    "tape",     "think",    "thomas",   "tictac",   "time",
+	"toast",    "tobacco",  "tonight",  "torch",    "torso",    "touch",
+	"toyota",   "trade",    "tribune",  "trinity",  "triton",   "truck",
+	"trust",    "type",     "under",    "unit",     "urban",    "urgent",
+	"user",     "value",    "vendor",   "venice",   "verona",   "vibrate",
+	"virgo",    "visible",  "vista",    "vital",    "voice",    "vortex",
+	"waiter",   "watch",    "wave",     "weather",  "wedding",  "wheel",
+	"whiskey",  "wisdom",   "deal",     "null",     "nurse",    "quebec",
+	"reserve",  "reunion",  "roof",     "singer",   "verbal",   "amen",
+	"ego",      "fax",      "jet",      "job",      "rio",      "ski",
+	"yes"
+].freeze

lib/sip_account.rb 🔗

@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+require "securerandom"
+require "value_semantics/monkey_patched"
+
+require_relative "./catapult"
+require_relative "./mn_words"
+
+class SipAccount
+	def self.find(name)
+		CATAPULT.endpoint_find(name).then do |found|
+			next New.new(username: name) unless found
+
+			new(username: found["name"], url: found["url"])
+		end
+	end
+
+	module Common
+		def with_random_password
+			with(password: MN_WORDS.sample(3).join(" "))
+		end
+
+	protected
+
+		def create
+			CATAPULT.create_endpoint(
+				name: username,
+				credentials: { password: password }
+			).then do |url|
+				with(url: url)
+			end
+		end
+	end
+
+	include Common
+
+	value_semantics do
+		url String
+		username String
+		password Either(String, nil), default: nil
+	end
+
+	def form
+		form = Blather::Stanza::X.new(:result)
+		form.title = "Sip Account Reset!"
+		form.instructions = "These are your new SIP credentials"
+
+		form.fields = [
+			{ var: "username", value: username, label: "Username" },
+			{ var: "password", value: password, label: "Password" },
+			{ var: "server", value: server, label: "Server" }
+		]
+
+		form
+	end
+
+	def put
+		delete.then { create }
+	end
+
+	def delete
+		CATAPULT.delete(url).then do |http|
+			unless http.response_header.status == 200
+				raise "Delete old SIP account failed"
+			end
+
+			self
+		end
+	end
+
+protected
+
+	protected :url, :username, :password
+
+	def server
+		CATAPULT.sip_host
+	end
+
+	class New
+		include Common
+
+		value_semantics do
+			username String
+			password String, default_generator: -> { MN_WORDS.sample(3).join(" ") }
+		end
+
+		def put
+			create
+		end
+
+		def with(**kwargs)
+			if kwargs.key?(:url)
+				SipAccount.new(internal_to_h.merge(kwargs))
+			else
+				super
+			end
+		end
+
+		protected :username, :password
+	end
+end

sgx_jmp.rb 🔗

@@ -258,6 +258,11 @@ disco_items node: "http://jabber.org/protocol/commands" do |iq|
 			iq.to,
 			"usage",
 			"Show Monthly Usage"
+		),
+		Blather::Stanza::DiscoItems::Item.new(
+			iq.to,
+			"reset sip account",
+			"Create or Reset SIP Account"
 		)
 	]
 	self << reply
@@ -321,6 +326,21 @@ command :execute?, node: "buy-credit", sessionid: nil do |iq|
 	}.catch { |e| panic(e, sentry_hub) }
 end
 
+command :execute?, node: "reset sip account", sessionid: nil do |iq|
+	sentry_hub = new_sentry_hub(iq, name: iq.node)
+	Customer.for_jid(iq.from.stripped).then { |customer|
+		sentry_hub.current_scope.set_user(
+			id: customer.customer_id,
+			jid: iq.from.stripped.to_s
+		)
+		customer.reset_sip_account
+	}.then { |sip_account|
+		reply = iq.reply
+		reply.command << sip_account.form
+		BLATHER << reply
+	}.catch { |e| panic(e, sentry_hub) }
+end
+
 command :execute?, node: "usage", sessionid: nil do |iq|
 	sentry_hub = new_sentry_hub(iq, name: iq.node)
 	report_for = (Date.today..(Date.today << 1))

test/data/catapult_import_body.json 🔗

@@ -1 +1 @@
-{"number":"+15555550000","applicationId":"catapult_app","provider":{"providerName":"bandwidth-dashboard","properties":{"accountId":"test_bw_account","userName":"test_bw_user","password":"test_bw_password"}}}
+{"applicationId":"catapult_app","number":"+15555550000","provider":{"providerName":"bandwidth-dashboard","properties":{"accountId":"test_bw_account","userName":"test_bw_user","password":"test_bw_password"}}}

test/test_customer.rb 🔗

@@ -11,6 +11,14 @@ CustomerPlan::DB = Minitest::Mock.new
 CustomerUsage::REDIS = Minitest::Mock.new
 CustomerUsage::DB = Minitest::Mock.new
 
+class SipAccount
+	public :username, :url
+
+	class New
+		public :username
+	end
+end
+
 class CustomerTest < Minitest::Test
 	def test_for_jid
 		Customer::REDIS.expect(
@@ -205,4 +213,73 @@ class CustomerTest < Minitest::Test
 		)
 	end
 	em :test_customer_usage_report
+
+	def test_sip_account_new
+		req = stub_request(
+			:get,
+			"https://api.catapult.inetwork.com/v1/users/" \
+			"catapult_user/domains/catapult_domain/endpoints?page=0&size=1000"
+		).with(
+			headers: {
+				"Authorization" => "Basic Y2F0YXB1bHRfdG9rZW46Y2F0YXB1bHRfc2VjcmV0"
+			}
+		).to_return(status: 404)
+		sip = Customer.new("test").sip_account.sync
+		assert_kind_of SipAccount::New, sip
+		assert_equal "test", sip.username
+		assert_requested req
+	end
+	em :test_sip_account_new
+
+	def test_sip_account_existing
+		req1 = stub_request(
+			:get,
+			"https://api.catapult.inetwork.com/v1/users/" \
+			"catapult_user/domains/catapult_domain/endpoints?page=0&size=1000"
+		).with(
+			headers: {
+				"Authorization" => "Basic Y2F0YXB1bHRfdG9rZW46Y2F0YXB1bHRfc2VjcmV0"
+			}
+		).to_return(status: 200, body: [
+			{ name: "NOTtest", domainId: "domain", id: "endpoint" }
+		].to_json)
+
+		req2 = stub_request(
+			:get,
+			"https://api.catapult.inetwork.com/v1/users/" \
+			"catapult_user/domains/catapult_domain/endpoints?page=1&size=1000"
+		).with(
+			headers: {
+				"Authorization" => "Basic Y2F0YXB1bHRfdG9rZW46Y2F0YXB1bHRfc2VjcmV0"
+			}
+		).to_return(status: 200, body: [
+			{ name: "test", domainId: "domain", id: "endpoint" }
+		].to_json)
+
+		sip = Customer.new("test").sip_account.sync
+		assert_kind_of SipAccount, sip
+		assert_equal "test", sip.username
+		assert_equal(
+			"https://api.catapult.inetwork.com/v1/users/" \
+			"catapult_user/domains/domain/endpoints/endpoint",
+			sip.url
+		)
+
+		assert_requested req1
+		assert_requested req2
+	end
+	em :test_sip_account_existing
+
+	def test_sip_account_error
+		stub_request(
+			:get,
+			"https://api.catapult.inetwork.com/v1/users/" \
+			"catapult_user/domains/catapult_domain/endpoints?page=0&size=1000"
+		).to_return(status: 400)
+
+		assert_raises(RuntimeError) do
+			Customer.new("test").sip_account.sync
+		end
+	end
+	em :test_sip_account_error
 end

test/test_helper.rb 🔗

@@ -48,6 +48,8 @@ CONFIG = {
 		user: "catapult_user",
 		token: "catapult_token",
 		secret: "catapult_secret",
+		domain: "catapult_domain",
+		sip_host: "host.bwapp.io.example.com",
 		application_id: "catapult_app"
 	},
 	activation_amount: 1,

test/test_sip_account.rb 🔗

@@ -0,0 +1,121 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "sip_account"
+
+class SipAccount
+	public :password, :url
+
+	class New
+		public :password
+	end
+end
+
+class SipAccountTest < Minitest::Test
+	def setup
+		@sip = SipAccount.new(
+			url: "https://api.catapult.inetwork.com/v1/" \
+			     "users/catapult_user/domains/catapult_domain/endpoints/test",
+			username: "12345",
+			password: "old password"
+		)
+	end
+
+	def test_with_random_password
+		new_sip = @sip.with_random_password
+		refute_equal @sip.password, new_sip.password
+		refute_empty new_sip.password
+		assert_kind_of String, new_sip.password
+	end
+
+	def test_form
+		form = @sip.form
+		assert_equal "12345", form.field("username").value
+		assert_equal "old password", form.field("password").value
+		assert_equal "host.bwapp.io.example.com", form.field("server").value
+	end
+
+	def test_put
+		delete = stub_request(:delete, @sip.url).with(
+			headers: {
+				"Authorization" => "Basic Y2F0YXB1bHRfdG9rZW46Y2F0YXB1bHRfc2VjcmV0"
+			}
+		).to_return(status: 200)
+
+		post = stub_request(
+			:post,
+			"https://api.catapult.inetwork.com/v1/users/" \
+			"catapult_user/domains/catapult_domain/endpoints"
+		).with(
+			body: open(__dir__ + "/data/catapult_create_sip.json").read.chomp,
+			headers: {
+				"Authorization" => "Basic Y2F0YXB1bHRfdG9rZW46Y2F0YXB1bHRfc2VjcmV0",
+				"Content-Type" => "application/json"
+			}
+		).to_return(
+			status: 201,
+			headers: { "Location" => "http://example.com/endpoint" }
+		)
+
+		new_sip = @sip.put.sync
+		assert_equal "http://example.com/endpoint", new_sip.url
+		assert_requested delete
+		assert_requested post
+	end
+	em :test_put
+
+	def test_put_delete_fail
+		stub_request(:delete, @sip.url).to_return(status: 400)
+		assert_raises(RuntimeError) { @sip.put.sync }
+	end
+	em :test_put_delete_fail
+
+	def test_put_post_fail
+		stub_request(:delete, @sip.url).to_return(status: 200)
+		stub_request(
+			:post,
+			"https://api.catapult.inetwork.com/v1/users/" \
+			"catapult_user/domains/catapult_domain/endpoints"
+		).to_return(status: 400)
+		assert_raises(RuntimeError) { @sip.put.sync }
+	end
+	em :test_put_post_fail
+
+	class NewTest < Minitest::Test
+		def setup
+			@sip = SipAccount::New.new(
+				username: "12345",
+				password: "old password"
+			)
+		end
+
+		def test_with_random_password
+			new_sip = @sip.with_random_password
+			refute_equal @sip.password, new_sip.password
+			refute_empty new_sip.password
+			assert_kind_of String, new_sip.password
+		end
+
+		def test_put
+			post = stub_request(
+				:post,
+				"https://api.catapult.inetwork.com/v1/users/" \
+				"catapult_user/domains/catapult_domain/endpoints"
+			).with(
+				body: open(__dir__ + "/data/catapult_create_sip.json").read.chomp,
+				headers: {
+					"Authorization" => "Basic Y2F0YXB1bHRfdG9rZW46Y2F0YXB1bHRfc2VjcmV0",
+					"Content-Type" => "application/json"
+				}
+			).to_return(
+				status: 201,
+				headers: { "Location" => "http://example.com/endpoint" }
+			)
+
+			new_sip = @sip.put.sync
+			assert_equal "http://example.com/endpoint", new_sip.url
+			assert_requested post
+		end
+		em :test_put
+	end
+end