diff --git a/src/main/nim/strawbosspkg/configuration.nim b/src/main/nim/strawbosspkg/configuration.nim index a01cfc2..8c79c1d 100644 --- a/src/main/nim/strawbosspkg/configuration.nim +++ b/src/main/nim/strawbosspkg/configuration.nim @@ -1,6 +1,8 @@ import logging, json, os, nre, sequtils, strtabs, tables, times import private/util +from typeinfo import toAny + # Types # type @@ -68,6 +70,17 @@ proc getOrFail(n: JsonNode, key: string, objName: string = ""): JsonNode = return n[key] # Configuration parsing code + +proc parseProjectDef*(pJson: JsonNode): ProjectDef = + var envVars = newStringTable(modeCaseSensitive) + for k, v in pJson.getIfExists("envVars").getFields: envVars[k] = v.getStr("") + + result = ProjectDef( + cfgFilePath: pJson.getIfExists("cfgFilePath").getStr("strawboss.json"), + defaultBranch: pJson.getIfExists("defaultBranch").getStr("master"), + name: pJson.getOrFail("name", "project definition").getStr, + envVars: envVars, + repo: pJson.getOrFail("repo", "project definition").getStr) proc loadStrawBossConfig*(cfgFile: string): StrawBossConfig = if not existsFile(cfgFile): @@ -75,20 +88,6 @@ proc loadStrawBossConfig*(cfgFile: string): StrawBossConfig = let jsonCfg = parseFile(cfgFile) - var projectDefs: seq[ProjectDef] = @[] - - for pJson in jsonCfg.getIfExists("projects").getElems: - var envVars = newStringTable(modeCaseSensitive) - for k, v in pJson.getIfExists("envVars").getFields: envVars[k] = v.getStr("") - - projectDefs.add( - ProjectDef( - cfgFilePath: pJson.getIfExists("cfgFilePath").getStr("strawboss.json"), - defaultBranch: pJson.getIfExists("defaultBranch").getStr("master"), - name: pJson.getOrFail("name", "project definition").getStr, - envVars: envVars, - repo: pJson.getOrFail("repo", "project definition").getStr)) - var users: seq[UserRef] = @[] for uJson in jsonCfg.getIfExists("users").getElems: @@ -101,7 +100,7 @@ proc loadStrawBossConfig*(cfgFile: string): StrawBossConfig = authSecret: jsonCfg.getOrFail("authSecret", "strawboss config").getStr, debug: jsonCfg.getIfExists("debug").getBVal(false), pwdCost: int8(jsonCfg.getOrFail("pwdCost", "strawboss config").getNum), - projects: projectDefs, + projects: jsonCfg.getIfExists("projects").getElems.mapIt(parseProjectDef(it)), users: users) proc loadProjectConfig*(cfgFile: string): ProjectConfig = @@ -141,6 +140,7 @@ proc loadBuildStatus*(statusFile: string): BuildStatus = state: jsonObj.getOrFail("state", "build status").getStr, details: jsonObj.getIfExists("details").getStr("") ) + # TODO: unused and untested, add tests if we start using this proc parseRunRequest*(reqStr: string): RunRequest = let reqJson = parseJson(reqStr) @@ -166,7 +166,8 @@ proc `%`*(p: ProjectDef): JsonNode = "defaultBranch": p.defaultBranch, "repo": p.repo } - # TODO: envVars? + result["envVars"] = newJObject() + for k, v in p.envVars: result["envVars"][k] = %v proc `%`*(req: RunRequest): JsonNode = result = %* { @@ -178,3 +179,18 @@ proc `%`*(req: RunRequest): JsonNode = proc `$`*(s: BuildStatus): string = result = pretty(%s) proc `$`*(req: RunRequest): string = result = pretty(%req) +proc `$`*(pd: ProjectDef): string = result = pretty(%pd) + +# TODO: maybe a macro for more general-purpose, shallow object comparison? +#proc `==`*(a, b: ProjectDef): bool = + +template shallowEquals(a, b: RootObj): bool = + if type(a) != type(b): return false + var anyB = toAny(b) + + for name, value in a.fieldPairs: + if value != b[name]: return false + + return true + +#proc `==`*(a, b: ProjectDef): bool = result = shallowEquals(a, b) diff --git a/src/main/nim/strawbosspkg/server.nim b/src/main/nim/strawbosspkg/server.nim index 4aa28e8..5fbb804 100644 --- a/src/main/nim/strawbosspkg/server.nim +++ b/src/main/nim/strawbosspkg/server.nim @@ -1,6 +1,7 @@ -import asyncdispatch, bcrypt, jester, json, jwt, os, osproc, sequtils, tempfile, - times, unittest +import asyncdispatch, bcrypt, jester, json, jwt, os, osproc, sequtils, + strutils, tempfile, times, unittest +import logging import ./configuration, ./core, private/util type Worker = object @@ -47,7 +48,7 @@ proc toJWT*(cfg: StrawBossConfig, session: Session): string = jwt.sign(cfg.authSecret) result = $jwt -proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session = +proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session = let jwt = toJWT(strTok) var secret = cfg.authSecret if not jwt.verify(secret): raiseEx "Unable to verify auth token." @@ -66,11 +67,15 @@ proc fromJWT*(cfg: StrawBossConfig, strTok: string): Session = proc extractSession(cfg: StrawBossConfig, request: Request): Session = # Find the auth header - if not request.headers.hasKey("Authentication"): + if not request.headers.hasKey("Authorization"): raiseEx "No auth token." # Read and verify the JWT token - result = fromJWT(cfg, request.headers["Authentication"]) + let headerVal = request.headers["Authorization"] + if not headerVal.startsWith("Bearer "): + raiseEx "Invalid Authentication type (only 'Bearer' is supported)." + + result = fromJWT(cfg, headerVal[7..^1]) proc spawnWorker(req: RunRequest): Worker = let dir = mkdtemp() @@ -101,10 +106,19 @@ proc makeAuthToken*(cfg: StrawBossConfig, uname, pwd: string): string = if not validatePwd(user, pwd): raiseEx "invalid username or password" result = toJWT(cfg, newSession(user)) -template requireAuth() = + +template withSession(body: untyped): untyped = var session {.inject.}: Session - try: session = extractSession(givenCfg, request) - except: resp(Http401, makeJsonResp(Http401), "application/json") + var authed = false + + try: + session = extractSession(givenCfg, request) + authed = true + except: + debug "Auth failed: " & getCurrentExceptionMsg() + resp(Http401, makeJsonResp(Http401), "application/json") + + if authed: body proc start*(givenCfg: StrawBossConfig): void = @@ -116,18 +130,22 @@ proc start*(givenCfg: StrawBossConfig): void = appName = "/api" routes: + get "/ping": resp($(%*"pong"), "application/json") get "/auth-token": - echo $request.params try: let authToken = makeAuthToken(givenCfg, @"username", @"password") resp("\"" & $authToken & "\"", "application/json") except: resp(Http401, makeJsonResp(Http401, getCurrentExceptionMsg())) - get "/projects": - requireAuth() + get "/verify-auth": withSession: + resp(Http200, $(%*{ + "username": session.user.name + }), "application/json") + + get "/projects": withSession: resp($(%(givenCfg.projects)), "application/json") post "/project/@projectName/@stepName/run/@buildRef?": diff --git a/src/test/nim/tconfiguration.nim b/src/test/nim/tconfiguration.nim index a5b2f22..423b776 100644 --- a/src/test/nim/tconfiguration.nim +++ b/src/test/nim/tconfiguration.nim @@ -1,9 +1,61 @@ -import strtabs, tables, unittest +import json, strtabs, tables, unittest import ./testutil import ../../main/nim/strawbosspkg/configuration suite "load and save configuration objects": + # suite setup & common data + let testProjDefStr = """{ "name": "test-project-1", "repo": + "/non-existent/dir", + "cfgFilePath": "strawhat.json", + "defaultBranch": "deploy", + "envVars": { "VAR1": "value" } }""" + + let testProjDef = ProjectDef( + name: "test-project-1", + repo: "/non-existent/dir", + cfgFilePath: "strawhat.json", + defaultBranch: "deploy", + envVars: newStringTable("VAR1", "value", modeCaseInsensitive)) + + + test "parseProjectDef": + let pd = parseProjectDef(parseJson(testProjDefStr)) + + check: + pd.name == "test-project-1" + pd.repo == "/non-existent/dir" + pd.cfgFilePath == "strawhat.json" + pd.defaultBranch == "deploy" + pd.envVars.len == 1 + pd.envVars.hasKey("VAR1") + pd.envVars["VAR1"] == "value" + + test "ProjectDef ==": + let pd1 = parseProjectDef(parseJson(testProjDefStr)) + + check pd1 == testProjDef + + test "ProjectDef != (name)": + var pd1 = testProjDef + pd1.name = "different" + check pd1 != testProjDef + + test "ProjectDef != (repo)": + var pd1 = testProjDef + pd1.repo = "different" + check pd1 != testProjDef + + test "ProjectDef != (cfgFilePath)": + var pd1 = testProjDef + pd1.cfgFilePath = "different" + check pd1 != testProjDef + + test "ProjectDef != (defaultBranch)": + var pd1 = testProjDef + pd1.defaultBranch = "different" + check pd1 != testProjDef + test "loadStrawBossConfig": let cfg = loadStrawBossConfig("src/test/json/strawboss.config.json") let expectedUsers = @[UserRef(name: "bob@builder.com", hashedPwd: "testvalue"), diff --git a/src/test/nim/tserver.nim b/src/test/nim/tserver.nim index 7456529..73e24c6 100644 --- a/src/test/nim/tserver.nim +++ b/src/test/nim/tserver.nim @@ -1,10 +1,19 @@ -import asyncdispatch, httpclient, os, osproc, strutils, times, unittest +import asyncdispatch, httpclient, json, os, osproc, sequtils, strutils, times, unittest import ./testutil import ../../main/nim/strawbosspkg/configuration import ../../main/nim/strawbosspkg/server import ../../main/nim/strawbosspkg/private/util -suite "strawboss server": +import strtabs + +# test helpers +proc newAuthenticatedHttpClient(apiBase, uname, pwd: string): HttpClient = + result = newHttpClient() + let authResp = result.get(apiBase & "/auth-token?username=" & uname & "&password=" & pwd) + assert authResp.status.startsWith("200") + result.headers = newHttpHeaders({"Authorization": "Bearer " & parseJson(authResp.body).getStr}) + +suite "strawboss server can...": # suite setup code let cfgFilePath = "src/test/json/strawboss.config.json" @@ -24,35 +33,59 @@ suite "strawboss server": ## UNIT TESTS - test "can validate hashed pwd": + test "validate hashed pwd": check validatePwd(testuser, "password") - test "can detect invalid pwds": + test "detect invalid pwds": check(not validatePwd(testuser, "Password")) - test "can make and extract a JWT token from a session": + test "make and extract a JWT token from a session": let session = newSession(testuser) let tok = toJWT(cfg, session) + check fromJWT(cfg, tok) == session - check: - fromJWT(cfg, tok) == session - - test "can ping": + test "ping": let resp = http.get(apiBase & "/ping") check: resp.status.startsWith("200") resp.body == "\"pong\"" - test "can fail auth": + test "fail auth": let resp = http.get(apiBase & "/auth-token?username=bob@builder.com&password=notpassword") - check: - resp.status.startsWith("401") + check resp.status.startsWith("401") - test "can auth": + test "auth": let resp = http.get(apiBase & "/auth-token?username=bob@builder.com&password=password") - check: - resp.status.startsWith("200") + check resp.status.startsWith("200") + + test "verify valid auth token": + let authHttp = newAuthenticatedHttpClient(apiBase, "bob@builder.com", "password") + let resp = authHttp.get(apiBase & "/verify-auth") + check resp.status.startsWith("200") + + test "verify fails when no auth token is given": + let resp = http.get(apiBase & "/verify-auth") + check resp.status.startsWith("401") + + test "verify fails when invalid auth token is given": + let http1 = newHttpClient() + http1.headers = newHttpHeaders({"Authorization": "Bearer nope"}) + let resp = http1.get(apiBase & "/verify-auth") + check resp.status.startsWith("401") + + test "fail to get projects when not authenticated": + let resp = http.get(apiBase & "/projects") + check resp.status.startsWith("401") + + test "get projects": + let authHttp = newAuthenticatedHttpClient(apiBase, "bob@builder.com", "password") + let resp = authHttp.get(apiBase & "/projects") + check resp.status.startsWith("200") + + let projects: seq[ProjectDef] = parseJson(resp.body).getElems.mapIt(parseProjectDef(it)) + + check sameContents(projects, cfg.projects) # suite tear-down try: discard http.post(apiBase & "/service/debug/stop")