From 74c1b106ee9e31743f80d0abd4779d2f8fdeb69b Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Thu, 27 Nov 2025 06:14:39 -0500 Subject: [PATCH 1/7] Remove unused test function --- src/Test/E2E/Route/Session.gren | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Test/E2E/Route/Session.gren b/src/Test/E2E/Route/Session.gren index bb3cfa9..658909f 100644 --- a/src/Test/E2E/Route/Session.gren +++ b/src/Test/E2E/Route/Session.gren @@ -22,14 +22,6 @@ tests httpPerm = db : Db.Connection db = initDb httpPerm - - getUser : String -> Task Db.Error User - getUser email = - Db.getOne db - { query = "select * from user where email = :email" - , parameters = [ Db.Encode.string "email" email ] - , decoder = User.decoder - } in [ awaitError "GET /session" (get httpPerm "/session") <| \response -> test "404s" <| \_ -> From 9b5dc4cbb82e300b5ab63c42bf49435e9c5dc6ef Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Sun, 23 Nov 2025 06:06:02 -0500 Subject: [PATCH 2/7] Email confirmation endpoint POST email confirmation code and get a CLI confirmation code in the response. --- README.md | 21 +++-- gren.json | 8 +- src/Main.gren | 24 +++++ src/Postmark.gren | 2 - src/Registry/Db.gren | 10 +- src/Route/Session.gren | 88 ++++++++++++++++- src/Session.gren | 162 ++++++++++++++++++++++++++++++-- src/Test/E2E.gren | 2 +- src/Test/E2E/Helper.gren | 74 +++++++++++++-- src/Test/E2E/Route/Session.gren | 98 ++++++++++++++++++- src/Test/E2E/Session.gren | 117 +++++++++++++++++++---- 11 files changed, 547 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 5b0bf7c..2a0c1bc 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ This project uses [devbox](https://www.jetify.com/devbox) and [direnv](https://d Install both for the smoothest experience. Copy `.envrc.sample` to `.envrc` and set environment variables appropriately. +Then run `direnv allow` so it will be evaluated when you enter this directory. You can run the server with `devbox services up` @@ -74,20 +75,20 @@ Auth flow: 1. CLI posts email address to the server (IN PROGRESS) 1. [X] server finds or creates row in `user` table 2. [X] server creates new row in `session` table with: - - `email_validation_token` for unique url for user to validate their email address and get a `validation_code` - - `fetch_session_token` for unique url for cli to fetch session (along with `validation_code`) - 4. [ ] server emails validation link with `email_validation_token` (postmark) + - `email_confirmation_token` for user to confirm their email address and get a `confirmation_code` + - `fetch_session_token` for fetching the session (once they have a `confirmation_code`) + 4. [ ] server emails confirmation link with `email_confirmation_token` (postmark) 5. [ ] server returns fetch session link with `fetch_session_token` -2. User follows email validation link to get a validation code. -3. User enters validation code on the CLI, where it was prompting/waiting for it. -4. CLI posts to the fetch link with the validation code. +2. User follows email confirmation link to get a confirmation code. +3. User enters confirmation code on the CLI, where it was prompting/waiting for it. +4. CLI posts to the fetch link with the confirmation code. 5. Server creates and returns a session token. 6. CLI saves token on disk to use for authenticated requests. Benefits: - Not saving or requiring passwords. -- CLI auth tightly coupled to email validation. - - Can't get session token without fetch token/link from the cli initiation and the validation code. - - Can't get the validation code without validation token/link from the email. -- CLI auth loosely coupled to where you check your email (clicking validation link in email gives you a code that can be manually entered on the cli) +- CLI auth tightly coupled to email confirmation. + - Can't get session token without fetch token/link from the cli initiation and the confirmation code. + - Can't get the confirmation code without confirmation token/link from the email. +- CLI auth loosely coupled to where you check your email (clicking confirmation link in email gives you a code that can be manually entered on the cli) diff --git a/gren.json b/gren.json index 112a11d..e2b586a 100644 --- a/gren.json +++ b/gren.json @@ -11,11 +11,11 @@ "blaix/gren-ws4sql": "3.0.2", "gren-lang/core": "7.0.0", "gren-lang/node": "6.0.0", - "gren-lang/test": "5.0.0" + "gren-lang/test": "5.0.0", + "gren-lang/url": "6.0.0" }, "indirect": { - "gren-lang/test-runner-node": "7.0.0", - "gren-lang/url": "6.0.0" + "gren-lang/test-runner-node": "7.0.0" } } -} +} \ No newline at end of file diff --git a/src/Main.gren b/src/Main.gren index 1a0bfa6..f9f82e3 100644 --- a/src/Main.gren +++ b/src/Main.gren @@ -19,6 +19,8 @@ import Route.Session import Stream import Task exposing (Task) import Test.E2E.Helper as Helper +import Url.Parser +import Url.Parser.Query as Query main : Program Model Msg @@ -207,6 +209,28 @@ route model request response = Route.Error.invalidRequestData response "Request json did not contain a valid `email` field." + { method = GET, path = [ "session", "confirm-email" ] } -> + let + queryParser = + Query.string "token" + |> Url.Parser.query + + -- https://github.com/elm/url/issues/17 + queryOnly = + { request.url | path = "" } + in + when Url.Parser.parse queryParser queryOnly is + Just (Just token) -> + Route.Session.confirmEmail + { db = model.db + , requestData = { token = token } + , response = response + } + + _ -> + Route.Error.invalidRequestData response + "Request must contain a `token`" + _ -> Route.Error.notFound response diff --git a/src/Postmark.gren b/src/Postmark.gren index f9249e2..0bcae2a 100644 --- a/src/Postmark.gren +++ b/src/Postmark.gren @@ -17,8 +17,6 @@ url = "https://api.postmarkapp.com/email" --- Might not want to always hardcode this. But for now we're only using this for --- email validation. from : String from = "no-reply@gren-lang.org" diff --git a/src/Registry/Db.gren b/src/Registry/Db.gren index 0a97097..3e395bb 100644 --- a/src/Registry/Db.gren +++ b/src/Registry/Db.gren @@ -36,17 +36,17 @@ migrate db = created INTEGER NOT NULL, user_id INTEGER NOT NULL REFERENCES user(id), - -- Token used in email validation link. - email_validation_token TEXT NOT NULL UNIQUE, + -- Token used in email confirmation link. + email_confirmation_token TEXT NOT NULL UNIQUE, - -- Create and show this when user follows email validation link. + -- Create and show this when user follows email confirmation link. -- User will provide this to the CLI. - cli_validation_token TEXT UNIQUE, + confirmation_code TEXT UNIQUE, -- Token used by the CLI to fetch session token. fetch_session_token TEXT NOT NULL UNIQUE, - -- Create and return this when CLI posts with validation_code and fetch_session_token + -- Create and return this when CLI posts with confirmation_code and fetch_session_token session_token TEXT UNIQUE ) STRICT """ diff --git a/src/Route/Session.gren b/src/Route/Session.gren index 3c41c36..d4c7558 100644 --- a/src/Route/Session.gren +++ b/src/Route/Session.gren @@ -1,5 +1,7 @@ module Route.Session exposing ( create + , confirmEmail + , confirmEmailPath ) @@ -13,8 +15,11 @@ import HttpServer.Response as Response exposing (Response) import Postmark import Json.Decode import Json.Encode +import Route.Error import Session exposing (Session) import Task exposing (Task) +import Time +import Url.Builder import User exposing (User) @@ -23,6 +28,20 @@ type Error | SendEmailFailed HttpClient.Error +-- PATHS + + +confirmEmailPath : { token : Maybe String } -> String +confirmEmailPath { token } = + let + query = + when token is + Just t -> [ Url.Builder.string "token" t ] + Nothing -> [] + in + Url.Builder.absolute [ "session", "confirm-email" ] query + + -- ENDPOINTS @@ -37,11 +56,72 @@ create : create { db, secureContext, postmark, requestData, response } = findOrCreateUser db requestData.email |> Task.andThen (createSession db secureContext ) - |> Task.andThen (sendEmailValidationLink postmark) + |> Task.andThen (sendEmailConfirmationLink postmark) |> Task.map (createSuccess response) |> Task.onError (createFailed response) +confirmEmail : + { db : Db.Connection + , requestData : { token : String } + , response : Response + } + -> Task Never Response +confirmEmail { db, requestData, response } = + let + handleConfirmatoinCode session = + Time.now + |> Task.andThen (\now -> + if Session.emailConfirmationExpired now session then + Task.fail "Email confirmation link has expired" + else + when session.confirmationCode is + Just code -> + Task.succeed code + + Nothing -> + Session.setConfirmationCode db session + |> Task.mapError (\_ -> "Failed to set confirmation code") + |> Task.andThen (\updatedSession -> + when updatedSession.confirmationCode is + Just code -> + Task.succeed code + + Nothing -> + Task.fail "Failed to generate confirmation code" + ) + ) + + handleDbError error = + when error is + Db.NoResultError -> + Task.fail "Invalid token" + + _ -> + Task.fail "Database error" + in + Session.getWithEmailConfirmationToken db requestData.token + |> Task.onError handleDbError + |> Task.andThen handleConfirmatoinCode + |> Task.andThen (\code -> confirmEmailSuccess response code) + |> Task.onError (Route.Error.invalidRequestData response) + + +confirmEmailSuccess : Response -> String -> Task x Response +confirmEmailSuccess response code = + let + json = + Json.Encode.object + [ { key = "confirmationCode", value = Json.Encode.string code } + ] + in + response + |> Response.setStatus 200 + |> Response.setHeader "Content-Type" "application/json" + |> Response.setBody (Json.Encode.encode 0 json) + |> Task.succeed + + -- ACTIONS @@ -57,12 +137,12 @@ createSession db secureContext user = |> Task.mapError DbError -sendEmailValidationLink : Postmark.Configuration -> Session -> Task Error Session -sendEmailValidationLink postmarkConfig session = +sendEmailConfirmationLink : Postmark.Configuration -> Session -> Task Error Session +sendEmailConfirmationLink postmarkConfig session = { to = session.user.email |> Email.toString , subject = "Gren: Confirm your email address" , textBody = - "TODO: link with this token: " ++ session.emailValidationToken + "TODO: link with this token: " ++ session.emailConfirmationToken } |> Postmark.send postmarkConfig |> Task.mapError SendEmailFailed diff --git a/src/Session.gren b/src/Session.gren index fb49c7c..263915c 100644 --- a/src/Session.gren +++ b/src/Session.gren @@ -1,13 +1,19 @@ module Session exposing ( Session , create + , getWithEmailConfirmationToken + , setConfirmationCode + , emailConfirmationExpired ) +import Bytes.Decode import Crypto import Db import Db.Encode import Db.Decode +import Email +import Math import Task exposing (Task) import Time import User exposing (User) @@ -16,16 +22,17 @@ import User exposing (User) type alias Session = { created : Time.Posix , user : User - , emailValidationToken : String + , emailConfirmationToken : String , fetchSessionToken : String + , confirmationCode : Maybe String } -create : +create : { db : Db.Connection , secureContext : Crypto.SecureContext , user : User - } + } -> Task Db.Error Session create { db, user, secureContext } = Task.await (Crypto.randomUuidV4 secureContext) <| \uuid1 -> @@ -34,24 +41,165 @@ create { db, user, secureContext } = dbInsert db { created = now , user = user - , emailValidationToken = uuid1 + , emailConfirmationToken = uuid1 , fetchSessionToken = uuid2 + , confirmationCode = Nothing } +-- GETTERS + + +getWithEmailConfirmationToken : Db.Connection -> String -> Task Db.Error Session +getWithEmailConfirmationToken db token = + Db.getOne db + { query = queryPrelude ++ " where s.email_confirmation_token = :token" + , parameters = [ Db.Encode.string "token" token ] + , decoder = dbDecoder + } + + +-- CONFIRMATION CODE + + +confirmationCodeCharset : String +confirmationCodeCharset = + "023456789ABCDEFGHJKMNPQRSTUVWXYZ" + + +byteToConfirmationChar : Int -> String +byteToConfirmationChar byte = + let + charsetLength = + 32 + + index = + Math.modBy charsetLength byte + + char = + String.slice index (index + 1) confirmationCodeCharset + in + char + + +generateConfirmationCode : Task {} String +generateConfirmationCode = + let + decoder = + Bytes.Decode.loop { index = 0, chars = [] } <| \state -> + if state.index >= 8 then + Bytes.Decode.succeed (Bytes.Decode.Done state.chars) + else + (Bytes.Decode.unsignedInt8 + |> Bytes.Decode.map (\byte -> + Bytes.Decode.Loop + { index = state.index + 1 + , chars = Array.pushLast (byteToConfirmationChar byte) state.chars + } + )) + in + (Crypto.getRandomUInt8Values 8 + |> Task.andThen (\bytes -> + when Bytes.Decode.decode decoder bytes is + Just chars -> + Task.succeed (String.join "" chars) + + Nothing -> + Task.fail {} + )) + + +emailConfirmationExpired : Time.Posix -> Session -> Bool +emailConfirmationExpired now session = + let + emailConfirmationWindow = + 30 * 60 * 1000 -- 30 mins + + confirmationExpiresAt = + Time.posixToMillis session.created + emailConfirmationWindow + in + Time.posixToMillis now >= confirmationExpiresAt + + +setConfirmationCode : Db.Connection -> Session -> Task Db.Error Session +setConfirmationCode db session = + generateConfirmationCode + -- maybe need a custom error type? + |> Task.mapError (\_ -> Db.Error "Failed to generate confirmation code") + |> Task.andThen (\code -> + Db.execute db + { statement = "UPDATE session SET confirmation_code = :code WHERE email_confirmation_token = :token" + , parameters = + [ Db.Encode.string "code" code + , Db.Encode.string "token" session.emailConfirmationToken + ] + } + |> Task.map (\_ -> { session | confirmationCode = Just code }) + ) + + +-- DB HELPERS + + dbInsert : Db.Connection -> Session -> Task Db.Error Session dbInsert db session = Task.map (\_ -> session) <| Db.execute db { statement = """ - insert into session (created, user_id, email_validation_token, fetch_session_token) - values (:created, :user_id, :email_validation_token, :fetch_session_token) + insert into session (created, user_id, email_confirmation_token, fetch_session_token) + values (:created, :user_id, :email_confirmation_token, :fetch_session_token) """ , parameters = [ Db.Encode.posix "created" session.created , Db.Encode.int "user_id" session.user.id - , Db.Encode.string "email_validation_token" session.emailValidationToken + , Db.Encode.string "email_confirmation_token" session.emailConfirmationToken , Db.Encode.string "fetch_session_token" session.fetchSessionToken ] } + + +queryPrelude : String +queryPrelude = + """ + select + s.created as session_created, + s.email_confirmation_token, + s.fetch_session_token, + s.confirmation_code, + u.id as user_id, + u.created as user_created, + u.email + from session s + inner join user u on (u.id = s.user_id) + """ + +dbDecoder : Db.Decode.Decoder Session +dbDecoder = + Db.Decode.string "email" + |> Db.Decode.andThen + (\emailString -> + when (Email.fromString emailString) is + Nothing -> + Db.Decode.fail ("Invalid user email in db: " ++ emailString) + Just userEmail -> + Db.Decode.get6 + ( Db.Decode.posix "session_created" ) + ( Db.Decode.string "email_confirmation_token" ) + ( Db.Decode.string "fetch_session_token" ) + ( Db.Decode.maybe Db.Decode.string "confirmation_code" ) + ( Db.Decode.int "user_id" ) + ( Db.Decode.posix "user_created" ) + (\created emailConfirmationToken fetchSessionToken confirmationCode userId userCreated -> + { created = created + , emailConfirmationToken = emailConfirmationToken + , fetchSessionToken = fetchSessionToken + , confirmationCode = confirmationCode + , user = + { id = userId + , created = userCreated + , email = userEmail + } + } + ) + ) diff --git a/src/Test/E2E.gren b/src/Test/E2E.gren index ba91c96..ff876c4 100644 --- a/src/Test/E2E.gren +++ b/src/Test/E2E.gren @@ -28,7 +28,7 @@ tests httpPerm = in [ await "Get secure context" Crypto.getSecureContext <| \secureContext -> concat - [ describe "Session route tests" (Test.E2E.Route.Session.tests httpPerm) + [ describe "Session route tests" (Test.E2E.Route.Session.tests httpPerm secureContext) , describe "Session module tests" (Test.E2E.Session.tests db secureContext) , describe "User module tests" (Test.E2E.User.tests db) , describe "Postmark module tests" (Test.E2E.Postmark.tests postmark) diff --git a/src/Test/E2E/Helper.gren b/src/Test/E2E/Helper.gren index 8959ac8..5985d54 100644 --- a/src/Test/E2E/Helper.gren +++ b/src/Test/E2E/Helper.gren @@ -1,6 +1,10 @@ module Test.E2E.Helper exposing - ( expectBadStatus + ( createSession + , decodeJson + , expectBadStatus , expectJson + , expectNoResultError + , expectStringContains , initDb , get , post @@ -9,19 +13,20 @@ module Test.E2E.Helper exposing import Bytes exposing (Bytes) +import Crypto import Db import Email import Expect exposing (Expectation) import HttpClient exposing (Response) import Json.Decode import Json.Encode +import Result +import Session exposing (Session) import Task exposing (Task) import User -url : String -> String -url path = - "http://localhost:3000" ++ path +-- DB initDb : HttpClient.Permission -> Db.Connection @@ -30,11 +35,30 @@ initDb httpPerm = Db.init httpPerm "http://localhost:12321/local" -get : HttpClient.Permission -> String -> Task HttpClient.Error (HttpClient.Response {}) +expectNoResultError : Db.Error -> Expectation +expectNoResultError error = + when error is + Db.NoResultError -> + Expect.pass + + e -> + Expect.fail ("Expected NoResultError, got " ++ (Db.errorToString e)) + + +-- HTTP + + +url : String -> String +url path = + "http://localhost:3000" ++ path + + + +get : HttpClient.Permission -> String -> Task HttpClient.Error (HttpClient.Response String) get httpPerm path = url path |> HttpClient.get - |> HttpClient.expectAnything + |> HttpClient.expectString |> HttpClient.send httpPerm @@ -66,6 +90,12 @@ expectBadStatus status error = (Debug.toString error) +decodeJson : Json.Decode.Decoder a -> Bytes -> Maybe a +decodeJson decoder bytes = + Bytes.toString bytes + |> Maybe.andThen (Json.Decode.decodeString decoder >> Result.toMaybe) + + expectJson : Json.Decode.Decoder a -> a -> Bytes -> Expectation expectJson decoder expected bytes = when Bytes.toString bytes is @@ -79,3 +109,35 @@ expectJson decoder expected bytes = Err error -> Expect.fail (Json.Decode.errorToString error) + + +-- SESSIONS + + +{-| Create a session for a user with the example email. +-} +createSession : + { db : Db.Connection, secureContext : Crypto.SecureContext } + -> Task Db.Error Session +createSession { db, secureContext } = + User.findOrCreate db Email.example + |> Task.andThen + (\user -> + Session.create + { db = db + , secureContext = secureContext + , user = user + } + ) + + +-- STRINGS + +expectStringContains : String -> String -> Expectation +expectStringContains expected actual = + if String.contains expected actual then + Expect.pass + else + "Expected \"" ++ actual ++ "\" to contain \"" ++ expected ++ "\"" + |> Expect.fail + diff --git a/src/Test/E2E/Route/Session.gren b/src/Test/E2E/Route/Session.gren index 658909f..d5ae636 100644 --- a/src/Test/E2E/Route/Session.gren +++ b/src/Test/E2E/Route/Session.gren @@ -2,6 +2,7 @@ module Test.E2E.Route.Session exposing (tests) import Bytes +import Crypto import Db import Db.Encode import Db.Decode @@ -10,14 +11,18 @@ import Expect import HttpClient import Json.Decode import Json.Encode +import Route.Session +import Session exposing (Session) +import String import Task exposing (Task) import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe, test) -import Test.E2E.Helper exposing (initDb, get, post, postWithJson, expectBadStatus, expectJson) +import Test.E2E.Helper as Helper exposing (initDb, get, post, postWithJson, expectBadStatus, expectJson, expectStringContains) +import Time import User exposing (User) -tests : HttpClient.Permission -> Array Test -tests httpPerm = +tests : HttpClient.Permission -> Crypto.SecureContext -> Array Test +tests httpPerm secureContext = let db : Db.Connection db = @@ -27,6 +32,10 @@ tests httpPerm = test "404s" <| \_ -> expectBadStatus 404 response + + -- CREATE SESSION + + , describe "Create session" <| let goodEmail = @@ -80,5 +89,88 @@ tests httpPerm = expectedToken ) ] + + + -- CONFIRM EMAIL + + + , describe "Confirm email" <| + let + createTestSession = + Helper.createSession + { db = db + , secureContext = secureContext + } + + httpGet token = + Route.Session.confirmEmailPath { token = token } + |> (\path -> + ("http://localhost:3000" ++ path) + |> HttpClient.get + |> HttpClient.expectBytes + |> HttpClient.send httpPerm + ) + in + [ awaitError "GET with missing token" (httpGet Nothing) <| \responseError -> + test "is client error" <| \_ -> + expectBadStatus 400 responseError + + , awaitError "GET with bad token" (httpGet (Just "bad token")) <| \responseError -> + test "is client error" <| \_ -> + expectBadStatus 400 responseError + + , await "create test session" createTestSession <| \session -> + await "GET with good token" (httpGet (Just session.emailConfirmationToken)) <| \response -> + let + codeDecoder = + Json.Decode.field "confirmationCode" Json.Decode.string + in + concat + [ test "is success" <| \_ -> + Expect.equal 200 response.statusCode + + , await "get updated session" (Session.getWithEmailConfirmationToken db session.emailConfirmationToken) <| \updatedSession -> + test "responds with confirmation code" <| \_ -> + when Helper.decodeJson codeDecoder response.data is + Just code -> + Expect.all + [ String.count >> Expect.equal 8 + , Just >> Expect.equal updatedSession.confirmationCode + ] + code + + Nothing -> + Expect.fail "Failed to decode confirmation code from response" + + , await "GET again with same token (idempotent)" (httpGet (Just session.emailConfirmationToken)) <| \response2 -> + test "returns same code" <| \_ -> + Expect.equal response.data response2.data + ] + + , await "create session for expired email confirmation test" createTestSession <| \expiredSession -> + await "update session to be expired (created 31 minutes ago)" (forceEmailConfirmationExpiration db expiredSession) <| \_ -> + awaitError "GET with expired session token" (httpGet (Just expiredSession.emailConfirmationToken)) <| \responseError -> + test "is client error" <| \_ -> + expectBadStatus 400 responseError + ] ] ] + + +forceEmailConfirmationExpiration : Db.Connection -> Session -> Task Db.Error Int +forceEmailConfirmationExpiration db session = + Task.await Time.now <| \now -> + let + past = + now + |> Time.posixToMillis + |> (\m -> m - (31 * 60 * 1000)) -- 31 mins ago + |> Time.millisToPosix + in + Db.execute db + { statement = "UPDATE session SET created = :created WHERE email_confirmation_token = :token" + , parameters = + [ Db.Encode.posix "created" past + , Db.Encode.string "token" session.emailConfirmationToken + ] + } diff --git a/src/Test/E2E/Session.gren b/src/Test/E2E/Session.gren index d1027ce..894495f 100644 --- a/src/Test/E2E/Session.gren +++ b/src/Test/E2E/Session.gren @@ -7,19 +7,19 @@ import Db.Encode import Db.Decode import Email import Expect +import Fuzz import Session +import Set import Task exposing (Task) -import Test.Runner.Effectful exposing (Test, await, concat, describe, test) +import Test.E2E.Helper exposing (expectNoResultError) +import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe, fuzz, fuzz2, test) +import Time import User exposing (User) tests : Db.Connection -> Crypto.SecureContext -> Array Test tests db secureContext = let - getUser : Task Db.Error User - getUser = - User.findOrCreate db Email.example - countUserSessions : User -> Task Db.Error Int countUserSessions user = Db.getOne db @@ -28,16 +28,99 @@ tests db secureContext = , decoder = Db.Decode.int "count" } in - [ describe "Session.create" - [ await "Create test user" getUser <| \user -> - await "Get initial session count" (countUserSessions user) <| \countBeforeCreate -> - await "Create session" (Session.create { db = db, user = user, secureContext = secureContext }) <| \session -> - await "Get new session count" (countUserSessions user) <| \countAfterCreate -> - concat - [ test "Session is created" <| \_ -> - Expect.equal (countBeforeCreate + 1) countAfterCreate - , test "Session is created for user" <| \_ -> - Expect.equal user session.user + [ await "Create test user" (User.findOrCreate db Email.example) <| \user -> + await "Create test session" (Session.create { db = db, user = user, secureContext = secureContext }) <| \session -> + concat + [ describe "Session.create" + [ await "Get initial session count" (countUserSessions user) <| \countBeforeCreate -> + await "Create another session" (Session.create { db = db, user = user, secureContext = secureContext }) <| \newSession -> + await "Get new session count" (countUserSessions user) <| \countAfterCreate -> + concat + [ test "Session is created" <| \_ -> + Expect.equal (countBeforeCreate + 1) countAfterCreate + , test "Session is created for user" <| \_ -> + Expect.equal user newSession.user + , test "Session has emailConfirmationToken" <| \_ -> + Expect.equal True (String.count newSession.emailConfirmationToken > 1) + , test "Session has fetchSessionToken" <| \_ -> + Expect.equal True (String.count newSession.fetchSessionToken > 1) + , test "Session does not have confirmationCode" <| \_ -> + Expect.equal Nothing session.confirmationCode + ] + ] + + , describe "Session.getWithEmailConfirmationToken" + [ awaitError "with incorrect token" (Session.getWithEmailConfirmationToken db "oops") <| \error -> + test "results in db error" <| \_ -> + expectNoResultError error + , await "with correct token" (Session.getWithEmailConfirmationToken db session.emailConfirmationToken) <| \result -> + test "returns session" <| \_ -> + Expect.equal session result + ] + + , describe "Session.setConfirmationCode" <| + let + sessionWithCode = + Session.create { db = db, user = user, secureContext = secureContext } + |> Task.andThen (Session.setConfirmationCode db) + + createSessionsWithCodes n = + Array.initialize n 0 (\_ -> sessionWithCode) + |> Task.sequence + in + [ await "create session" (Session.create { db = db, user = user, secureContext = secureContext }) <| \newSession -> + await "set confirmation code" (Session.setConfirmationCode db newSession) <| \updatedSession -> + test "sets valid confirmation code" <| \_ -> + when updatedSession.confirmationCode is + Just code -> + Expect.equal 8 (String.count code) + + Nothing -> + Expect.fail "Expected confirmation code to exist" + + , await "create 100 sessions with codes" (createSessionsWithCodes 100) <| \sessions -> + let + codes = + sessions + |> Array.map .confirmationCode + |> Array.map (Maybe.withDefault "") + + ambiguousChars = + [ "O", "1", "I", "L" ] + in + concat + [ test "all codes are unique" <| \_ -> + codes + |> Set.fromArray + |> Set.toArray + |> Array.length + |> Expect.equal 100 + + , fuzz2 (Fuzz.oneOfValues codes) (Fuzz.oneOfValues ambiguousChars) "does not contain ambiguous characters" <| \code ambiguousChar -> + if String.contains ambiguousChar code then + Expect.fail ("Code contains ambiguous character: " ++ ambiguousChar) + else + Expect.pass + ] + ] + + , describe "Session.emailConfirmationExpired" + [ await "get current time" Time.now <| \now -> + await "create session" (Session.create { db = db, user = user, secureContext = secureContext }) <| \freshSession -> + concat + [ test "fresh session email confirmation not expired" <| \_ -> + Expect.equal False (Session.emailConfirmationExpired now freshSession) + + , test "old session email confirmation is expired" <| \_ -> + let + future = + now + |> Time.posixToMillis + |> (\m -> m + (31 * 60 * 1000)) + |> Time.millisToPosix + in + Expect.equal True (Session.emailConfirmationExpired future freshSession) + ] ] - ] - ] + ] + ] From c722c5d113ee265066de9f0cb617d02fdd890ad7 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Fri, 13 Feb 2026 13:05:43 -0500 Subject: [PATCH 3/7] update auth TODO list in README --- README.md | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 2a0c1bc..565afd6 100644 --- a/README.md +++ b/README.md @@ -72,18 +72,12 @@ Goals: Auth flow: -1. CLI posts email address to the server (IN PROGRESS) - 1. [X] server finds or creates row in `user` table - 2. [X] server creates new row in `session` table with: - - `email_confirmation_token` for user to confirm their email address and get a `confirmation_code` - - `fetch_session_token` for fetching the session (once they have a `confirmation_code`) - 4. [ ] server emails confirmation link with `email_confirmation_token` (postmark) - 5. [ ] server returns fetch session link with `fetch_session_token` -2. User follows email confirmation link to get a confirmation code. -3. User enters confirmation code on the CLI, where it was prompting/waiting for it. -4. CLI posts to the fetch link with the confirmation code. -5. Server creates and returns a session token. -6. CLI saves token on disk to use for authenticated requests. +1. [X] CLI posts email address to the server. Server emails confirmation link. +2. [X] User follows email confirmation link to get a confirmation code. +3. [ ] User enters confirmation code on the CLI, where it was prompting/waiting for it. +4. [ ] CLI posts to the fetch link with the confirmation code. +5. [ ] Server creates and returns a session token. +6. [ ] CLI saves token on disk to use for authenticated requests. Benefits: From fe300271027749f4b99e7116c88ea92bf95ffaee Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Fri, 13 Feb 2026 13:08:21 -0500 Subject: [PATCH 4/7] fix typo --- src/Route/Session.gren | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Route/Session.gren b/src/Route/Session.gren index d4c7558..08e0278 100644 --- a/src/Route/Session.gren +++ b/src/Route/Session.gren @@ -69,7 +69,7 @@ confirmEmail : -> Task Never Response confirmEmail { db, requestData, response } = let - handleConfirmatoinCode session = + handleConfirmationCode session = Time.now |> Task.andThen (\now -> if Session.emailConfirmationExpired now session then @@ -102,7 +102,7 @@ confirmEmail { db, requestData, response } = in Session.getWithEmailConfirmationToken db requestData.token |> Task.onError handleDbError - |> Task.andThen handleConfirmatoinCode + |> Task.andThen handleConfirmationCode |> Task.andThen (\code -> confirmEmailSuccess response code) |> Task.onError (Route.Error.invalidRequestData response) From 86c8db852ababca19fad96fd657f6a5f5934122f Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Fri, 13 Feb 2026 13:09:25 -0500 Subject: [PATCH 5/7] more descriptive function name --- src/Route/Session.gren | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Route/Session.gren b/src/Route/Session.gren index 08e0278..c5ef13c 100644 --- a/src/Route/Session.gren +++ b/src/Route/Session.gren @@ -69,7 +69,7 @@ confirmEmail : -> Task Never Response confirmEmail { db, requestData, response } = let - handleConfirmationCode session = + generateConfirmationCode session = Time.now |> Task.andThen (\now -> if Session.emailConfirmationExpired now session then @@ -102,7 +102,7 @@ confirmEmail { db, requestData, response } = in Session.getWithEmailConfirmationToken db requestData.token |> Task.onError handleDbError - |> Task.andThen handleConfirmationCode + |> Task.andThen generateConfirmationCode |> Task.andThen (\code -> confirmEmailSuccess response code) |> Task.onError (Route.Error.invalidRequestData response) From 3f74459df470bb5d904635df50d9de495ec34034 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Fri, 13 Feb 2026 13:15:21 -0500 Subject: [PATCH 6/7] remove unused test helper --- src/Test/E2E/Helper.gren | 13 ------------- src/Test/E2E/Route/Session.gren | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/Test/E2E/Helper.gren b/src/Test/E2E/Helper.gren index 5985d54..9aab2cc 100644 --- a/src/Test/E2E/Helper.gren +++ b/src/Test/E2E/Helper.gren @@ -4,7 +4,6 @@ module Test.E2E.Helper exposing , expectBadStatus , expectJson , expectNoResultError - , expectStringContains , initDb , get , post @@ -129,15 +128,3 @@ createSession { db, secureContext } = , user = user } ) - - --- STRINGS - -expectStringContains : String -> String -> Expectation -expectStringContains expected actual = - if String.contains expected actual then - Expect.pass - else - "Expected \"" ++ actual ++ "\" to contain \"" ++ expected ++ "\"" - |> Expect.fail - diff --git a/src/Test/E2E/Route/Session.gren b/src/Test/E2E/Route/Session.gren index d5ae636..36ebe13 100644 --- a/src/Test/E2E/Route/Session.gren +++ b/src/Test/E2E/Route/Session.gren @@ -16,7 +16,7 @@ import Session exposing (Session) import String import Task exposing (Task) import Test.Runner.Effectful exposing (Test, await, awaitError, concat, describe, test) -import Test.E2E.Helper as Helper exposing (initDb, get, post, postWithJson, expectBadStatus, expectJson, expectStringContains) +import Test.E2E.Helper as Helper exposing (initDb, get, post, postWithJson, expectBadStatus, expectJson) import Time import User exposing (User) From 971ba20e0b3f8807d623b79b35503c92daf3ea38 Mon Sep 17 00:00:00 2001 From: Justin Blake Date: Fri, 13 Feb 2026 13:18:14 -0500 Subject: [PATCH 7/7] test cleanup --- src/Test/E2E/Route/Session.gren | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Test/E2E/Route/Session.gren b/src/Test/E2E/Route/Session.gren index 36ebe13..1487d75 100644 --- a/src/Test/E2E/Route/Session.gren +++ b/src/Test/E2E/Route/Session.gren @@ -110,6 +110,9 @@ tests httpPerm secureContext = |> HttpClient.expectBytes |> HttpClient.send httpPerm ) + + reloadSessionFromDb session = + Session.getWithEmailConfirmationToken db session.emailConfirmationToken in [ awaitError "GET with missing token" (httpGet Nothing) <| \responseError -> test "is client error" <| \_ -> @@ -129,7 +132,7 @@ tests httpPerm secureContext = [ test "is success" <| \_ -> Expect.equal 200 response.statusCode - , await "get updated session" (Session.getWithEmailConfirmationToken db session.emailConfirmationToken) <| \updatedSession -> + , await "get updated session" (reloadSessionFromDb session) <| \updatedSession -> test "responds with confirmation code" <| \_ -> when Helper.decodeJson codeDecoder response.data is Just code ->