From 9b310cf706422bd1671fac39003a410059602dc2 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 22 Apr 2024 12:53:55 +0200 Subject: [PATCH 01/32] Initial commit --- GoTrue/build.gradle.kts | 1 + .../io/github/jan/supabase/gotrue/PKCE.kt | 2 +- .../providers/builtin/DefaultAuthProvider.kt | 3 +- .../supabase/gotrue/providers/builtin/OTP.kt | 5 +- .../jan/supabase/gotrue/user/UserSession.kt | 2 +- GoTrue/src/commonTest/kotlin/AuthApiMock.kt | 206 -------- GoTrue/src/commonTest/kotlin/AuthTest.kt | 491 +++++++++--------- 7 files changed, 266 insertions(+), 444 deletions(-) delete mode 100644 GoTrue/src/commonTest/kotlin/AuthApiMock.kt diff --git a/GoTrue/build.gradle.kts b/GoTrue/build.gradle.kts index 1a215644..24cae48f 100644 --- a/GoTrue/build.gradle.kts +++ b/GoTrue/build.gradle.kts @@ -75,6 +75,7 @@ kotlin { val commonTest by getting { dependencies { implementation(libs.bundles.testing) + implementation(project(":test-common")) } } val jvmMain by getting { diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/PKCE.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/PKCE.kt index 446b49a5..3ef700bd 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/PKCE.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/PKCE.kt @@ -8,7 +8,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi internal object PKCEConstants { const val VERIFIER_LENGTH = 64 - const val CHALLENGE_METHOD = "S256" + const val CHALLENGE_METHOD = "s256" } @OptIn(ExperimentalEncodingApi::class) diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/DefaultAuthProvider.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/DefaultAuthProvider.kt index 7267f22a..b6a6fd46 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/DefaultAuthProvider.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/DefaultAuthProvider.kt @@ -5,6 +5,7 @@ import io.github.jan.supabase.annotations.SupabaseExperimental import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.gotrue.AuthImpl import io.github.jan.supabase.gotrue.FlowType +import io.github.jan.supabase.gotrue.PKCEConstants import io.github.jan.supabase.gotrue.auth import io.github.jan.supabase.gotrue.generateCodeChallenge import io.github.jan.supabase.gotrue.generateCodeVerifier @@ -90,7 +91,7 @@ sealed interface DefaultAuthProvider : AuthProvider { putJsonObject(body) codeChallenge?.let { put("code_challenge", it) - put("code_challenge_method", "s256") + put("code_challenge_method", PKCEConstants.CHALLENGE_METHOD) } }) { redirectUrl?.let { redirectTo(it) } diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/OTP.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/OTP.kt index b8d9c74c..5d63a545 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/OTP.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/OTP.kt @@ -10,6 +10,7 @@ import io.github.jan.supabase.gotrue.auth import io.github.jan.supabase.gotrue.generateCodeChallenge import io.github.jan.supabase.gotrue.generateCodeVerifier import io.github.jan.supabase.gotrue.providers.AuthProvider +import io.github.jan.supabase.gotrue.putCaptchaToken import io.github.jan.supabase.gotrue.user.UserSession import io.github.jan.supabase.putJsonObject import kotlinx.serialization.json.JsonObject @@ -40,6 +41,7 @@ data object OTP: AuthProvider { var phone: String? = null, var data: JsonObject? = null, var createUser: Boolean = true, + var captchaToken: String? = null ) { /** @@ -87,6 +89,7 @@ data object OTP: AuthProvider { put("code_challenge", it) put("code_challenge_method", "s256") } + otpConfig.captchaToken?.let { putCaptchaToken(it) } }) { redirectUrl?.let { url.parameters.append("redirect_to", it) } } @@ -97,6 +100,6 @@ data object OTP: AuthProvider { onSuccess: suspend (UserSession) -> Unit, redirectUrl: String?, config: (Config.() -> Unit)? - ): Unit? = login(supabaseClient, onSuccess, redirectUrl, config) + ): Unit = login(supabaseClient, onSuccess, redirectUrl, config) } \ No newline at end of file diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/user/UserSession.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/user/UserSession.kt index 939dfebd..33fb742f 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/user/UserSession.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/user/UserSession.kt @@ -21,7 +21,7 @@ data class UserSession( val expiresIn: Long, @SerialName("token_type") val tokenType: String, - val user: UserInfo?, + val user: UserInfo? = null, @SerialName("type") val type: String = "", val expiresAt: Instant = Clock.System.now() + (expiresIn.seconds), diff --git a/GoTrue/src/commonTest/kotlin/AuthApiMock.kt b/GoTrue/src/commonTest/kotlin/AuthApiMock.kt deleted file mode 100644 index bc27399b..00000000 --- a/GoTrue/src/commonTest/kotlin/AuthApiMock.kt +++ /dev/null @@ -1,206 +0,0 @@ -import io.github.jan.supabase.gotrue.user.UserInfo -import io.github.jan.supabase.gotrue.user.UserSession -import io.ktor.client.engine.mock.MockEngine -import io.ktor.client.engine.mock.MockRequestHandleScope -import io.ktor.client.engine.mock.respond -import io.ktor.client.engine.mock.respondOk -import io.ktor.client.engine.mock.toByteArray -import io.ktor.client.request.HttpRequestData -import io.ktor.client.request.HttpResponseData -import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod -import io.ktor.http.HttpStatusCode -import io.ktor.http.headersOf -import kotlinx.datetime.Clock -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put - -class AuthApiMock { - - val engine = MockEngine { - handleRequest(it) ?: respondInternalError("Invalid route") - } - - private suspend fun MockRequestHandleScope.handleRequest(request: HttpRequestData): HttpResponseData? { - val url = request.url - val urlWithoutQuery = url.encodedPath - return when { - urlWithoutQuery.endsWith("token") -> handleLogin(request) - urlWithoutQuery.endsWith("signup") -> handleSignUp(request) - urlWithoutQuery.endsWith("user") -> handleUserRequest(request) - urlWithoutQuery.endsWith("verify") -> handleVerify(request) - urlWithoutQuery.endsWith("otp") -> handleOtp(request) - urlWithoutQuery.endsWith("recover") -> handleRecovery(request) - urlWithoutQuery.endsWith("logout") -> handleLogout(request) - else -> null - } - } - - private suspend fun MockRequestHandleScope.handleLogout(request: HttpRequestData): HttpResponseData { - if(request.method != HttpMethod.Post) return respondBadRequest("Invalid method") - if(!request.headers.contains("Authorization")) return respondBadRequest("access token missing") - return respondOk() - } - - private suspend fun MockRequestHandleScope.handleRecovery(request: HttpRequestData): HttpResponseData { - if(request.method != HttpMethod.Post) respondBadRequest("Invalid method") - val body = try { - request.decodeJsonObject() - } catch(e: Exception) { - return respondBadRequest("Invalid body") - } - if(!body.containsKey("email")) return respondBadRequest("Email missing") - return respondOk() - } - - private suspend fun MockRequestHandleScope.handleOtp(request: HttpRequestData): HttpResponseData { - if(request.method != HttpMethod.Post) respondBadRequest("Invalid method") - val body = try { - request.decodeJsonObject() - } catch(e: Exception) { - return respondBadRequest("Invalid body") - } - if(!body.containsKey("create_user")) return respondBadRequest("create_user missing") - if(!body.containsKey("phone") && !body.containsKey("email")) return respondBadRequest("email or phone missing") - return respondOk("{}") - } - - private suspend fun MockRequestHandleScope.handleVerify(request: HttpRequestData): HttpResponseData { - if(request.method != HttpMethod.Post) return respondBadRequest("Invalid method") - val body = try { - request.decodeJsonObject() - } catch(e: Exception) { - return respondBadRequest("Invalid body") - } - if(!body.containsKey("token")) return respondBadRequest("token missing") - if(!body.containsKey("type")) return respondBadRequest("type missing") - val token = body["token"]!!.jsonPrimitive.content - if(token != VALID_VERIFY_TOKEN) return respondBadRequest("Failed to verify user") - return when(body["type"]!!.jsonPrimitive.content) { - in listOf("invite", "signup", "recovery") -> respondValidSession() - "sms" -> { - body["phone"]?.jsonPrimitive?.contentOrNull ?: return respondBadRequest("Missing parameter: phone_number") - respondValidSession() - } - else -> respondBadRequest("Invalid type") - } - } - - private suspend fun MockRequestHandleScope.handleUserRequest(request: HttpRequestData): HttpResponseData { - if(!request.headers.contains(HttpHeaders.Authorization)) return respondUnauthorized() - val authorizationHeader = request.headers[HttpHeaders.Authorization]!! - if(!authorizationHeader.startsWith("Bearer ")) return respondUnauthorized() - val token = authorizationHeader.substringAfter("Bearer ") - if(token != VALID_ACCESS_TOKEN) return respondUnauthorized() - return when(request.method) { - HttpMethod.Get -> respond(UserInfo(aud = "", id = "userid")) - HttpMethod.Put -> { - respond(UserInfo(aud = "", id = "userid", email = "old_email@email.com", emailChangeSentAt = Clock.System.now())) - } - else -> return respondBadRequest("Invalid method") - } - } - - private suspend fun MockRequestHandleScope.handleSignUp(request: HttpRequestData): HttpResponseData { - if(request.method != HttpMethod.Post) respondBadRequest("Invalid method") - val body = try { - request.decodeJsonObject() - } catch(e: Exception) { - return respondBadRequest("Invalid body") - } - - return when { - body.containsKey("email") -> { - respond(UserInfo(id ="uuid", email = body["email"]!!.jsonPrimitive.content, aud = "")) - } - body.containsKey("phone") -> { - respond(UserInfo(id ="uuid", phone = body["phone"]!!.jsonPrimitive.content, aud = "")) - } - !body.containsKey("password") -> respondBadRequest("Missing password") - else -> respondBadRequest("Missing email or phone") - } - } - - private suspend fun MockRequestHandleScope.handleLogin(request: HttpRequestData): HttpResponseData { - if(request.method != HttpMethod.Post) respondBadRequest("Invalid method") - if(!request.url.parameters.contains("grant_type")) return respondBadRequest("grant_type is required") - return when(request.url.parameters["grant_type"]) { - "refresh_token" -> { - val body = try { - request.decodeJsonObject() - } catch(e: Exception) { - return respondBadRequest("Invalid body") - } - if(!body.containsKey("refresh_token")) return respondBadRequest("refresh_token is required") - val refreshToken = body["refresh_token"]!!.jsonPrimitive.content - if(refreshToken != VALID_REFRESH_TOKEN) return respondBadRequest("Invalid refresh token") - respondValidSession() - } - "password" -> { - val body = try { - request.decodeJsonObject() - } catch(e: Exception) { - return respondBadRequest("Invalid body") - } - if(!body.containsKey("password")) return respondBadRequest("password is required") - val password = body["password"]?.jsonPrimitive?.contentOrNull ?: "" - return when { - body.containsKey("email") -> { - if(password != VALID_PASSWORD) return respondBadRequest("Invalid password") - respondValidSession() - } - body.containsKey("phone") -> { - if(password != VALID_PASSWORD) return respondBadRequest("Invalid password") - respondValidSession() - } - else -> respondBadRequest("email or phone is required") - } - } - else ->respondBadRequest("grant_type must be password") - } - } - - private inline fun MockRequestHandleScope.respond(data: T): HttpResponseData { - return respond(Json.encodeToString(data), HttpStatusCode.OK, headersOf("Content-Type" to listOf("application/json"))) - } - - private fun MockRequestHandleScope.respondValidSession() = respond(UserSession( - NEW_ACCESS_TOKEN, - "refresh_token", - "", - "", - 200, - "token_type", - UserInfo(aud = "", id = "") - )) - - private fun MockRequestHandleScope.respondInternalError(message: String): HttpResponseData { - return respond(message, HttpStatusCode.InternalServerError) - } - - private fun MockRequestHandleScope.respondBadRequest(message: String): HttpResponseData { - return respond(buildJsonObject { - put("error", message) - }.toString(), HttpStatusCode.BadRequest) - } - - private fun MockRequestHandleScope.respondUnauthorized(): HttpResponseData { - return respond("Unauthorized", HttpStatusCode.Unauthorized) - } - - private suspend inline fun HttpRequestData.decodeJsonObject() = Json.decodeFromString(body.toByteArray().decodeToString()) - - companion object { - const val VALID_PASSWORD = "password" - const val VALID_REFRESH_TOKEN = "valid_refresh_token" - const val NEW_ACCESS_TOKEN = "new_access_token" - const val VALID_ACCESS_TOKEN = "valid_access_token" - const val VALID_VERIFY_TOKEN = "valid_verify_token" - } - -} \ No newline at end of file diff --git a/GoTrue/src/commonTest/kotlin/AuthTest.kt b/GoTrue/src/commonTest/kotlin/AuthTest.kt index c301a401..3535ee07 100644 --- a/GoTrue/src/commonTest/kotlin/AuthTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthTest.kt @@ -1,293 +1,316 @@ -import io.github.jan.supabase.SupabaseClient -import io.github.jan.supabase.createSupabaseClient -import io.github.jan.supabase.exceptions.BadRequestRestException -import io.github.jan.supabase.exceptions.UnauthorizedRestException +import io.github.jan.supabase.SupabaseClientBuilder import io.github.jan.supabase.gotrue.Auth import io.github.jan.supabase.gotrue.AuthConfig -import io.github.jan.supabase.gotrue.MemoryCodeVerifierCache -import io.github.jan.supabase.gotrue.MemorySessionManager -import io.github.jan.supabase.gotrue.OtpType +import io.github.jan.supabase.gotrue.FlowType +import io.github.jan.supabase.gotrue.PKCEConstants import io.github.jan.supabase.gotrue.SessionSource import io.github.jan.supabase.gotrue.SessionStatus import io.github.jan.supabase.gotrue.auth -import io.github.jan.supabase.gotrue.providers.Github +import io.github.jan.supabase.gotrue.minimalSettings +import io.github.jan.supabase.gotrue.providers.Google import io.github.jan.supabase.gotrue.providers.builtin.Email +import io.github.jan.supabase.gotrue.providers.builtin.IDToken import io.github.jan.supabase.gotrue.providers.builtin.OTP -import io.github.jan.supabase.gotrue.user.UserSession -import kotlinx.coroutines.test.StandardTestDispatcher +import io.github.jan.supabase.gotrue.providers.builtin.Phone +import io.github.jan.supabase.testing.assertMethodIs +import io.github.jan.supabase.testing.assertPathIs +import io.github.jan.supabase.testing.createMockedSupabaseClient +import io.github.jan.supabase.testing.pathAfterVersion +import io.github.jan.supabase.testing.toJsonElement +import io.ktor.client.engine.mock.respond +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.headersOf import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull -import kotlin.test.assertTrue -class GoTrueTest { +class AuthTest { - private val mockEngine = AuthApiMock().engine - - private val dispatcher = StandardTestDispatcher() - - @Test - fun test_login_with_email_with_wrong_credentials() { - val client = createSupabaseClient() - runTest(dispatcher) { - assertFailsWith("Login with email and wrong password should fail") { - client.auth.signInWith(Email, "https://website.com") { - email = "email@example.com" - password = "wrong_password" - } - } - client.close() + private val configuration: SupabaseClientBuilder.() -> Unit = { + install(Auth) { + minimalSettings() + flowType = FlowType.PKCE } } @Test - fun test_login_with_email_with_correct_credentials() { - val client = createSupabaseClient() - runTest(dispatcher) { - client.auth.signInWith(Email) { - email = "email@example.com" - password = AuthApiMock.VALID_PASSWORD + fun testSignUpWithEmailNoAutoconfirm() { + runTest { + val expectedEmail = "example@email.com" + val expectedPassword = "password" + val captchaToken = "captchaToken" + val userData = buildJsonObject { + put("key", "value") } - assertEquals(AuthApiMock.NEW_ACCESS_TOKEN, client.auth.currentAccessTokenOrNull()) - assertIs(client.auth.sessionSource()) - assertIs(client.auth.sessionSourceAs().provider) - client.close() - } - } - - @Test - fun test_sign_up_with_email() { - val client = createSupabaseClient() - runTest(dispatcher) { - val result = client.auth.signUpWith(Email) { - email = "email@example.com" - password = AuthApiMock.VALID_PASSWORD - } ?: error("Sign up with email should not return null") - assertEquals("email@example.com", result.email) - client.close() - } - } - - @Test - fun test_import_jwt_token() { - val client = createSupabaseClient() - runTest(dispatcher) { - client.auth.importAuthToken("some_token") - assertEquals("some_token", client.auth.currentAccessTokenOrNull()) - client.close() - } - } - - @Test - fun test_import_session_and_invalidate_session() { - val client = createSupabaseClient() - runTest(dispatcher) { - val session = - UserSession("some_token", "some_refresh_token", "", "", 20, "token_type", null) - client.auth.importSession(session, false, source = SessionSource.External) - assertIs(client.auth.sessionSource()) - assertEquals("some_token", client.auth.currentAccessTokenOrNull()) - assertEquals("some_refresh_token", client.auth.currentSessionOrNull()!!.refreshToken) - client.auth.signOut() - assertNull(client.auth.currentAccessTokenOrNull()) - assertIs(client.auth.sessionStatus.value) - assertTrue { - (client.auth.sessionStatus.value as SessionStatus.NotAuthenticated).isSignOut + val expectedUrl = "https://example.com" + val client = createMockedSupabaseClient(configuration = configuration) { + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + val params = it.url.parameters + assertEquals(expectedUrl, params["redirect_to"]) + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/signup", it.url.pathAfterVersion()) + assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) + assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content) + assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(userData, body["data"]!!.jsonObject) + containsCodeChallenge(body) + respond( + sampleUserObject(email = expectedEmail), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) } - client.close() - } - } - - @Test - fun test_auto_refresh_with_wrong_token() { - val client = createSupabaseClient { - alwaysAutoRefresh = true - } - runTest(dispatcher) { - val session = - UserSession("old_token", "some_refresh_token", "", "", 0, "token_type", null) - client.auth.importSession(session, true) - assertNull(client.auth.currentAccessTokenOrNull(), null) - client.close() - } - } - - @Test - fun test_auto_refresh_with_correct_token() { - val client = createSupabaseClient() - runTest(dispatcher) { - val session = UserSession( - "old_token", - AuthApiMock.VALID_REFRESH_TOKEN, - "", - "", - 0, - "token_type", - null - ) - client.auth.importSession(session, true) - assertEquals(AuthApiMock.NEW_ACCESS_TOKEN, client.auth.currentAccessTokenOrNull()) - assertIs(client.auth.sessionSource()) - client.close() + val user = client.auth.signUpWith(Email, redirectUrl = expectedUrl) { + email = expectedEmail + password = expectedPassword + this.captchaToken = captchaToken + data = userData + } + assertEquals(expectedEmail, user?.email, "Email should be equal") } } @Test - fun test_loading_session_from_storage() { - val client = createSupabaseClient { - sessionManager = MemorySessionManager( - UserSession( - "token", - "refresh_token", - "", - "", - 1000, - "type", - null - ) - ) - } + fun testSignUpWithEmailAutoconfirm() { runTest { - assertIs(client.auth.sessionStatus.value) - client.auth.loadFromStorage() - assertIs(client.auth.sessionSource()) - assertNotNull(client.auth.currentSessionOrNull()) - client.close() - } - } - - @Test - fun test_requesting_user_with_invalid_token() { - val client = createSupabaseClient() - runTest(dispatcher) { - assertFailsWith("Requesting user with invalid token should fail") { - client.auth.retrieveUser("invalid_token") + val expectedEmail = "example@email.com" + val expectedPassword = "password" + val captchaToken = "captchaToken" + val userData = buildJsonObject { + put("key", "value") } - client.close() - } - } - - @Test - fun test_requesting_user_with_valid_token() { - val client = createSupabaseClient() - runTest(dispatcher) { - val user = client.auth.retrieveUser(AuthApiMock.VALID_ACCESS_TOKEN) - assertEquals("userid", user.id) - client.close() + val client = createMockedSupabaseClient(configuration = configuration) { + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/signup", it.url.pathAfterVersion()) + assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) + assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content) + assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(userData, body["data"]!!.jsonObject) + containsCodeChallenge(body) + respond( + sampleUserSession(), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + val user = client.auth.signUpWith(Email) { + email = expectedEmail + password = expectedPassword + this.captchaToken = captchaToken + data = userData + } + assertNull(user) + assertNotNull(client.auth.currentSessionOrNull(), "Session should not be null") + assertEquals(client.auth.sessionSource(), SessionSource.SignUp(Email)) } } @Test - fun test_verifying_with_wrong_token() { - val client = createSupabaseClient() - runTest(dispatcher) { - assertFailsWith("verifying with a wrong token should fail") { - client.auth.verifyEmailOtp( - OtpType.Email.INVITE, - "example@email.com", - "wrong_token" + fun testSignUpWithPhoneAutoconfirm() { + runTest { + val expectedPhone = "+1234567890" + val expectedPassword = "password" + val captchaToken = "captchaToken" + val userData = buildJsonObject { + put("key", "value") + } + val client = createMockedSupabaseClient(configuration = configuration) { + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/signup", it.url.pathAfterVersion()) + assertEquals(expectedPhone, body["phone"]?.jsonPrimitive?.content) + assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content) + assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(userData, body["data"]!!.jsonObject) + containsCodeChallenge(body) + respond( + sampleUserSession(), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) ) } - client.close() + val user = client.auth.signUpWith(Phone) { + phone = expectedPhone + password = expectedPassword + this.captchaToken = captchaToken + data = userData + } + assertNull(user) + assertNotNull(client.auth.currentSessionOrNull(), "Session should not be null") + assertEquals(client.auth.sessionSource(), SessionSource.SignUp(Phone)) } } @Test - fun test_verifying_with_valid_token() { - val client = createSupabaseClient() - runTest(dispatcher) { - client.auth.verifyEmailOtp( - OtpType.Email.INVITE, - "example@gmail.com", - AuthApiMock.VALID_VERIFY_TOKEN - ) - assertEquals( - AuthApiMock.NEW_ACCESS_TOKEN, - client.auth.currentAccessTokenOrNull(), - "verify with valid token should update the user session" - ) - assertIs(client.auth.sessionSource()) - assertIs(client.auth.sessionSourceAs().provider) + fun testSignUpWithPhoneNoAutoconfirm() { + runTest { + val expectedPhone = "+1234567890" + val expectedPassword = "password" + val captchaToken = "captchaToken" + val userData = buildJsonObject { + put("key", "value") + } + val expectedUrl = "https://example.com" + val client = createMockedSupabaseClient(configuration = configuration) { + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + val params = it.url.parameters + assertEquals(expectedUrl, params["redirect_to"]) + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/signup", it.url.pathAfterVersion()) + assertEquals(expectedPhone, body["phone"]?.jsonPrimitive?.content) + assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content) + assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(userData, body["data"]!!.jsonObject) + containsCodeChallenge(body) + respond( + sampleUserObject(phone = expectedPhone), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + val user = client.auth.signUpWith(Phone, redirectUrl = expectedUrl) { + phone = expectedPhone + password = expectedPassword + this.captchaToken = captchaToken + data = userData + } + assertEquals(expectedPhone, user?.phone, "Phone should be equal") } } @Test - fun test_custom_url() { - val client = createSupabaseClient { - customUrl = "https://custom.auth.com" - } - runTest(dispatcher) { - assertEquals("https://custom.auth.com/signup", client.auth.resolveUrl("signup")) - client.close() + fun testSignUpOtpWithPhone() { + runTest { + val expectedPhone = "+1234567890" + val captchaToken = "captchaToken" + val userData = buildJsonObject { + put("key", "value") + } + val expectedUrl = "https://example.com" + val client = createMockedSupabaseClient(configuration = configuration) { + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + val params = it.url.parameters + assertEquals(expectedUrl, params["redirect_to"]) + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/otp", it.url.pathAfterVersion()) + assertEquals(expectedPhone, body["phone"]?.jsonPrimitive?.content) + assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(userData, body["data"]!!.jsonObject) + containsCodeChallenge(body) + respond("") + } + client.auth.signUpWith(OTP, redirectUrl = expectedUrl) { + phone = expectedPhone + this.captchaToken = captchaToken + data = userData + } } } @Test - fun test_otp_email() { - val client = createSupabaseClient() - runTest(dispatcher) { - client.auth.signInWith(OTP) { - email = "example@email.com" + fun testSignUpOtpWithEmail() { + runTest { + val expectedEmail = "example@email.com" + val captchaToken = "captchaToken" + val userData = buildJsonObject { + put("key", "value") + } + val expectedUrl = "https://example.com" + val client = createMockedSupabaseClient(configuration = configuration) { + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + val params = it.url.parameters + assertEquals(expectedUrl, params["redirect_to"]) + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/otp", it.url.pathAfterVersion()) + assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) + assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(userData, body["data"]!!.jsonObject) + containsCodeChallenge(body) + respond("") + } + client.auth.signUpWith(OTP, redirectUrl = expectedUrl) { + email = expectedEmail + this.captchaToken = captchaToken + data = userData } - client.close() } } @Test - fun test_otp_phone() { - val client = createSupabaseClient() - runTest(dispatcher) { - client.auth.signInWith(OTP) { - phone = "12345678" + fun testSignInWithIDToken() { + runTest { + val captchaToken = "captchaToken" + val userData = buildJsonObject { + put("key", "value") } - client.close() + val expectedIdToken = "idToken" + val expectedProvider = Google + val expectedAccessToken = "accessToken" + val expectedNonce = "nonce" + val client = createMockedSupabaseClient(configuration = configuration) { + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + val params = it.url.parameters + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/token", it.url.pathAfterVersion()) + assertEquals("id_token", params["grant_type"]) + assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(userData, body["data"]!!.jsonObject) + assertEquals(expectedIdToken, body["id_token"]?.jsonPrimitive?.content) + assertEquals(expectedProvider.name, body["provider"]?.jsonPrimitive?.content) + assertEquals(expectedAccessToken, body["access_token"]?.jsonPrimitive?.content) + assertEquals(expectedNonce, body["nonce"]?.jsonPrimitive?.content) + containsCodeChallenge(body) + respond( + sampleUserSession(), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + client.auth.signInWith(IDToken) { + this.captchaToken = captchaToken + data = userData + this.idToken = expectedIdToken + provider = expectedProvider + this.nonce = expectedNonce + accessToken = expectedAccessToken + } + assertNotNull(client.auth.currentSessionOrNull(), "Session should not be null") } } - @Test - fun test_recovery() { - val client = createSupabaseClient() - runTest(dispatcher) { - client.auth.resetPasswordForEmail("example@email.com") - client.close() + private fun sampleUserObject(email: String? = null, phone: String? = null) = """ + { + "id": "id", + "aud": "aud", + "email": "$email", + "phone": "$phone" } - } + """.trimIndent() - @Test - fun test_oauth_url() { - val client = createSupabaseClient() - val expected = - "https://example.com/auth/v1/authorize?provider=github&redirect_to=https://example.com&scopes=test+test2&custom=value" - val actual = client.auth.getOAuthUrl(Github, "https://example.com") { - scopes.addAll(listOf("test", "test2")) - queryParams["custom"] = "value" + private fun sampleUserSession() = """ + { + "access_token": "token", + "refresh_token": "refresh", + "token_type": "bearer", + "expires_in": 3600 } - assertEquals(expected, actual) - } - - private fun createSupabaseClient(additionalGoTrueSettings: AuthConfig.() -> Unit = {}): SupabaseClient { - return createSupabaseClient( - supabaseUrl = "https://example.com", - supabaseKey = "example", - ) { - httpEngine = mockEngine + """.trimIndent() - install(Auth) { - autoLoadFromStorage = false - alwaysAutoRefresh = false - coroutineDispatcher = dispatcher - - sessionManager = MemorySessionManager() - codeVerifierCache = MemoryCodeVerifierCache() - - additionalGoTrueSettings() - platformSettings() - } - } + private fun containsCodeChallenge(body: JsonObject) { + assertNotNull(body["code_challenge"]) + assertEquals(PKCEConstants.CHALLENGE_METHOD, body["code_challenge_method"]?.jsonPrimitive?.content) } private fun Auth.sessionSource() = (sessionStatus.value as SessionStatus.Authenticated).source @@ -296,4 +319,4 @@ class GoTrueTest { } -expect fun AuthConfig.platformSettings() +expect fun AuthConfig.platformSettings() \ No newline at end of file From 7279005aead13b78c47f6a974deba6fcb5f866ae Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 23 Apr 2024 19:44:00 +0200 Subject: [PATCH 02/32] Fix IDToken test --- .../src/commonTest/kotlin/{AuthTest.kt => AuthRequestTest.kt} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename GoTrue/src/commonTest/kotlin/{AuthTest.kt => AuthRequestTest.kt} (99%) diff --git a/GoTrue/src/commonTest/kotlin/AuthTest.kt b/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt similarity index 99% rename from GoTrue/src/commonTest/kotlin/AuthTest.kt rename to GoTrue/src/commonTest/kotlin/AuthRequestTest.kt index 3535ee07..644fee48 100644 --- a/GoTrue/src/commonTest/kotlin/AuthTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt @@ -272,7 +272,6 @@ class AuthTest { assertEquals(expectedProvider.name, body["provider"]?.jsonPrimitive?.content) assertEquals(expectedAccessToken, body["access_token"]?.jsonPrimitive?.content) assertEquals(expectedNonce, body["nonce"]?.jsonPrimitive?.content) - containsCodeChallenge(body) respond( sampleUserSession(), headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) @@ -287,6 +286,7 @@ class AuthTest { accessToken = expectedAccessToken } assertNotNull(client.auth.currentSessionOrNull(), "Session should not be null") + assertEquals(client.auth.sessionSource(), SessionSource.SignIn(IDToken)) } } From 231c890d353d025a80072d69bb9b8f956d99635f Mon Sep 17 00:00:00 2001 From: Jan Date: Thu, 25 Apr 2024 18:24:42 +0200 Subject: [PATCH 03/32] add anonymous sign in and linking identities tests --- .../src/commonTest/kotlin/AuthRequestTest.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt b/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt index 644fee48..852ab328 100644 --- a/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt @@ -290,6 +290,63 @@ class AuthTest { } } + @Test + fun testSignInAnonymously() { + runTest { + val captchaToken = "captchaToken" + val userData = buildJsonObject { + put("key", "value") + } + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/signup", it.url.pathAfterVersion()) + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(userData, body["data"]!!.jsonObject) + respond( + sampleUserSession(), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + client.auth.signInAnonymously( + captchaToken = captchaToken, + data = userData + ) + assertNotNull(client.auth.currentSessionOrNull(), "Session should not be null") + assertEquals(client.auth.sessionSource(), SessionSource.AnonymousSignIn) + } + } + + @Test + fun testLinkIdentity() { + runTest { + val expectedProvider = Google + val expectedRedirectUrl = "https://example.com" + val expectedScopes = listOf("scope1", "scope2") + val expectedUrlParams = mapOf("key" to "value") + val client = createMockedSupabaseClient(configuration = configuration) { + val params = it.url.parameters + assertEquals(expectedRedirectUrl, params["redirect_to"]) + assertMethodIs(HttpMethod.Get, it.method) + assertPathIs("/user/identities/authorize", it.url.pathAfterVersion()) + assertEquals(expectedProvider.name, params["provider"]) + assertNotNull(params["code_challenge"]) + assertEquals(PKCEConstants.CHALLENGE_METHOD, params["code_challenge_method"]) + assertEquals(expectedScopes.joinToString(" "), params["scopes"]) + assertEquals("value", params["key"]) + respond( + sampleUserSession(), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + client.auth.linkIdentity(expectedProvider, redirectUrl = expectedRedirectUrl) { + scopes.addAll(expectedScopes) + queryParams.putAll(expectedUrlParams) + } + } + } + private fun sampleUserObject(email: String? = null, phone: String? = null) = """ { "id": "id", From 92284d316d90159d9cdb2015f8b8bf8932510390 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 28 Apr 2024 22:44:53 +0200 Subject: [PATCH 04/32] document captchaToken --- .../jan/supabase/gotrue/providers/builtin/OTP.kt | 2 +- GoTrue/src/commonTest/kotlin/AuthRequestTest.kt | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/OTP.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/OTP.kt index 5d63a545..eb434953 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/OTP.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/OTP.kt @@ -33,7 +33,7 @@ data object OTP: AuthProvider { * @param phone The phone number of the user * @param data Additional data to store with the user * @param createUser Whether to create a new user if the user doesn't exist - * + * @param captchaToken The captcha token for the request */ class Config( @PublishedApi internal val serializer: SupabaseSerializer, diff --git a/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt b/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt index 852ab328..62c4c01b 100644 --- a/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt @@ -340,9 +340,13 @@ class AuthTest { headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) ) } - client.auth.linkIdentity(expectedProvider, redirectUrl = expectedRedirectUrl) { - scopes.addAll(expectedScopes) - queryParams.putAll(expectedUrlParams) + try { + client.auth.linkIdentity(expectedProvider, redirectUrl = expectedRedirectUrl) { + scopes.addAll(expectedScopes) + queryParams.putAll(expectedUrlParams) + } + } catch(e: RuntimeException) { + // Ignore, throws an exception because it cannot open a browser } } } From d43a295a83435b4db9d5af58c01ba9d6f075a29e Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Tue, 30 Apr 2024 20:39:59 +0700 Subject: [PATCH 05/32] Check to avoid creating file with size 0 with empty data on Dashboard --- .../github/jan/supabase/storage/BucketApi.kt | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt index 91283ba5..785223a0 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt @@ -36,11 +36,15 @@ sealed interface BucketApi { * @param data The data to upload * @param upsert Whether to overwrite an existing file * @return the key to the uploaded file + * @throws IllegalArgumentException if data to upload is empty * @throws RestException or one of its subclasses if receiving an error response * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun upload(path: String, data: ByteArray, upsert: Boolean = false): String = upload(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + suspend fun upload(path: String, data: ByteArray, upsert: Boolean = false): String { + require(data.isNotEmpty()) { "The data to upload should not be empty" } + return upload(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + } /** * Uploads a file in [bucketId] under [path] @@ -62,7 +66,17 @@ sealed interface BucketApi { * @param upsert Whether to overwrite an existing file * @return the key of the uploaded file */ - suspend fun uploadToSignedUrl(path: String, token: String, data: ByteArray, upsert: Boolean = false): String = uploadToSignedUrl(path, token, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + suspend fun uploadToSignedUrl( + path: String, + token: String, + data: ByteArray, + upsert: Boolean = false + ): String = uploadToSignedUrl( + path, + token, + UploadData(ByteReadChannel(data), data.size.toLong()), + upsert + ) /** * Uploads a file in [bucketId] under [path] using a presigned url @@ -76,7 +90,12 @@ sealed interface BucketApi { * @throws HttpRequestException on network related issues * @throws HttpRequestException on network related issues */ - suspend fun uploadToSignedUrl(path: String, token: String, data: UploadData, upsert: Boolean = false): String + suspend fun uploadToSignedUrl( + path: String, + token: String, + data: UploadData, + upsert: Boolean = false + ): String /** * Updates a file in [bucketId] under [path] @@ -88,7 +107,8 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun update(path: String, data: ByteArray, upsert: Boolean = false): String = update(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + suspend fun update(path: String, data: ByteArray, upsert: Boolean = false): String = + update(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) /** * Updates a file in [bucketId] under [path] @@ -159,7 +179,11 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun createSignedUrl(path: String, expiresIn: Duration, transform: ImageTransformation.() -> Unit = {}): String + suspend fun createSignedUrl( + path: String, + expiresIn: Duration, + transform: ImageTransformation.() -> Unit = {} + ): String /** * Creates signed urls for all specified paths. The urls will expire after [expiresIn] @@ -181,7 +205,8 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun createSignedUrls(expiresIn: Duration, vararg paths: String) = createSignedUrls(expiresIn, paths.toList()) + suspend fun createSignedUrls(expiresIn: Duration, vararg paths: String) = + createSignedUrls(expiresIn, paths.toList()) /** * Downloads a file from [bucketId] under [path] @@ -192,7 +217,10 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun downloadAuthenticated(path: String, transform: ImageTransformation.() -> Unit = {}): ByteArray + suspend fun downloadAuthenticated( + path: String, + transform: ImageTransformation.() -> Unit = {} + ): ByteArray /** * Downloads a file from [bucketId] under [path] @@ -203,7 +231,11 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun downloadAuthenticated(path: String, channel: ByteWriteChannel, transform: ImageTransformation.() -> Unit = {}) + suspend fun downloadAuthenticated( + path: String, + channel: ByteWriteChannel, + transform: ImageTransformation.() -> Unit = {} + ) /** * Downloads a file from [bucketId] under [path] using the public url @@ -214,7 +246,10 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun downloadPublic(path: String, transform: ImageTransformation.() -> Unit = {}): ByteArray + suspend fun downloadPublic( + path: String, + transform: ImageTransformation.() -> Unit = {} + ): ByteArray /** * Downloads a file from [bucketId] under [path] using the public url @@ -225,7 +260,11 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun downloadPublic(path: String, channel: ByteWriteChannel, transform: ImageTransformation.() -> Unit = {}) + suspend fun downloadPublic( + path: String, + channel: ByteWriteChannel, + transform: ImageTransformation.() -> Unit = {} + ) /** @@ -235,7 +274,10 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun list(prefix: String = "", filter: BucketListFilter.() -> Unit = {}): List + suspend fun list( + prefix: String = "", + filter: BucketListFilter.() -> Unit = {} + ): List /** * Changes the bucket's public status to [public] @@ -300,6 +342,8 @@ sealed interface BucketApi { */ fun BucketApi.authenticatedRequest(path: String): Pair { val url = authenticatedUrl(path) - val token = supabaseClient.storage.config.jwtToken ?: supabaseClient.pluginManager.getPluginOrNull(Auth)?.currentAccessTokenOrNull() ?: supabaseClient.supabaseKey + val token = + supabaseClient.storage.config.jwtToken ?: supabaseClient.pluginManager.getPluginOrNull(Auth) + ?.currentAccessTokenOrNull() ?: supabaseClient.supabaseKey return token to url } \ No newline at end of file From 7404a2980a25567578a9eb217c3afe6882a5be4a Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Tue, 30 Apr 2024 20:47:30 +0700 Subject: [PATCH 06/32] Add more check for byte array and revert unneeded reformatted code --- .../github/jan/supabase/storage/BucketApi.kt | 33 +++++++++---------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt index 785223a0..479ac80a 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt @@ -66,17 +66,16 @@ sealed interface BucketApi { * @param upsert Whether to overwrite an existing file * @return the key of the uploaded file */ - suspend fun uploadToSignedUrl( - path: String, - token: String, - data: ByteArray, - upsert: Boolean = false - ): String = uploadToSignedUrl( - path, - token, - UploadData(ByteReadChannel(data), data.size.toLong()), - upsert - ) + suspend fun uploadToSignedUrl(path: String, token: String, data: ByteArray, upsert: Boolean = false + ): String { + require(data.isNotEmpty()) { "The data to upload should not be empty" } + return uploadToSignedUrl( + path, + token, + UploadData(ByteReadChannel(data), data.size.toLong()), + upsert + ) + } /** * Uploads a file in [bucketId] under [path] using a presigned url @@ -107,8 +106,10 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun update(path: String, data: ByteArray, upsert: Boolean = false): String = - update(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + suspend fun update(path: String, data: ByteArray, upsert: Boolean = false): String { + require(data.isNotEmpty()) { "The data to upload should not be empty" } + return update(path, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) + } /** * Updates a file in [bucketId] under [path] @@ -179,11 +180,7 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun createSignedUrl( - path: String, - expiresIn: Duration, - transform: ImageTransformation.() -> Unit = {} - ): String + suspend fun createSignedUrl(path: String, expiresIn: Duration, transform: ImageTransformation.() -> Unit = {}): String /** * Creates signed urls for all specified paths. The urls will expire after [expiresIn] From 087e1a191930a537ed743dcb7280fded53176660 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Tue, 30 Apr 2024 20:48:58 +0700 Subject: [PATCH 07/32] Add more check for byte array and revert unneeded reformatted code --- .../kotlin/io/github/jan/supabase/storage/BucketApi.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt index 479ac80a..8b421c8d 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt @@ -65,6 +65,7 @@ sealed interface BucketApi { * @param data The data to upload * @param upsert Whether to overwrite an existing file * @return the key of the uploaded file + * @throws IllegalArgumentException if data to upload is empty */ suspend fun uploadToSignedUrl(path: String, token: String, data: ByteArray, upsert: Boolean = false ): String { @@ -102,6 +103,7 @@ sealed interface BucketApi { * @param data The new data * @param upsert Whether to overwrite an existing file * @return the key to the updated file + * @throws IllegalArgumentException if data to upload is empty * @throws RestException or one of its subclasses if receiving an error response * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues From 7a27b52b2877c24ecc55ed3340cd3fcbdb43a7b7 Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Tue, 30 Apr 2024 20:51:08 +0700 Subject: [PATCH 08/32] Revert unneeded reformatted code --- .../github/jan/supabase/storage/BucketApi.kt | 36 ++++--------------- 1 file changed, 7 insertions(+), 29 deletions(-) diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt index 8b421c8d..957744dc 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt @@ -90,12 +90,7 @@ sealed interface BucketApi { * @throws HttpRequestException on network related issues * @throws HttpRequestException on network related issues */ - suspend fun uploadToSignedUrl( - path: String, - token: String, - data: UploadData, - upsert: Boolean = false - ): String + suspend fun uploadToSignedUrl(path: String, token: String, data: UploadData, upsert: Boolean = false): String /** * Updates a file in [bucketId] under [path] @@ -204,8 +199,7 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun createSignedUrls(expiresIn: Duration, vararg paths: String) = - createSignedUrls(expiresIn, paths.toList()) + suspend fun createSignedUrls(expiresIn: Duration, vararg paths: String) = createSignedUrls(expiresIn, paths.toList()) /** * Downloads a file from [bucketId] under [path] @@ -216,10 +210,7 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun downloadAuthenticated( - path: String, - transform: ImageTransformation.() -> Unit = {} - ): ByteArray + suspend fun downloadAuthenticated(path: String, transform: ImageTransformation.() -> Unit = {}): ByteArray /** * Downloads a file from [bucketId] under [path] @@ -230,11 +221,7 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun downloadAuthenticated( - path: String, - channel: ByteWriteChannel, - transform: ImageTransformation.() -> Unit = {} - ) + suspend fun downloadAuthenticated(path: String, channel: ByteWriteChannel, transform: ImageTransformation.() -> Unit = {}) /** * Downloads a file from [bucketId] under [path] using the public url @@ -245,10 +232,7 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun downloadPublic( - path: String, - transform: ImageTransformation.() -> Unit = {} - ): ByteArray + suspend fun downloadPublic(path: String, transform: ImageTransformation.() -> Unit = {}): ByteArray /** * Downloads a file from [bucketId] under [path] using the public url @@ -259,11 +243,7 @@ sealed interface BucketApi { * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend fun downloadPublic( - path: String, - channel: ByteWriteChannel, - transform: ImageTransformation.() -> Unit = {} - ) + suspend fun downloadPublic(path: String, channel: ByteWriteChannel, transform: ImageTransformation.() -> Unit = {}) /** @@ -341,8 +321,6 @@ sealed interface BucketApi { */ fun BucketApi.authenticatedRequest(path: String): Pair { val url = authenticatedUrl(path) - val token = - supabaseClient.storage.config.jwtToken ?: supabaseClient.pluginManager.getPluginOrNull(Auth) - ?.currentAccessTokenOrNull() ?: supabaseClient.supabaseKey + val token = supabaseClient.storage.config.jwtToken ?: supabaseClient.pluginManager.getPluginOrNull(Auth)?.currentAccessTokenOrNull() ?: supabaseClient.supabaseKey return token to url } \ No newline at end of file From 62180f504eecd6bd64ab45bbd9cbdeb8b3fce2ae Mon Sep 17 00:00:00 2001 From: Hieu Vu Date: Tue, 30 Apr 2024 20:51:50 +0700 Subject: [PATCH 09/32] Revert unneeded reformatted code --- .../kotlin/io/github/jan/supabase/storage/BucketApi.kt | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt index 957744dc..a637d9b5 100644 --- a/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt +++ b/Storage/src/commonMain/kotlin/io/github/jan/supabase/storage/BucketApi.kt @@ -70,12 +70,7 @@ sealed interface BucketApi { suspend fun uploadToSignedUrl(path: String, token: String, data: ByteArray, upsert: Boolean = false ): String { require(data.isNotEmpty()) { "The data to upload should not be empty" } - return uploadToSignedUrl( - path, - token, - UploadData(ByteReadChannel(data), data.size.toLong()), - upsert - ) + return uploadToSignedUrl(path, token, UploadData(ByteReadChannel(data), data.size.toLong()), upsert) } /** From 2bd8116df35815491d6b22b1dbf12c2471abfe7d Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 30 Apr 2024 18:22:10 +0200 Subject: [PATCH 10/32] Rethrow and log CancellationException --- .../jan/supabase/network/KtorSupabaseHttpClient.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt b/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt index 1b0855ba..45bd4bc0 100644 --- a/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt +++ b/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt @@ -7,6 +7,7 @@ import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.exceptions.HttpRequestException import io.github.jan.supabase.logging.d import io.github.jan.supabase.logging.e +import io.github.jan.supabase.logging.w import io.github.jan.supabase.supabaseJson import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig @@ -25,6 +26,7 @@ import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpStatement import io.ktor.http.encodedPath import io.ktor.serialization.kotlinx.json.json +import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration.Companion.milliseconds private const val HTTPS_PORT = 443 @@ -58,6 +60,9 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( } catch(e: HttpRequestTimeoutException) { SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint timed out after $requestTimeout ms" } throw e + } catch(e: CancellationException) { + SupabaseClient.LOGGER.w { "${request.method.value} request to endpoint $endPoint was cancelled"} + throw e } catch(e: Exception) { SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint failed with exception ${e.message}" } throw HttpRequestException(e.message ?: "", request) @@ -75,12 +80,14 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( url(url) builder() } - val response = try { httpClient.prepareRequest(url, builder) } catch(e: HttpRequestTimeoutException) { SupabaseClient.LOGGER.e { "Request timed out after $requestTimeout ms on url $url" } throw e + } catch(e: CancellationException) { + SupabaseClient.LOGGER.w { "Request was cancelled on url $url" } + throw e } catch(e: Exception) { SupabaseClient.LOGGER.e { "Request failed with ${e.message} on url $url" } throw HttpRequestException(e.message ?: "", request) From 482e493955a17b62c7dcb9395d4ee6825d25d541 Mon Sep 17 00:00:00 2001 From: Jan Date: Tue, 30 Apr 2024 18:29:10 +0200 Subject: [PATCH 11/32] Change level to error --- .../io/github/jan/supabase/network/KtorSupabaseHttpClient.kt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt b/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt index 45bd4bc0..5bc92e09 100644 --- a/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt +++ b/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt @@ -7,7 +7,6 @@ import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.exceptions.HttpRequestException import io.github.jan.supabase.logging.d import io.github.jan.supabase.logging.e -import io.github.jan.supabase.logging.w import io.github.jan.supabase.supabaseJson import io.ktor.client.HttpClient import io.ktor.client.HttpClientConfig @@ -61,7 +60,7 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint timed out after $requestTimeout ms" } throw e } catch(e: CancellationException) { - SupabaseClient.LOGGER.w { "${request.method.value} request to endpoint $endPoint was cancelled"} + SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint was cancelled"} throw e } catch(e: Exception) { SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint failed with exception ${e.message}" } @@ -86,7 +85,7 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( SupabaseClient.LOGGER.e { "Request timed out after $requestTimeout ms on url $url" } throw e } catch(e: CancellationException) { - SupabaseClient.LOGGER.w { "Request was cancelled on url $url" } + SupabaseClient.LOGGER.e { "Request was cancelled on url $url" } throw e } catch(e: Exception) { SupabaseClient.LOGGER.e { "Request failed with ${e.message} on url $url" } From 68111e21e22ab540056b8249b5e707a8aa79a31c Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 1 May 2024 22:43:51 +0200 Subject: [PATCH 12/32] Initial commit --- .../supabase/realtime/PostgrestExtensions.kt | 86 +++++++++++++++++++ .../jan/supabase/realtime/RealtimeImpl.kt | 7 +- 2 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt new file mode 100644 index 00000000..69561c42 --- /dev/null +++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt @@ -0,0 +1,86 @@ +package io.github.jan.supabase.realtime + +import io.github.jan.supabase.postgrest.query.PostgrestQueryBuilder +import io.github.jan.supabase.postgrest.query.filter.FilterOperation +import io.github.jan.supabase.postgrest.query.filter.PostgrestFilterBuilder +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlin.reflect.KProperty1 + +/** + * Executes vertical filtering with select on [PostgrestQueryBuilder.table] and [PostgrestQueryBuilder.schema] and returns a [Flow] of a single value matching the [filter]. + * This function listens for changes in the table and emits the new value whenever a change occurs. + * @param primaryKey the primary key of the [Data] type + * @param filter the filter to apply to the select query + */ +inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( + primaryKey: PrimaryKey, + crossinline filter: PostgrestFilterBuilder.() -> Unit +): Flow { + val realtime = postgrest.supabaseClient.realtime as RealtimeImpl + val id = realtime.nextIncrementId() + val channel = realtime.channel("$schema:$table:$id") + return flow { + val dataFlow = channel.postgresSingleDataFlow( + schema = this@selectSingleValueAsFlow.schema, + table = this@selectSingleValueAsFlow.table, + primaryKey = primaryKey, + filter = filter + ) + channel.subscribe() + emitAll(dataFlow) + }.onCompletion { + realtime.removeChannel(channel) + } +} + +/** + * Executes vertical filtering with select on [PostgrestQueryBuilder.table] and [PostgrestQueryBuilder.schema] and returns a [Flow] of a single value matching the [filter]. + * This function listens for changes in the table and emits the new value whenever a change occurs. + * @param primaryKey the primary key of the [Data] type + * @param filter the filter to apply to the select query + */ +inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( + primaryKey: KProperty1, + crossinline filter: PostgrestFilterBuilder.() -> Unit +): Flow = selectSingleValueAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, filter) + +/** + * Executes vertical filtering with select on [PostgrestQueryBuilder.table] and [PostgrestQueryBuilder.schema] and returns a [Flow] of a list of values matching the [filter]. + * This function listens for changes in the table and emits the new list whenever a change occurs. + * @param primaryKey the primary key of the [Data] type + * @param filter the filter to apply to the select query + */ +inline fun PostgrestQueryBuilder.selectListAsFlow( + primaryKey: PrimaryKey, + filter: FilterOperation? = null, +): Flow> { + val realtime = postgrest.supabaseClient.realtime as RealtimeImpl + val id = realtime.nextIncrementId() + val channel = realtime.channel("$schema:$table:$id") + return flow { + val dataFlow = channel.postgresListDataFlow( + schema = this@selectListAsFlow.schema, + table = this@selectListAsFlow.table, + primaryKey = primaryKey, + filter = filter + ) + channel.subscribe() + emitAll(dataFlow) + }.onCompletion { + realtime.removeChannel(channel) + } +} + +/** + * Executes vertical filtering with select on [PostgrestQueryBuilder.table] and [PostgrestQueryBuilder.schema] and returns a [Flow] of a list of values matching the [filter]. + * This function listens for changes in the table and emits the new list whenever a change occurs. + * @param primaryKey the primary key of the [Data] type + * @param filter the filter to apply to the select query + */ +inline fun PostgrestQueryBuilder.selectListAsFlow( + primaryKey: KProperty1, + filter: FilterOperation? = null, +): Flow> = selectListAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, filter) \ No newline at end of file diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeImpl.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeImpl.kt index b44bdadb..0f0b5f31 100644 --- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeImpl.kt +++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/RealtimeImpl.kt @@ -38,7 +38,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.buildJsonObject import kotlin.time.Duration.Companion.milliseconds -internal class RealtimeImpl(override val supabaseClient: SupabaseClient, override val config: Realtime.Config) : Realtime { +@PublishedApi internal class RealtimeImpl(override val supabaseClient: SupabaseClient, override val config: Realtime.Config) : Realtime { private var ws: DefaultClientWebSocketSession? = null @Suppress("MagicNumber") @@ -63,6 +63,7 @@ internal class RealtimeImpl(override val supabaseClient: SupabaseClient, overrid override var serializer = config.serializer ?: supabaseClient.defaultSerializer private val websocketUrl = realtimeWebsocketUrl() + private var incrementId by atomic(0) override suspend fun connect() = connect(false) @@ -284,4 +285,8 @@ internal class RealtimeImpl(override val supabaseClient: SupabaseClient, overrid } } + fun nextIncrementId(): Int { + return incrementId++ + } + } From c2f51a68b0e4e68819d3ef3f10283f6a17d5954c Mon Sep 17 00:00:00 2001 From: Jan Tennert Date: Fri, 3 May 2024 11:44:12 +0200 Subject: [PATCH 13/32] Update version --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index c28693d7..40aa44a4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,4 +9,4 @@ org.gradle.parallel=true org.jetbrains.compose.experimental.uikit.enabled=true org.jetbrains.compose.experimental.jscanvas.enabled=true -supabase-version = 2.3.1 +supabase-version = 2.4.0-beta-1 From b1e4eac0fe41c3bb6ba1d935478b4f0fc7f3f015 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 3 May 2024 11:55:49 +0200 Subject: [PATCH 14/32] rename method --- .../jan/supabase/realtime/PostgrestExtensions.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt index 69561c42..44b2e1f7 100644 --- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt +++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt @@ -53,7 +53,7 @@ inline fun PostgrestQueryBuilder.selectSingleValueAs * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query */ -inline fun PostgrestQueryBuilder.selectListAsFlow( +inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: PrimaryKey, filter: FilterOperation? = null, ): Flow> { @@ -62,8 +62,8 @@ inline fun PostgrestQueryBuilder.selectListAsFlow( val channel = realtime.channel("$schema:$table:$id") return flow { val dataFlow = channel.postgresListDataFlow( - schema = this@selectListAsFlow.schema, - table = this@selectListAsFlow.table, + schema = this@selectAsFlow.schema, + table = this@selectAsFlow.table, primaryKey = primaryKey, filter = filter ) @@ -80,7 +80,7 @@ inline fun PostgrestQueryBuilder.selectListAsFlow( * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query */ -inline fun PostgrestQueryBuilder.selectListAsFlow( +inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: KProperty1, filter: FilterOperation? = null, -): Flow> = selectListAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, filter) \ No newline at end of file +): Flow> = selectAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, filter) \ No newline at end of file From 67070d0e2eb9c982cf41d8717845132c9c81e677 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 3 May 2024 13:13:46 +0200 Subject: [PATCH 15/32] add more request tests --- .../src/commonTest/kotlin/AuthRequestTest.kt | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt b/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt index 62c4c01b..caedb9a8 100644 --- a/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt @@ -2,6 +2,7 @@ import io.github.jan.supabase.SupabaseClientBuilder import io.github.jan.supabase.gotrue.Auth import io.github.jan.supabase.gotrue.AuthConfig import io.github.jan.supabase.gotrue.FlowType +import io.github.jan.supabase.gotrue.OtpType import io.github.jan.supabase.gotrue.PKCEConstants import io.github.jan.supabase.gotrue.SessionSource import io.github.jan.supabase.gotrue.SessionStatus @@ -351,6 +352,205 @@ class AuthTest { } } + @Test + fun testUnlinkIdentity() { + runTest { + val expectedIdentityId = "identityId" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Delete, it.method) + assertPathIs("/user/identities/$expectedIdentityId", it.url.pathAfterVersion()) + respond( + sampleUserObject(), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + client.auth.unlinkIdentity(expectedIdentityId) + } + } + + @Test + fun testRetrieveSSOUrlWithDomain() { + runTest { + val expectedRedirectUrl = "https://example.com" + val expectedDomain = "https://example.com" + val expectedCaptchaToken = "captchaToken" + val expectedUrl = "https://ssourl.com" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/sso", it.url.pathAfterVersion()) + val body = it.body.toJsonElement().jsonObject + assertEquals(expectedRedirectUrl, body["redirect_to"]!!.jsonPrimitive.content) + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertEquals(expectedDomain, body["domain"]?.jsonPrimitive?.content) + assertEquals(expectedCaptchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + respond( + """ + { + "url": "$expectedUrl" + } + """.trimIndent(), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + val result = client.auth.retrieveSSOUrl(redirectUrl = expectedRedirectUrl) { + this.domain = expectedDomain + this.captchaToken = expectedCaptchaToken + } + assertEquals(expectedUrl, result.url) + } + } + + @Test + fun testRetrieveSSOUrlWithProviderId() { + runTest { + val expectedRedirectUrl = "https://example.com" + val expectedProviderId = "providerId" + val expectedCaptchaToken = "captchaToken" + val expectedUrl = "https://ssourl.com" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/sso", it.url.pathAfterVersion()) + val body = it.body.toJsonElement().jsonObject + assertEquals(expectedRedirectUrl, body["redirect_to"]!!.jsonPrimitive.content) + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertEquals(expectedProviderId, body["provider_id"]?.jsonPrimitive?.content) + assertEquals(expectedCaptchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + respond( + """ + { + "url": "$expectedUrl" + } + """.trimIndent(), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + } + val result = client.auth.retrieveSSOUrl(redirectUrl = expectedRedirectUrl) { + this.providerId = expectedProviderId + this.captchaToken = expectedCaptchaToken + } + assertEquals(expectedUrl, result.url) + } + } + + @Test + fun testUpdateUser() { + runTest { + val expectedEmail = "example@email.com" + val expectedPhone = "+1234567890" + val expectedData = buildJsonObject { + put("key", "value") + } + val expectedPassword = "password" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Put, it.method) + assertPathIs("/user", it.url.pathAfterVersion()) + val body = it.body.toJsonElement().jsonObject + assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) + assertEquals(expectedPhone, body["phone"]?.jsonPrimitive?.content) + assertEquals(expectedData, body["data"]!!.jsonObject) + assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content) + respond( + sampleUserObject(email = expectedEmail, phone = expectedPhone), + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) + ) + } + val user = client.auth.updateUser { + email = expectedEmail + phone = expectedPhone + data = expectedData + password = expectedPassword + } + assertEquals(expectedEmail, user.email, "Email should be equal") + assertEquals(expectedPhone, user.phone, "Phone should be equal") + } + } + + @Test + fun testResendEmail() { + runTest { + val expectedEmail = "example@email.com" + val expectedType = OtpType.Email.SIGNUP + val expectedCaptchaToken = "captchaToken" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/resend", it.url.pathAfterVersion()) + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertEquals(expectedCaptchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) + assertEquals(expectedType.name.lowercase(), body["type"]?.jsonPrimitive?.content) + respond( + sampleUserObject(email = expectedEmail), + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) + ) + } + client.auth.resendEmail(expectedType, expectedEmail, expectedCaptchaToken) + } + } + + @Test + fun testResendPhone() { + runTest { + val expectedPhone = "+1234567890" + val expectedType = OtpType.Phone.PHONE_CHANGE + val expectedCaptchaToken = "captchaToken" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/resend", it.url.pathAfterVersion()) + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertEquals(expectedCaptchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) + assertEquals(expectedPhone, body["phone"]?.jsonPrimitive?.content) + assertEquals(expectedType.name.lowercase(), body["type"]?.jsonPrimitive?.content) + respond( + sampleUserObject(email = expectedPhone), + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) + ) + } + client.auth.resendPhone(expectedType, expectedPhone, expectedCaptchaToken) + } + } + + @Test + fun testResetPasswordForEmail() { + runTest { + val expectedEmail = "example@email.com" + val expectedCaptchaToken = "captchaToken" + val expectedRedirectUrl = "https://example.com" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/recover", it.url.pathAfterVersion()) + val params = it.url.parameters + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertEquals( + expectedCaptchaToken, + metaSecurity["captcha_token"]?.jsonPrimitive?.content + ) + assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) + assertEquals(expectedRedirectUrl, params["redirect_to"]) + containsCodeChallenge(body) + respond( + sampleUserObject(email = expectedEmail), + headers = headersOf( + HttpHeaders.ContentType, + ContentType.Application.Json.toString() + ) + ) + } + client.auth.resetPasswordForEmail(expectedEmail, expectedRedirectUrl, expectedCaptchaToken) + } + } + private fun sampleUserObject(email: String? = null, phone: String? = null) = """ { "id": "id", From ca73e718f95972ba294915900b727421489f7ce9 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 3 May 2024 16:01:22 +0200 Subject: [PATCH 16/32] add more tests --- .../io/github/jan/supabase/gotrue/Auth.kt | 3 +- .../io/github/jan/supabase/gotrue/AuthImpl.kt | 14 +- .../gotrue/providers/ExternalAuthConfig.kt | 7 + .../{AuthRequestTest.kt => AuthApiTest.kt} | 226 ++++++++++++------ GoTrue/src/commonTest/kotlin/AuthTest.kt | 91 +++++++ GoTrue/src/commonTest/kotlin/AuthTestUtils.kt | 6 + .../github/jan/supabase/testing/KtorUtils.kt | 15 +- 7 files changed, 289 insertions(+), 73 deletions(-) rename GoTrue/src/commonTest/kotlin/{AuthRequestTest.kt => AuthApiTest.kt} (77%) create mode 100644 GoTrue/src/commonTest/kotlin/AuthTest.kt create mode 100644 GoTrue/src/commonTest/kotlin/AuthTestUtils.kt diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt index 8a4d71db..9226d600 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt @@ -144,12 +144,13 @@ sealed interface Auth : MainPlugin, CustomSerializationPlugin { * @param provider The OAuth provider * @param redirectUrl The redirect url to use. If you don't specify this, the platform specific will be used, like deeplinks on android. * @param config Extra configuration + * @return The OAuth url to open in the browser if [ExternalAuthConfigDefaults.automaticallyOpenUrl] is false, otherwise null. */ suspend fun linkIdentity( provider: OAuthProvider, redirectUrl: String? = defaultRedirectUrl(), config: ExternalAuthConfigDefaults.() -> Unit = {} - ) + ): String? /** * Unlinks an OAuth Identity from an existing user. diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt index e7cc6923..e77cc137 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt @@ -132,7 +132,18 @@ internal class AuthImpl( provider: OAuthProvider, redirectUrl: String?, config: ExternalAuthConfigDefaults.() -> Unit - ) { + ): String? { + val automaticallyOpen = ExternalAuthConfigDefaults().apply(config).automaticallyOpenUrl + val fetchUrl: suspend (String) -> String = { redirectTo: String -> + val url = getOAuthUrl(provider, redirectTo, "user/identities/authorize", config) + val response = api.rawRequest(url) { + method = HttpMethod.Get + } + response.request.url.toString() + } + if(!automaticallyOpen) { + return fetchUrl(redirectUrl ?: "") + } startExternalAuth( redirectUrl = redirectUrl, getUrl = { @@ -146,6 +157,7 @@ internal class AuthImpl( importSession(it, source = SessionSource.UserIdentitiesChanged(it)) } ) + return null } override suspend fun unlinkIdentity(identityId: String, updateLocalUser: Boolean) { diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/ExternalAuthConfig.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/ExternalAuthConfig.kt index 3a7c89a2..7fffe5f2 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/ExternalAuthConfig.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/ExternalAuthConfig.kt @@ -1,5 +1,7 @@ package io.github.jan.supabase.gotrue.providers +import io.github.jan.supabase.gotrue.Auth + /** * Configuration for external authentication providers like Google, Twitter, etc. */ @@ -20,4 +22,9 @@ open class ExternalAuthConfigDefaults { */ val queryParams = mutableMapOf() + /** + * Automatically open the URL in the browser. Only applies to [Auth.linkIdentity]. + */ + var automaticallyOpenUrl: Boolean = true + } \ No newline at end of file diff --git a/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt b/GoTrue/src/commonTest/kotlin/AuthApiTest.kt similarity index 77% rename from GoTrue/src/commonTest/kotlin/AuthRequestTest.kt rename to GoTrue/src/commonTest/kotlin/AuthApiTest.kt index caedb9a8..6b733123 100644 --- a/GoTrue/src/commonTest/kotlin/AuthRequestTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthApiTest.kt @@ -5,7 +5,7 @@ import io.github.jan.supabase.gotrue.FlowType import io.github.jan.supabase.gotrue.OtpType import io.github.jan.supabase.gotrue.PKCEConstants import io.github.jan.supabase.gotrue.SessionSource -import io.github.jan.supabase.gotrue.SessionStatus +import io.github.jan.supabase.gotrue.SignOutScope import io.github.jan.supabase.gotrue.auth import io.github.jan.supabase.gotrue.minimalSettings import io.github.jan.supabase.gotrue.providers.Google @@ -17,13 +17,12 @@ import io.github.jan.supabase.testing.assertMethodIs import io.github.jan.supabase.testing.assertPathIs import io.github.jan.supabase.testing.createMockedSupabaseClient import io.github.jan.supabase.testing.pathAfterVersion +import io.github.jan.supabase.testing.respondJson import io.github.jan.supabase.testing.toJsonElement import io.ktor.client.engine.mock.respond -import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders import io.ktor.http.HttpMethod -import io.ktor.http.headersOf import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonObject @@ -34,7 +33,7 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull -class AuthTest { +class AuthRequestTest { private val configuration: SupabaseClientBuilder.() -> Unit = { install(Auth) { @@ -65,9 +64,8 @@ class AuthTest { assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) assertEquals(userData, body["data"]!!.jsonObject) containsCodeChallenge(body) - respond( - sampleUserObject(email = expectedEmail), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + respondJson( + sampleUserObject(email = expectedEmail) ) } val user = client.auth.signUpWith(Email, redirectUrl = expectedUrl) { @@ -99,9 +97,8 @@ class AuthTest { assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) assertEquals(userData, body["data"]!!.jsonObject) containsCodeChallenge(body) - respond( - sampleUserSession(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + respondJson( + sampleUserSession() ) } val user = client.auth.signUpWith(Email) { @@ -135,9 +132,8 @@ class AuthTest { assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) assertEquals(userData, body["data"]!!.jsonObject) containsCodeChallenge(body) - respond( - sampleUserSession(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + respondJson( + sampleUserSession() ) } val user = client.auth.signUpWith(Phone) { @@ -174,9 +170,8 @@ class AuthTest { assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) assertEquals(userData, body["data"]!!.jsonObject) containsCodeChallenge(body) - respond( - sampleUserObject(phone = expectedPhone), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + respondJson( + sampleUserObject(phone = expectedPhone) ) } val user = client.auth.signUpWith(Phone, redirectUrl = expectedUrl) { @@ -273,9 +268,8 @@ class AuthTest { assertEquals(expectedProvider.name, body["provider"]?.jsonPrimitive?.content) assertEquals(expectedAccessToken, body["access_token"]?.jsonPrimitive?.content) assertEquals(expectedNonce, body["nonce"]?.jsonPrimitive?.content) - respond( - sampleUserSession(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + respondJson( + sampleUserSession() ) } client.auth.signInWith(IDToken) { @@ -305,9 +299,8 @@ class AuthTest { val metaSecurity = body["gotrue_meta_security"]!!.jsonObject assertEquals(captchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) assertEquals(userData, body["data"]!!.jsonObject) - respond( - sampleUserSession(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + respondJson( + sampleUserSession() ) } client.auth.signInAnonymously( @@ -336,18 +329,14 @@ class AuthTest { assertEquals(PKCEConstants.CHALLENGE_METHOD, params["code_challenge_method"]) assertEquals(expectedScopes.joinToString(" "), params["scopes"]) assertEquals("value", params["key"]) - respond( - sampleUserSession(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + respondJson( + sampleUserSession() ) } - try { - client.auth.linkIdentity(expectedProvider, redirectUrl = expectedRedirectUrl) { - scopes.addAll(expectedScopes) - queryParams.putAll(expectedUrlParams) - } - } catch(e: RuntimeException) { - // Ignore, throws an exception because it cannot open a browser + client.auth.linkIdentity(expectedProvider, redirectUrl = expectedRedirectUrl) { + scopes.addAll(expectedScopes) + queryParams.putAll(expectedUrlParams) + automaticallyOpenUrl = false } } } @@ -359,9 +348,8 @@ class AuthTest { val client = createMockedSupabaseClient(configuration = configuration) { assertMethodIs(HttpMethod.Delete, it.method) assertPathIs("/user/identities/$expectedIdentityId", it.url.pathAfterVersion()) - respond( - sampleUserObject(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + respondJson( + sampleUserObject() ) } client.auth.unlinkIdentity(expectedIdentityId) @@ -383,13 +371,12 @@ class AuthTest { val metaSecurity = body["gotrue_meta_security"]!!.jsonObject assertEquals(expectedDomain, body["domain"]?.jsonPrimitive?.content) assertEquals(expectedCaptchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) - respond( + respondJson( """ { "url": "$expectedUrl" } - """.trimIndent(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + """.trimIndent() ) } val result = client.auth.retrieveSSOUrl(redirectUrl = expectedRedirectUrl) { @@ -415,13 +402,12 @@ class AuthTest { val metaSecurity = body["gotrue_meta_security"]!!.jsonObject assertEquals(expectedProviderId, body["provider_id"]?.jsonPrimitive?.content) assertEquals(expectedCaptchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) - respond( + respondJson( """ { "url": "$expectedUrl" } - """.trimIndent(), - headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + """.trimIndent() ) } val result = client.auth.retrieveSSOUrl(redirectUrl = expectedRedirectUrl) { @@ -449,12 +435,8 @@ class AuthTest { assertEquals(expectedPhone, body["phone"]?.jsonPrimitive?.content) assertEquals(expectedData, body["data"]!!.jsonObject) assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content) - respond( - sampleUserObject(email = expectedEmail, phone = expectedPhone), - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Application.Json.toString() - ) + respondJson( + sampleUserObject(email = expectedEmail, phone = expectedPhone) ) } val user = client.auth.updateUser { @@ -482,12 +464,8 @@ class AuthTest { assertEquals(expectedCaptchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) assertEquals(expectedType.name.lowercase(), body["type"]?.jsonPrimitive?.content) - respond( - sampleUserObject(email = expectedEmail), - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Application.Json.toString() - ) + respondJson( + sampleUserObject(email = expectedEmail) ) } client.auth.resendEmail(expectedType, expectedEmail, expectedCaptchaToken) @@ -508,12 +486,8 @@ class AuthTest { assertEquals(expectedCaptchaToken, metaSecurity["captcha_token"]?.jsonPrimitive?.content) assertEquals(expectedPhone, body["phone"]?.jsonPrimitive?.content) assertEquals(expectedType.name.lowercase(), body["type"]?.jsonPrimitive?.content) - respond( - sampleUserObject(email = expectedPhone), - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Application.Json.toString() - ) + respondJson( + sampleUserObject(email = expectedPhone) ) } client.auth.resendPhone(expectedType, expectedPhone, expectedCaptchaToken) @@ -539,18 +513,134 @@ class AuthTest { assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) assertEquals(expectedRedirectUrl, params["redirect_to"]) containsCodeChallenge(body) - respond( - sampleUserObject(email = expectedEmail), - headers = headersOf( - HttpHeaders.ContentType, - ContentType.Application.Json.toString() - ) + respondJson( + sampleUserObject(email = expectedEmail) ) } client.auth.resetPasswordForEmail(expectedEmail, expectedRedirectUrl, expectedCaptchaToken) } } + @Test + fun testReauthenticate() { + runTest { + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Get, it.method) + assertPathIs("/reauthenticate", it.url.pathAfterVersion()) + respond("") + } + client.auth.reauthenticate() + } + } + + @Test + fun testVerifyEmailOtp() { + runTest { + val expectedType = OtpType.Email.EMAIL + val expectedToken = "token" + val expectedEmail = "example@email.com" + val expectedCaptchaToken = "captchaToken" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/verify", it.url.pathAfterVersion()) + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertEquals( + expectedCaptchaToken, + metaSecurity["captcha_token"]?.jsonPrimitive?.content + ) + assertEquals(expectedToken, body["token"]?.jsonPrimitive?.content) + assertEquals(expectedEmail, body["email"]?.jsonPrimitive?.content) + assertEquals(expectedType.name.lowercase(), body["type"]?.jsonPrimitive?.content) + respondJson( + sampleUserSession() + ) + } + client.auth.verifyEmailOtp(expectedType, expectedEmail, expectedToken, expectedCaptchaToken) + } + } + + @Test + fun testVerifyPhoneOtp() { + runTest { + val expectedType = OtpType.Phone.SMS + val expectedToken = "token" + val expectedPhone = "+1234567890" + val expectedCaptchaToken = "captchaToken" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/verify", it.url.pathAfterVersion()) + val body = it.body.toJsonElement().jsonObject + val metaSecurity = body["gotrue_meta_security"]!!.jsonObject + assertEquals( + expectedCaptchaToken, + metaSecurity["captcha_token"]?.jsonPrimitive?.content + ) + assertEquals(expectedToken, body["token"]?.jsonPrimitive?.content) + assertEquals(expectedPhone, body["phone"]?.jsonPrimitive?.content) + assertEquals(expectedType.name.lowercase(), body["type"]?.jsonPrimitive?.content) + respondJson( + sampleUserSession() + ) + } + client.auth.verifyPhoneOtp(expectedType, expectedPhone, expectedToken, expectedCaptchaToken) + } + } + + @Test + fun testRetrieveUser() { + runTest { + val expectedJWT = "token" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Get, it.method) + assertPathIs("/user", it.url.pathAfterVersion()) + assertEquals("Bearer $expectedJWT", it.headers["Authorization"]) + respondJson( + sampleUserObject() + ) + } + val user = client.auth.retrieveUser(expectedJWT) + assertNotNull(user, "User should not be null") + } + } + + @Test + fun testSignOut() { + runTest { + val expectedScope = SignOutScope.LOCAL + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/logout", it.url.pathAfterVersion()) + val parameters = it.url.parameters + assertEquals(expectedScope.name.lowercase(), parameters["scope"]) + respond("") + } + client.auth.importSession(Json.decodeFromString(sampleUserSession())) + assertNotNull(client.auth.currentSessionOrNull(), "Session should not be null") + client.auth.signOut(expectedScope) + assertNull(client.auth.currentSessionOrNull(), "Session should be null") + } + } + + @Test + fun testRefreshSession() { + runTest { + val expectedRefreshToken = "refreshToken" + val client = createMockedSupabaseClient(configuration = configuration) { + assertMethodIs(HttpMethod.Post, it.method) + assertPathIs("/token", it.url.pathAfterVersion()) + val parameters = it.url.parameters + assertEquals("refresh_token", parameters["grant_type"]) + val body = it.body.toJsonElement().jsonObject + assertEquals(expectedRefreshToken, body["refresh_token"]?.jsonPrimitive?.content) + respondJson( + sampleUserSession() + ) + } + client.auth.refreshSession(expectedRefreshToken) + } + } + private fun sampleUserObject(email: String? = null, phone: String? = null) = """ { "id": "id", @@ -574,10 +664,6 @@ class AuthTest { assertEquals(PKCEConstants.CHALLENGE_METHOD, body["code_challenge_method"]?.jsonPrimitive?.content) } - private fun Auth.sessionSource() = (sessionStatus.value as SessionStatus.Authenticated).source - - private inline fun Auth.sessionSourceAs() = sessionSource() as T - } expect fun AuthConfig.platformSettings() \ No newline at end of file diff --git a/GoTrue/src/commonTest/kotlin/AuthTest.kt b/GoTrue/src/commonTest/kotlin/AuthTest.kt new file mode 100644 index 00000000..69056db0 --- /dev/null +++ b/GoTrue/src/commonTest/kotlin/AuthTest.kt @@ -0,0 +1,91 @@ +import io.github.jan.supabase.SupabaseClientBuilder +import io.github.jan.supabase.gotrue.Auth +import io.github.jan.supabase.gotrue.MemorySessionManager +import io.github.jan.supabase.gotrue.SessionStatus +import io.github.jan.supabase.gotrue.auth +import io.github.jan.supabase.gotrue.minimalSettings +import io.github.jan.supabase.gotrue.user.UserSession +import io.github.jan.supabase.testing.createMockedSupabaseClient +import io.github.jan.supabase.testing.respondJson +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class AuthTest { + + private val configuration: SupabaseClientBuilder.() -> Unit = { + install(Auth) { + minimalSettings() + } + } + + @Test + fun testLoadingSessionFromStorage() { + runTest { + val sessionManager = MemorySessionManager(userSession()) + val client = createMockedSupabaseClient( + configuration = { + install(Auth) { + minimalSettings( + sessionManager = sessionManager, + autoLoadFromStorage = true + ) + } + } + ) + assertIs(client.auth.sessionStatus.value) + client.auth.awaitInitialization() + assertIs(client.auth.sessionStatus.value) + } + } + + @Test + fun testSavingSessionToStorage() { + runTest { + val sessionManager = MemorySessionManager() + val client = createMockedSupabaseClient( + configuration = { + install(Auth) { + minimalSettings( + sessionManager = sessionManager, + autoSaveToStorage = true + ) + } + } + ) + client.auth.awaitInitialization() + assertIs(client.auth.sessionStatus.value) + assertNull(sessionManager.loadSession()) + val session = userSession() + client.auth.importSession(session) + assertIs(client.auth.sessionStatus.value) + assertEquals(session, sessionManager.loadSession()) + } + } + + @Test + fun testImportExpiredSession() { + runTest { + val newSession = userSession() + val client = createMockedSupabaseClient(configuration = configuration) { + respondJson(newSession) + } + client.auth.awaitInitialization() + assertIs(client.auth.sessionStatus.value) + val session = userSession(0) + client.auth.importSession(session) + assertIs(client.auth.sessionStatus.value) + assertEquals(newSession.expiresIn, client.auth.currentSessionOrNull()?.expiresIn) + } + } + + private fun userSession(expiresIn: Long = 3600) = UserSession( + accessToken = "accessToken", + refreshToken = "refreshToken", + expiresIn = expiresIn, + tokenType = "Bearer" + ) + +} \ No newline at end of file diff --git a/GoTrue/src/commonTest/kotlin/AuthTestUtils.kt b/GoTrue/src/commonTest/kotlin/AuthTestUtils.kt new file mode 100644 index 00000000..b271e573 --- /dev/null +++ b/GoTrue/src/commonTest/kotlin/AuthTestUtils.kt @@ -0,0 +1,6 @@ +import io.github.jan.supabase.gotrue.Auth +import io.github.jan.supabase.gotrue.SessionStatus + +fun Auth.sessionSource() = (sessionStatus.value as SessionStatus.Authenticated).source + +inline fun Auth.sessionSourceAs() = sessionSource() as T \ No newline at end of file diff --git a/test-common/src/commonMain/kotlin/io/github/jan/supabase/testing/KtorUtils.kt b/test-common/src/commonMain/kotlin/io/github/jan/supabase/testing/KtorUtils.kt index b82e7ee9..25721075 100644 --- a/test-common/src/commonMain/kotlin/io/github/jan/supabase/testing/KtorUtils.kt +++ b/test-common/src/commonMain/kotlin/io/github/jan/supabase/testing/KtorUtils.kt @@ -1,10 +1,23 @@ package io.github.jan.supabase.testing +import io.ktor.client.engine.mock.MockRequestHandleScope +import io.ktor.client.engine.mock.respond import io.ktor.client.engine.mock.toByteArray +import io.ktor.client.request.HttpResponseData +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.content.OutgoingContent +import io.ktor.http.headersOf +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement suspend fun OutgoingContent.toJsonElement(): JsonElement { return Json.decodeFromString(toByteArray().decodeToString()) -} \ No newline at end of file +} + +fun MockRequestHandleScope.respondJson(json: String): HttpResponseData { + return respond(json, headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())) +} + +inline fun MockRequestHandleScope.respondJson(json: T): HttpResponseData = respondJson(Json.encodeToString(json)) \ No newline at end of file From cd877b4e90cab6c96c8837fac424cd47b110c54d Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 3 May 2024 16:02:26 +0200 Subject: [PATCH 17/32] remove unused code --- .../kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt index e77cc137..7f08e793 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/AuthImpl.kt @@ -134,7 +134,7 @@ internal class AuthImpl( config: ExternalAuthConfigDefaults.() -> Unit ): String? { val automaticallyOpen = ExternalAuthConfigDefaults().apply(config).automaticallyOpenUrl - val fetchUrl: suspend (String) -> String = { redirectTo: String -> + val fetchUrl: suspend (String?) -> String = { redirectTo: String? -> val url = getOAuthUrl(provider, redirectTo, "user/identities/authorize", config) val response = api.rawRequest(url) { method = HttpMethod.Get @@ -147,11 +147,7 @@ internal class AuthImpl( startExternalAuth( redirectUrl = redirectUrl, getUrl = { - val url = getOAuthUrl(provider, it, "user/identities/authorize", config) - val response = api.rawRequest(url) { - method = HttpMethod.Get - } - response.request.url.toString() + fetchUrl(it) }, onSessionSuccess = { importSession(it, source = SessionSource.UserIdentitiesChanged(it)) From e310aa68082ab1f1e69cf16d3300cc82f275422c Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 3 May 2024 16:09:11 +0200 Subject: [PATCH 18/32] fix test --- GoTrue/src/commonTest/kotlin/AuthTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/GoTrue/src/commonTest/kotlin/AuthTest.kt b/GoTrue/src/commonTest/kotlin/AuthTest.kt index 69056db0..0999d953 100644 --- a/GoTrue/src/commonTest/kotlin/AuthTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthTest.kt @@ -35,7 +35,6 @@ class AuthTest { } } ) - assertIs(client.auth.sessionStatus.value) client.auth.awaitInitialization() assertIs(client.auth.sessionStatus.value) } From ba2cc5b78057dbef4b68855d59e67409b1d37741 Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 3 May 2024 16:40:08 +0200 Subject: [PATCH 19/32] Add function region parameter --- .../jan/supabase/functions/FunctionRegion.kt | 23 ++++++++++++ .../jan/supabase/functions/Functions.kt | 36 ++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) create mode 100644 Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/FunctionRegion.kt diff --git a/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/FunctionRegion.kt b/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/FunctionRegion.kt new file mode 100644 index 00000000..2fe639b2 --- /dev/null +++ b/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/FunctionRegion.kt @@ -0,0 +1,23 @@ +package io.github.jan.supabase.functions + +/** + * The region where the function is invoked. + * @param value The value of the region + */ +enum class FunctionRegion(val value: String) { + Any("any"), + ApNortheast1("ap-northeast-1"), + ApNortheast2("ap-northeast-2"), + ApSouth1("ap-south-1"), + ApSoutheast1("ap-southeast-1"), + ApSoutheast2("ap-southeast-2"), + CaCentral1("ca-central-1"), + EuCentral1("eu-central-1"), + EuWest1("eu-west-1"), + EuWest2("eu-west-2"), + EuWest3("eu-west-3"), + SaEast1("sa-east-1"), + UsEast1("us-east-1"), + UsWest1("us-west-1"), + UsWest2("us-west-2"), +} \ No newline at end of file diff --git a/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/Functions.kt b/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/Functions.kt index 8b8c8eac..ef2946d6 100644 --- a/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/Functions.kt +++ b/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/Functions.kt @@ -18,6 +18,7 @@ import io.github.jan.supabase.plugins.SupabasePluginProvider import io.github.jan.supabase.serializer.KotlinXSerializer import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.header import io.ktor.client.request.setBody import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText @@ -61,12 +62,16 @@ class Functions(override val config: Config, override val supabaseClient: Supaba * Invokes a remote edge function. The authorization token is automatically added to the request. * @param function The function to invoke * @param builder The request builder to configure the request + * @param region The region where the function is invoked * @throws RestException or one of its subclasses if receiving an error response * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend inline operator fun invoke(function: String, crossinline builder: HttpRequestBuilder.() -> Unit): HttpResponse { - return api.post(function, builder) + suspend inline operator fun invoke(function: String, region: FunctionRegion = config.defaultRegion, crossinline builder: HttpRequestBuilder.() -> Unit): HttpResponse { + return api.post(function) { + builder() + header("x-region", region.value) + } } /** @@ -75,12 +80,14 @@ class Functions(override val config: Config, override val supabaseClient: Supaba * @param function The function to invoke * @param body The body of the request * @param headers Headers to add to the request + * @param region The region where the function is invoked * @throws RestException or one of its subclasses if receiving an error response * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend inline operator fun invoke(function: String, body: T, headers: Headers = Headers.Empty): HttpResponse = invoke(function) { + suspend inline operator fun invoke(function: String, body: T, region: FunctionRegion = config.defaultRegion, headers: Headers = Headers.Empty): HttpResponse = invoke(function) { this.headers.appendAll(headers) + header("x-region", region.value) setBody(serializer.encode(body)) } @@ -88,21 +95,35 @@ class Functions(override val config: Config, override val supabaseClient: Supaba * Invokes a remote edge function. The authorization token is automatically added to the request. * @param function The function to invoke * @param headers Headers to add to the request + * @param region The region where the function is invoked * @throws RestException or one of its subclasses if receiving an error response * @throws HttpRequestTimeoutException if the request timed out * @throws HttpRequestException on network related issues */ - suspend inline operator fun invoke(function: String, headers: Headers = Headers.Empty): HttpResponse = invoke(function) { + suspend inline operator fun invoke(function: String, region: FunctionRegion = config.defaultRegion, headers: Headers = Headers.Empty): HttpResponse = invoke(function) { this.headers.appendAll(headers) + header("x-region", region.value) } /** * Builds an [EdgeFunction] which can be invoked multiple times * @param function The function name * @param headers Headers to add to the requests when invoking the function + * @param region The region where the function is invoked */ @OptIn(SupabaseInternal::class) - fun buildEdgeFunction(function: String, headers: Headers = Headers.Empty) = EdgeFunction(function, headers, supabaseClient) + fun buildEdgeFunction( + function: String, + region: FunctionRegion = config.defaultRegion, + headers: Headers = Headers.Empty + ) = EdgeFunction( + functionName = function, + headers = Headers.build { + appendAll(headers) + append("x-region", region.value) + }, + supabaseClient = supabaseClient + ) override suspend fun parseErrorResponse(response: HttpResponse): RestException { val error = response.bodyAsText() @@ -124,6 +145,11 @@ class Functions(override val config: Config, override val supabaseClient: Supaba override var serializer: SupabaseSerializer? = null + /** + * The default region to use when invoking a function + */ + var defaultRegion: FunctionRegion = FunctionRegion.Any + } companion object : SupabasePluginProvider { From 26f76d1b4f833f886bb3aebc5935a09f1d427ecf Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 4 May 2024 15:07:42 +0200 Subject: [PATCH 20/32] add channel name parameter to the methods --- .../jan/supabase/realtime/PostgrestExtensions.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt index 44b2e1f7..21703911 100644 --- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt +++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt @@ -14,14 +14,16 @@ import kotlin.reflect.KProperty1 * This function listens for changes in the table and emits the new value whenever a change occurs. * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query + * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be generated */ inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( primaryKey: PrimaryKey, + channelName: String? = null, crossinline filter: PostgrestFilterBuilder.() -> Unit ): Flow { val realtime = postgrest.supabaseClient.realtime as RealtimeImpl val id = realtime.nextIncrementId() - val channel = realtime.channel("$schema:$table:$id") + val channel = realtime.channel(channelName ?: "$schema:$table:$id") return flow { val dataFlow = channel.postgresSingleDataFlow( schema = this@selectSingleValueAsFlow.schema, @@ -41,25 +43,29 @@ inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( * This function listens for changes in the table and emits the new value whenever a change occurs. * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query + * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be generated */ inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( primaryKey: KProperty1, + channelName: String? = null, crossinline filter: PostgrestFilterBuilder.() -> Unit -): Flow = selectSingleValueAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, filter) +): Flow = selectSingleValueAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, channelName, filter) /** * Executes vertical filtering with select on [PostgrestQueryBuilder.table] and [PostgrestQueryBuilder.schema] and returns a [Flow] of a list of values matching the [filter]. * This function listens for changes in the table and emits the new list whenever a change occurs. * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query + * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be generated */ inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: PrimaryKey, + channelName: String? = null, filter: FilterOperation? = null, ): Flow> { val realtime = postgrest.supabaseClient.realtime as RealtimeImpl val id = realtime.nextIncrementId() - val channel = realtime.channel("$schema:$table:$id") + val channel = realtime.channel(channelName ?: "$schema:$table:$id") return flow { val dataFlow = channel.postgresListDataFlow( schema = this@selectAsFlow.schema, @@ -79,8 +85,10 @@ inline fun PostgrestQueryBuilder.selectAsFlow( * This function listens for changes in the table and emits the new list whenever a change occurs. * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query + * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be generated */ inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: KProperty1, + channelName: String? = null, filter: FilterOperation? = null, -): Flow> = selectAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, filter) \ No newline at end of file +): Flow> = selectAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, channelName, filter) \ No newline at end of file From 6e5bebd6686301b54d4b5b8eae6c78876a68045f Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 4 May 2024 15:08:35 +0200 Subject: [PATCH 21/32] change docs --- .../github/jan/supabase/realtime/PostgrestExtensions.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt index 21703911..5231c4ee 100644 --- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt +++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt @@ -14,7 +14,7 @@ import kotlin.reflect.KProperty1 * This function listens for changes in the table and emits the new value whenever a change occurs. * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query - * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be generated + * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be used */ inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( primaryKey: PrimaryKey, @@ -43,7 +43,7 @@ inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( * This function listens for changes in the table and emits the new value whenever a change occurs. * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query - * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be generated + * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be used */ inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( primaryKey: KProperty1, @@ -56,7 +56,7 @@ inline fun PostgrestQueryBuilder.selectSingleValueAs * This function listens for changes in the table and emits the new list whenever a change occurs. * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query - * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be generated + * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be used */ inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: PrimaryKey, @@ -85,7 +85,7 @@ inline fun PostgrestQueryBuilder.selectAsFlow( * This function listens for changes in the table and emits the new list whenever a change occurs. * @param primaryKey the primary key of the [Data] type * @param filter the filter to apply to the select query - * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be generated + * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be used */ inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: KProperty1, From cba22838f757cb22934c33b4a27c53e77bb59f3d Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 4 May 2024 15:15:41 +0200 Subject: [PATCH 22/32] fix unused id when using a custom name --- .../jan/supabase/realtime/PostgrestExtensions.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt index 5231c4ee..e25480b7 100644 --- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt +++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt @@ -22,8 +22,7 @@ inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( crossinline filter: PostgrestFilterBuilder.() -> Unit ): Flow { val realtime = postgrest.supabaseClient.realtime as RealtimeImpl - val id = realtime.nextIncrementId() - val channel = realtime.channel(channelName ?: "$schema:$table:$id") + val channel = realtime.channel(channelName ?: defaultChannelName(schema, table, realtime)) return flow { val dataFlow = channel.postgresSingleDataFlow( schema = this@selectSingleValueAsFlow.schema, @@ -64,8 +63,7 @@ inline fun PostgrestQueryBuilder.selectAsFlow( filter: FilterOperation? = null, ): Flow> { val realtime = postgrest.supabaseClient.realtime as RealtimeImpl - val id = realtime.nextIncrementId() - val channel = realtime.channel(channelName ?: "$schema:$table:$id") + val channel = realtime.channel(channelName ?: defaultChannelName(schema, table, realtime)) return flow { val dataFlow = channel.postgresListDataFlow( schema = this@selectAsFlow.schema, @@ -91,4 +89,7 @@ inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: KProperty1, channelName: String? = null, filter: FilterOperation? = null, -): Flow> = selectAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, channelName, filter) \ No newline at end of file +): Flow> = selectAsFlow(PrimaryKey(primaryKey.name) { primaryKey.get(it).toString() }, channelName, filter) + +@PublishedApi +internal fun defaultChannelName(schema: String, table: String, realtimeImpl: RealtimeImpl) = "$schema:$table:${realtimeImpl.nextIncrementId()}" \ No newline at end of file From 0ba1369e91afb02ae3f9de79cda074a96fade264 Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 4 May 2024 21:28:35 +0200 Subject: [PATCH 23/32] Update Kotlin and Compose --- gradle/libs.versions.toml | 5 +++-- plugins/ComposeAuth/build.gradle.kts | 1 + plugins/ComposeAuthUI/build.gradle.kts | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d1bb9cfb..f4df7784 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -kotlin = "2.0.0-RC1" +kotlin = "2.0.0-RC2" ktor = "2.3.10" dokka = "1.9.20" kotlinx-datetime = "0.5.0" @@ -20,7 +20,7 @@ moshi = "1.15.1" jackson = "2.17.0" browser = "1.8.0" googleid = "1.1.0" -compose = "1.6.2" +compose = "1.6.10-rc01" androidsvg = "1.4" imageloader = "1.7.8" coil = "2.6.0" @@ -37,6 +37,7 @@ maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "maven-publ detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } compose = { id = "org.jetbrains.compose", version.ref = "compose" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } native-cocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } diff --git a/plugins/ComposeAuth/build.gradle.kts b/plugins/ComposeAuth/build.gradle.kts index 10b078c3..19713a69 100644 --- a/plugins/ComposeAuth/build.gradle.kts +++ b/plugins/ComposeAuth/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.library) alias(libs.plugins.compose) + alias(libs.plugins.compose.compiler) } description = "Extends gotrue-kt with composable" diff --git a/plugins/ComposeAuthUI/build.gradle.kts b/plugins/ComposeAuthUI/build.gradle.kts index 60e09ee2..4e376767 100644 --- a/plugins/ComposeAuthUI/build.gradle.kts +++ b/plugins/ComposeAuthUI/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.library) alias(libs.plugins.compose) + alias(libs.plugins.compose.compiler) } description = "Extends supabase-kt with a Apollo GraphQL Client" From 7421ef38051610eca9002aaebe4b5a437b1da7d1 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 5 May 2024 00:52:46 +0200 Subject: [PATCH 24/32] fix build error --- Functions/build.gradle.kts | 1 + GoTrue/build.gradle.kts | 1 + Postgrest/build.gradle.kts | 1 + Realtime/build.gradle.kts | 1 + Storage/build.gradle.kts | 1 + build.gradle.kts | 1 + plugins/ApolloGraphQL/build.gradle.kts | 1 + plugins/CoilIntegration/build.gradle.kts | 1 + plugins/ComposeAuth/build.gradle.kts | 18 +++++++++++++++++- plugins/ComposeAuthUI/build.gradle.kts | 16 ++++++++++++++++ .../ImageLoaderIntegration/build.gradle.kts | 1 + serializers/Jackson/build.gradle.kts | 1 + serializers/Moshi/build.gradle.kts | 1 + test-common/build.gradle.kts | 1 + 14 files changed, 45 insertions(+), 1 deletion(-) diff --git a/Functions/build.gradle.kts b/Functions/build.gradle.kts index 996694a0..fd9c6205 100644 --- a/Functions/build.gradle.kts +++ b/Functions/build.gradle.kts @@ -46,6 +46,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/GoTrue/build.gradle.kts b/GoTrue/build.gradle.kts index 1a215644..e46dadfe 100644 --- a/GoTrue/build.gradle.kts +++ b/GoTrue/build.gradle.kts @@ -48,6 +48,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/Postgrest/build.gradle.kts b/Postgrest/build.gradle.kts index 7f85aa81..ca308730 100644 --- a/Postgrest/build.gradle.kts +++ b/Postgrest/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/Realtime/build.gradle.kts b/Realtime/build.gradle.kts index d7073920..c0271b0a 100644 --- a/Realtime/build.gradle.kts +++ b/Realtime/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/Storage/build.gradle.kts b/Storage/build.gradle.kts index 9d86884e..f045106a 100644 --- a/Storage/build.gradle.kts +++ b/Storage/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/build.gradle.kts b/build.gradle.kts index 233f2573..bf88a35e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -180,6 +180,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { kotlin.srcDir( diff --git a/plugins/ApolloGraphQL/build.gradle.kts b/plugins/ApolloGraphQL/build.gradle.kts index c44af851..bd1fc5ab 100644 --- a/plugins/ApolloGraphQL/build.gradle.kts +++ b/plugins/ApolloGraphQL/build.gradle.kts @@ -44,6 +44,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/plugins/CoilIntegration/build.gradle.kts b/plugins/CoilIntegration/build.gradle.kts index a794d8c7..797588f4 100644 --- a/plugins/CoilIntegration/build.gradle.kts +++ b/plugins/CoilIntegration/build.gradle.kts @@ -23,6 +23,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/plugins/ComposeAuth/build.gradle.kts b/plugins/ComposeAuth/build.gradle.kts index 19713a69..aa7cd0ba 100644 --- a/plugins/ComposeAuth/build.gradle.kts +++ b/plugins/ComposeAuth/build.gradle.kts @@ -1,3 +1,7 @@ +import com.android.build.gradle.internal.lint.AndroidLintAnalysisTask +import com.android.build.gradle.internal.lint.LintModelWriterTask +import com.android.build.gradle.internal.tasks.LintModelMetadataTask + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.library) @@ -5,7 +9,7 @@ plugins { alias(libs.plugins.compose.compiler) } -description = "Extends gotrue-kt with composable" +description = "Extends gotrue-kt with Native Auth composables" repositories { mavenCentral() @@ -41,6 +45,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { @@ -82,3 +87,14 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } } + +//see https://github.com/JetBrains/compose-multiplatform/issues/4739 +tasks.withType { + dependsOn("generateResourceAccessorsForAndroidUnitTest") +} +tasks.withType { + dependsOn("generateResourceAccessorsForAndroidUnitTest") +} +tasks.withType { + dependsOn("generateResourceAccessorsForAndroidUnitTest") +} \ No newline at end of file diff --git a/plugins/ComposeAuthUI/build.gradle.kts b/plugins/ComposeAuthUI/build.gradle.kts index 4e376767..c76d1c9d 100644 --- a/plugins/ComposeAuthUI/build.gradle.kts +++ b/plugins/ComposeAuthUI/build.gradle.kts @@ -1,3 +1,7 @@ +import com.android.build.gradle.internal.lint.AndroidLintAnalysisTask +import com.android.build.gradle.internal.lint.LintModelWriterTask +import com.android.build.gradle.internal.tasks.LintModelMetadataTask + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.android.library) @@ -39,6 +43,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { @@ -75,4 +80,15 @@ android { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 } +} + +//see https://github.com/JetBrains/compose-multiplatform/issues/4739 +tasks.withType { + dependsOn("generateResourceAccessorsForAndroidUnitTest") +} +tasks.withType { + dependsOn("generateResourceAccessorsForAndroidUnitTest") +} +tasks.withType { + dependsOn("generateResourceAccessorsForAndroidUnitTest") } \ No newline at end of file diff --git a/plugins/ImageLoaderIntegration/build.gradle.kts b/plugins/ImageLoaderIntegration/build.gradle.kts index 1cb24704..367c9f7f 100644 --- a/plugins/ImageLoaderIntegration/build.gradle.kts +++ b/plugins/ImageLoaderIntegration/build.gradle.kts @@ -34,6 +34,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/serializers/Jackson/build.gradle.kts b/serializers/Jackson/build.gradle.kts index 6dbd8122..bf31083d 100644 --- a/serializers/Jackson/build.gradle.kts +++ b/serializers/Jackson/build.gradle.kts @@ -22,6 +22,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/serializers/Moshi/build.gradle.kts b/serializers/Moshi/build.gradle.kts index 5085d011..e84a22fa 100644 --- a/serializers/Moshi/build.gradle.kts +++ b/serializers/Moshi/build.gradle.kts @@ -22,6 +22,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { diff --git a/test-common/build.gradle.kts b/test-common/build.gradle.kts index 7eec8d94..80f34b00 100644 --- a/test-common/build.gradle.kts +++ b/test-common/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { languageSettings.optIn("kotlin.RequiresOptIn") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseInternal") languageSettings.optIn("io.github.jan.supabase.annotations.SupabaseExperimental") + compilerOptions.freeCompilerArgs.add("-Xexpect-actual-classes") } val commonMain by getting { dependencies { From 5a310880a2f06ee27d885a5585336ffa95e2f816 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 5 May 2024 12:00:27 +0200 Subject: [PATCH 25/32] add more tests --- GoTrue/src/commonTest/kotlin/AdminApiTest.kt | 5 +++ GoTrue/src/commonTest/kotlin/AuthTest.kt | 40 +++++++++++++++---- .../kotlin/MemorySessionManagerTest.kt | 25 ++++++++++++ GoTrue/src/commonTest/kotlin/MfaApiTest.kt | 5 +++ 4 files changed, 68 insertions(+), 7 deletions(-) create mode 100644 GoTrue/src/commonTest/kotlin/AdminApiTest.kt create mode 100644 GoTrue/src/commonTest/kotlin/MemorySessionManagerTest.kt create mode 100644 GoTrue/src/commonTest/kotlin/MfaApiTest.kt diff --git a/GoTrue/src/commonTest/kotlin/AdminApiTest.kt b/GoTrue/src/commonTest/kotlin/AdminApiTest.kt new file mode 100644 index 00000000..acbeb17f --- /dev/null +++ b/GoTrue/src/commonTest/kotlin/AdminApiTest.kt @@ -0,0 +1,5 @@ +class AdminApiTest { + + //TODO: Implement tests + +} \ No newline at end of file diff --git a/GoTrue/src/commonTest/kotlin/AuthTest.kt b/GoTrue/src/commonTest/kotlin/AuthTest.kt index 0999d953..a9b55dcb 100644 --- a/GoTrue/src/commonTest/kotlin/AuthTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthTest.kt @@ -80,11 +80,37 @@ class AuthTest { } } - private fun userSession(expiresIn: Long = 3600) = UserSession( - accessToken = "accessToken", - refreshToken = "refreshToken", - expiresIn = expiresIn, - tokenType = "Bearer" - ) + @Test + fun testAutoRefreshSession() { + runTest { + val newSession = userSession() + val client = createMockedSupabaseClient(configuration = { + install(Auth) { + minimalSettings( + autoLoadFromStorage = false, + alwaysAutoRefresh = false, + autoSaveToStorage = false + ) + } + }) { + respondJson(newSession) + } + client.auth.awaitInitialization() + assertIs(client.auth.sessionStatus.value) + val session = userSession(0) + client.auth.importSession(session) + assertIs(client.auth.sessionStatus.value) + assertEquals(session.expiresIn, client.auth.currentSessionOrNull()?.expiresIn) //The session shouldn't be refreshed automatically as alwaysAutoRefresh is false + client.auth.startAutoRefreshForCurrentSession() + assertEquals(newSession.expiresIn, client.auth.currentSessionOrNull()?.expiresIn) + } + } + +} -} \ No newline at end of file +fun userSession(expiresIn: Long = 3600) = UserSession( + accessToken = "accessToken", + refreshToken = "refreshToken", + expiresIn = expiresIn, + tokenType = "Bearer" +) \ No newline at end of file diff --git a/GoTrue/src/commonTest/kotlin/MemorySessionManagerTest.kt b/GoTrue/src/commonTest/kotlin/MemorySessionManagerTest.kt new file mode 100644 index 00000000..d988f5b5 --- /dev/null +++ b/GoTrue/src/commonTest/kotlin/MemorySessionManagerTest.kt @@ -0,0 +1,25 @@ +import io.github.jan.supabase.gotrue.MemorySessionManager +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +class MemorySessionManagerTest { + + @Test + fun testMemorySessionManager() { + runTest { + val session = userSession() + val sessionManager = MemorySessionManager(session) + assertEquals(session, sessionManager.loadSession()) //Check if the session is loaded correctly + val newSession = userSession(200) + sessionManager.saveSession(newSession) + assertEquals(newSession, sessionManager.loadSession()) //Check if the new session is saved correctly + assertNotEquals(session, sessionManager.loadSession()) //Check if the new session is different from the old session + sessionManager.deleteSession() + assertNull(sessionManager.loadSession()) //Check if the session is deleted correctly + } + } + +} \ No newline at end of file diff --git a/GoTrue/src/commonTest/kotlin/MfaApiTest.kt b/GoTrue/src/commonTest/kotlin/MfaApiTest.kt new file mode 100644 index 00000000..9deabb41 --- /dev/null +++ b/GoTrue/src/commonTest/kotlin/MfaApiTest.kt @@ -0,0 +1,5 @@ +class MfaApiTest { + + //TODO: Implement tests + +} \ No newline at end of file From 70fd56a42d843f1628a6d3b1d10898294b3edf92 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 5 May 2024 12:03:22 +0200 Subject: [PATCH 26/32] add code verifier tests --- .../jan/supabase/gotrue/CodeVerifierCache.kt | 4 ++-- .../kotlin/CodeVerifierCacheTest.kt | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 GoTrue/src/commonTest/kotlin/CodeVerifierCacheTest.kt diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/CodeVerifierCache.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/CodeVerifierCache.kt index e5295b1a..8f1d3227 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/CodeVerifierCache.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/CodeVerifierCache.kt @@ -28,9 +28,9 @@ interface CodeVerifierCache { /** * A [CodeVerifierCache] that uses the [AtomicRef] API. */ -class MemoryCodeVerifierCache: CodeVerifierCache { +class MemoryCodeVerifierCache(codeVerifier: String? = null): CodeVerifierCache { - private var codeVerifier by atomic(null) + private var codeVerifier by atomic(codeVerifier) override suspend fun saveCodeVerifier(codeVerifier: String) { this.codeVerifier = codeVerifier diff --git a/GoTrue/src/commonTest/kotlin/CodeVerifierCacheTest.kt b/GoTrue/src/commonTest/kotlin/CodeVerifierCacheTest.kt new file mode 100644 index 00000000..d22a3424 --- /dev/null +++ b/GoTrue/src/commonTest/kotlin/CodeVerifierCacheTest.kt @@ -0,0 +1,23 @@ +import io.github.jan.supabase.gotrue.MemoryCodeVerifierCache +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CodeVerifierCacheTest { + + @Test + fun testMemoryCodeVerifierCache() { + runTest { + val codeVerifier = "codeVerifier" + val codeVerifierCache = MemoryCodeVerifierCache(codeVerifier) + assertEquals(codeVerifier, codeVerifierCache.loadCodeVerifier()) + val newCodeVerifier = "newCodeVerifier" + codeVerifierCache.saveCodeVerifier(newCodeVerifier) + assertEquals(newCodeVerifier, codeVerifierCache.loadCodeVerifier()) + codeVerifierCache.deleteCodeVerifier() + assertNull(codeVerifierCache.loadCodeVerifier()) + } + } + +} \ No newline at end of file From 3a7eb9f7ab94dd24de3ad251d6220f4cfb595225 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 5 May 2024 12:04:48 +0200 Subject: [PATCH 27/32] improve code --- .../gotrue/providers/builtin/DefaultAuthProvider.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/DefaultAuthProvider.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/DefaultAuthProvider.kt index b6a6fd46..7dff8128 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/DefaultAuthProvider.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/providers/builtin/DefaultAuthProvider.kt @@ -5,11 +5,11 @@ import io.github.jan.supabase.annotations.SupabaseExperimental import io.github.jan.supabase.annotations.SupabaseInternal import io.github.jan.supabase.gotrue.AuthImpl import io.github.jan.supabase.gotrue.FlowType -import io.github.jan.supabase.gotrue.PKCEConstants import io.github.jan.supabase.gotrue.auth import io.github.jan.supabase.gotrue.generateCodeChallenge import io.github.jan.supabase.gotrue.generateCodeVerifier import io.github.jan.supabase.gotrue.providers.AuthProvider +import io.github.jan.supabase.gotrue.putCodeChallenge import io.github.jan.supabase.gotrue.redirectTo import io.github.jan.supabase.gotrue.user.UserSession import io.github.jan.supabase.putJsonObject @@ -20,7 +20,6 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.put /** * A default authentication provider @@ -89,9 +88,8 @@ sealed interface DefaultAuthProvider : AuthProvider { } val response = gotrue.api.postJson(url, buildJsonObject { putJsonObject(body) - codeChallenge?.let { - put("code_challenge", it) - put("code_challenge_method", PKCEConstants.CHALLENGE_METHOD) + if (codeChallenge != null) { + putCodeChallenge(codeChallenge) } }) { redirectUrl?.let { redirectTo(it) } From 59a9fd7f7ba7550294ae644f83c684dd99feb6e1 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 5 May 2024 12:07:49 +0200 Subject: [PATCH 28/32] fix invalid default parameter --- .../src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt index 9226d600..b28176c0 100644 --- a/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt +++ b/GoTrue/src/commonMain/kotlin/io/github/jan/supabase/gotrue/Auth.kt @@ -290,7 +290,7 @@ sealed interface Auth : MainPlugin, CustomSerializationPlugin { /** * Imports a user session and starts auto-refreshing if [autoRefresh] is true */ - suspend fun importSession(session: UserSession, autoRefresh: Boolean = true, source: SessionSource = SessionSource.Unknown) + suspend fun importSession(session: UserSession, autoRefresh: Boolean = config.alwaysAutoRefresh, source: SessionSource = SessionSource.Unknown) /** * Imports the jwt token and retrieves the user profile. From 98820ad49e7553ead53baf23dd308df74a33aa3c Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 5 May 2024 17:42:23 +0200 Subject: [PATCH 29/32] fix tests --- GoTrue/src/commonTest/kotlin/AuthTest.kt | 49 +++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/GoTrue/src/commonTest/kotlin/AuthTest.kt b/GoTrue/src/commonTest/kotlin/AuthTest.kt index a9b55dcb..125000f4 100644 --- a/GoTrue/src/commonTest/kotlin/AuthTest.kt +++ b/GoTrue/src/commonTest/kotlin/AuthTest.kt @@ -4,9 +4,12 @@ import io.github.jan.supabase.gotrue.MemorySessionManager import io.github.jan.supabase.gotrue.SessionStatus import io.github.jan.supabase.gotrue.auth import io.github.jan.supabase.gotrue.minimalSettings +import io.github.jan.supabase.gotrue.providers.Github import io.github.jan.supabase.gotrue.user.UserSession import io.github.jan.supabase.testing.createMockedSupabaseClient +import io.github.jan.supabase.testing.pathAfterVersion import io.github.jan.supabase.testing.respondJson +import io.ktor.http.Url import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -68,7 +71,13 @@ class AuthTest { fun testImportExpiredSession() { runTest { val newSession = userSession() - val client = createMockedSupabaseClient(configuration = configuration) { + val client = createMockedSupabaseClient(configuration = { + install(Auth) { + minimalSettings( + alwaysAutoRefresh = true + ) + } + }) { respondJson(newSession) } client.auth.awaitInitialization() @@ -106,6 +115,44 @@ class AuthTest { } } + @Test + fun testGetOAuthUrl() { + runTest { + val expectedProvider = Github + val expectedRedirectUrl = "https://example.com/auth/callback" + val endpoint = "authorize/custom/endpoint" + val supabaseUrl = "https://id.supabase.co" + val client = createMockedSupabaseClient(supabaseUrl = supabaseUrl, configuration = configuration) + client.auth.awaitInitialization() + val url = Url(client.auth.getOAuthUrl(expectedProvider, expectedRedirectUrl, endpoint) { + queryParams["key"] = "value" + scopes.add("scope1") + scopes.add("scope2") + }) + assertEquals( + endpoint, + url.pathAfterVersion().substring(1) + ) + assertEquals( + expectedProvider.name, + url.parameters["provider"] + ) + assertEquals( + expectedRedirectUrl, + url.parameters["redirect_to"] + ) + assertEquals( + "value", + url.parameters["key"] + ) + assertEquals( + "scope1 scope2", + url.parameters["scopes"] + ) + } + + } + } fun userSession(expiresIn: Long = 3600) = UserSession( From 958123154216fe86119d03bd61411f60e2057f43 Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 6 May 2024 13:04:25 +0200 Subject: [PATCH 30/32] Fix case for FunctionRegion --- .../jan/supabase/functions/FunctionRegion.kt | 30 +++++++++---------- .../jan/supabase/functions/Functions.kt | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/FunctionRegion.kt b/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/FunctionRegion.kt index 2fe639b2..67d39f86 100644 --- a/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/FunctionRegion.kt +++ b/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/FunctionRegion.kt @@ -5,19 +5,19 @@ package io.github.jan.supabase.functions * @param value The value of the region */ enum class FunctionRegion(val value: String) { - Any("any"), - ApNortheast1("ap-northeast-1"), - ApNortheast2("ap-northeast-2"), - ApSouth1("ap-south-1"), - ApSoutheast1("ap-southeast-1"), - ApSoutheast2("ap-southeast-2"), - CaCentral1("ca-central-1"), - EuCentral1("eu-central-1"), - EuWest1("eu-west-1"), - EuWest2("eu-west-2"), - EuWest3("eu-west-3"), - SaEast1("sa-east-1"), - UsEast1("us-east-1"), - UsWest1("us-west-1"), - UsWest2("us-west-2"), + ANY("any"), + AP_NORTHEAST_1("ap-northeast-1"), + AP_NORTHEAST_2("ap-northeast-2"), + AP_SOUTH_1("ap-south-1"), + AP_SOUTHEAST_1("ap-southeast-1"), + AP_SOUTHEAST_2("ap-southeast-2"), + CA_CENTRAL_1("ca-central-1"), + EU_CENTRAL_1("eu-central-1"), + EU_WEST_1("eu-west-1"), + EU_WEST_2("eu-west-2"), + EU_WEST_3("eu-west-3"), + SA_EAST_1("sa-east-1"), + US_EAST_1("us-east-1"), + US_WEST_1("us-west-1"), + US_WEST_2("us-west-2"), } \ No newline at end of file diff --git a/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/Functions.kt b/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/Functions.kt index ef2946d6..fcc80726 100644 --- a/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/Functions.kt +++ b/Functions/src/commonMain/kotlin/io/github/jan/supabase/functions/Functions.kt @@ -148,7 +148,7 @@ class Functions(override val config: Config, override val supabaseClient: Supaba /** * The default region to use when invoking a function */ - var defaultRegion: FunctionRegion = FunctionRegion.Any + var defaultRegion: FunctionRegion = FunctionRegion.ANY } From 0c93053e9ddb192f7238d719617d96eb71702bfd Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 6 May 2024 13:06:50 +0200 Subject: [PATCH 31/32] Log actual error on network requests --- .../io/github/jan/supabase/network/KtorSupabaseHttpClient.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt b/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt index 5bc92e09..b252973e 100644 --- a/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt +++ b/src/commonMain/kotlin/io/github/jan/supabase/network/KtorSupabaseHttpClient.kt @@ -63,7 +63,7 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint was cancelled"} throw e } catch(e: Exception) { - SupabaseClient.LOGGER.e { "${request.method.value} request to endpoint $endPoint failed with exception ${e.message}" } + SupabaseClient.LOGGER.e(e) { "${request.method.value} request to endpoint $endPoint failed with exception ${e.message}" } throw HttpRequestException(e.message ?: "", request) } val responseTime = (response.responseTime.timestamp - response.requestTime.timestamp).milliseconds @@ -88,7 +88,7 @@ class KtorSupabaseHttpClient @SupabaseInternal constructor( SupabaseClient.LOGGER.e { "Request was cancelled on url $url" } throw e } catch(e: Exception) { - SupabaseClient.LOGGER.e { "Request failed with ${e.message} on url $url" } + SupabaseClient.LOGGER.e(e) { "Request failed with ${e.message} on url $url" } throw HttpRequestException(e.message ?: "", request) } return response From cd0f98d94c00b55c1d1c1c7908b66b0e23b6e4ff Mon Sep 17 00:00:00 2001 From: Jan Date: Mon, 6 May 2024 13:17:34 +0200 Subject: [PATCH 32/32] mark postgres methods as experimental --- .../io/github/jan/supabase/realtime/PostgrestExtensions.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt index e25480b7..d58622f1 100644 --- a/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt +++ b/Realtime/src/commonMain/kotlin/io/github/jan/supabase/realtime/PostgrestExtensions.kt @@ -1,5 +1,6 @@ package io.github.jan.supabase.realtime +import io.github.jan.supabase.annotations.SupabaseExperimental import io.github.jan.supabase.postgrest.query.PostgrestQueryBuilder import io.github.jan.supabase.postgrest.query.filter.FilterOperation import io.github.jan.supabase.postgrest.query.filter.PostgrestFilterBuilder @@ -16,6 +17,7 @@ import kotlin.reflect.KProperty1 * @param filter the filter to apply to the select query * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be used */ +@SupabaseExperimental inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( primaryKey: PrimaryKey, channelName: String? = null, @@ -44,6 +46,7 @@ inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( * @param filter the filter to apply to the select query * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be used */ +@SupabaseExperimental inline fun PostgrestQueryBuilder.selectSingleValueAsFlow( primaryKey: KProperty1, channelName: String? = null, @@ -57,6 +60,7 @@ inline fun PostgrestQueryBuilder.selectSingleValueAs * @param filter the filter to apply to the select query * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be used */ +@SupabaseExperimental inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: PrimaryKey, channelName: String? = null, @@ -85,6 +89,7 @@ inline fun PostgrestQueryBuilder.selectAsFlow( * @param filter the filter to apply to the select query * @param channelName the name of the channel to use for the realtime updates. If null, a channel name following the format "schema:table:id" will be used */ +@SupabaseExperimental inline fun PostgrestQueryBuilder.selectAsFlow( primaryKey: KProperty1, channelName: String? = null,