Skip to content

Snowflake V2 Connector - fix handling nulls and add integration tests #3920

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions certified-connectors/Snowflake v2/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
!/.gitignore
!/SnowflakeV2CoreLogic/
!/SnowflakeTestApp/
!/SnowflakeTestApp.Tests/
!/ConnectorArtifacts/

# .config folder is not ignored
Expand Down Expand Up @@ -64,6 +65,9 @@ QLogs
## Secret Disclosure Risks
###

# Test configuration file with secrets
SnowflakeTestApp/Mocks/ConnectionParametersProviderMock.cs

# *.pfx
*.[Pp][Ff][Xx]

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
{
"swagger": "2.0",
"info": {
"version": "1.1",
"title": "Snowflake",
"description": "Snowflake Connector allows you to build canvas apps and surface Snowflake data in Virtual Tables, while also enabling faster data processing and analytics compared to traditional solutions.",
"x-ms-api-annotation": {
"status": "Preview"
"info": {
"version": "1.1",
"title": "Snowflake",
"description": "Snowflake Connector allows you to build canvas apps and surface Snowflake data in Virtual Tables, while also enabling faster data processing and analytics compared to traditional solutions. This version fixes support for null values in columns of date and time types.",
"x-ms-api-annotation": {
"status": "Preview"
},
"x-ms-keywords": [
"snowflake"
]
},
"x-ms-keywords": [
"snowflake"
]
},
"host": "localhost:56800",
"basePath": "/",
"schemes": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json;
using SnowflakeTestApp.Tests.Infrastructure;

namespace SnowflakeTestApp.Tests
{
/// <summary>
/// Base class for integration tests providing common functionality and test data management.
/// </summary>
[TestClass]
public abstract class BaseIntegrationTest
{
private const int APPLICATION_HEALTH_CHECK_TIMEOUT_SECONDS = 5;
private const string BEARER_TOKEN_CONFIGURATION_ERROR =
"Bearer token not configured. Please update ConnectionParametersProviderMock.TestBearerToken with a valid OAuth bearer token. See README.md for instructions.";
private const string APPLICATION_NOT_RUNNING_ERROR =
"SnowflakeTestApp is not running at {0}. Please start the application before running tests. Error: {1}";

protected static AccessTokenService AccessTokenService;
protected string BaseUrl => TestData.BaseUrl;
protected HttpClient HttpClient;
protected static TestDataSeeder DataSeeder;
protected static List<TestDataRecord> SeededTestData => DataSeeder?.SeededRecords ?? new List<TestDataRecord>();
public TestContext TestContext { get; set; }

[AssemblyInitialize]
public static void AssemblyInitialize(TestContext context)
{
InitializeAccessTokenService();
InitializeTestDataSeeder();
SeedTestData();
}

[AssemblyCleanup]
public static void AssemblyCleanup()
{
CleanupTestResources();
}

[TestInitialize]
public virtual void TestInitialize()
{
HttpClient = CreateHttpClient();
}

[TestCleanup]
public virtual void TestCleanup()
{
HttpClient?.Dispose();
}

protected string GetTestToken()
{
var service = new AccessTokenService(TestData.TenantId, TestData.ClientId, TestData.ClientSecret, TestData.Scope);
var token = service.GetAccessTokenAsync().GetAwaiter().GetResult();
return token;
}

protected void EnsureApplicationIsRunning()
{
try
{
ValidateApplicationHealth();
}
catch (Exception ex)
{
Assert.Inconclusive(string.Format(APPLICATION_NOT_RUNNING_ERROR, BaseUrl, ex.Message));
}
}

protected T DeserializeResponse<T>(HttpResponseMessage response)
{
var content = response.Content.ReadAsStringAsync().Result;

try
{
return JsonConvert.DeserializeObject<T>(content);
}
catch (JsonException ex)
{
var errorMessage = $"Failed to deserialize response content as {typeof(T).Name}. Content: {content}. Error: {ex.Message}";
Assert.Fail(errorMessage);
return default(T);
}
}

protected StringContent CreateJsonContent(object data)
{
var json = JsonConvert.SerializeObject(data);
return new StringContent(json, System.Text.Encoding.UTF8, "application/json");
}

protected async Task<List<TestDataRecord>> FetchActualDataFromDatabase(string tableName = null)
{
using (var httpClient = CreateHttpClient())
{
var dataSeeder = new TestDataSeeder(httpClient, TestData.BaseUrl, AccessTokenService);
return await dataSeeder.FetchTestDataFromDatabase(tableName);
}
}

protected async Task<TestDataRecord> FetchActualRecordById(int id, string tableName = null)
{
using (var httpClient = CreateHttpClient())
{
var dataSeeder = new TestDataSeeder(httpClient, TestData.BaseUrl, AccessTokenService);
return await dataSeeder.FetchTestRecordById(id, tableName);
}
}

protected void ValidateDataMatches(List<TestDataRecord> expectedRecords, List<TestDataRecord> actualRecords, string message = null)
{
var assertionMessage = message ?? "Database records should match seeded test data";

Assert.AreEqual(expectedRecords.Count, actualRecords.Count, $"{assertionMessage}: Record count mismatch");

ValidateIndividualRecords(expectedRecords, actualRecords, assertionMessage);
}

protected void ValidateRecordMatches(TestDataRecord expected, TestDataRecord actual, string message = null)
{
var assertionMessage = message ?? $"Record with ID {expected?.Id} should match expected values";

Assert.IsNotNull(actual, $"{assertionMessage}: Record not found in database");
Assert.AreEqual(expected, actual, assertionMessage);
}
private static void InitializeAccessTokenService()
{
AccessTokenService = new AccessTokenService(TestData.TenantId, TestData.ClientId, TestData.ClientSecret, TestData.Scope);
}
private static void InitializeTestDataSeeder()
{
var httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(TestData.DefaultTimeoutSeconds)
};

DataSeeder = new TestDataSeeder(httpClient, TestData.BaseUrl, AccessTokenService);
}

private static void SeedTestData()
{
try
{
var success = DataSeeder.EnsureTestTableExistsAndSeed(TestData.DefaultTable, TestData.DefaultDataset)
.GetAwaiter().GetResult();

if (!success)
{
throw new InvalidOperationException($"Failed to setup test table '{TestData.DefaultTable}'");
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Test data seeding failed: {ex.Message}", ex);
}
}

private static void CleanupTestResources()
{
try
{
DataSeeder?.CleanupTestTable(TestData.DefaultTable).GetAwaiter().GetResult();
DataSeeder?.Dispose();
}
catch (Exception)
{
// Ignore cleanup errors to prevent masking test failures
}
}

private HttpClient CreateHttpClient()
{
return new HttpClient
{
Timeout = TimeSpan.FromSeconds(TestData.DefaultTimeoutSeconds)
};
}

private void ValidateApplicationHealth()
{
using (var client = new HttpClient { Timeout = TimeSpan.FromSeconds(APPLICATION_HEALTH_CHECK_TIMEOUT_SECONDS) })
{
var response = client.GetAsync(BaseUrl).Result;
}
}

private void ValidateIndividualRecords(List<TestDataRecord> expectedRecords, List<TestDataRecord> actualRecords, string assertionMessage)
{
foreach (var expected in expectedRecords)
{
var actual = actualRecords.FirstOrDefault(r => r.Id == expected.Id);

Assert.IsNotNull(actual, $"{assertionMessage}: Record with ID {expected.Id} not found in database");
Assert.AreEqual(expected, actual, $"{assertionMessage}: Record with ID {expected.Id} does not match expected values");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace SnowflakeTestApp.Tests.Connection
{
/// <summary>
/// Integration tests for the /testconnection endpoint.
/// </summary>
[TestClass]
public class TestConnectionEndpointIntegrationTest : BaseIntegrationTest
{
private const string TEST_CONNECTION_ENDPOINT = "/testconnection";
private const string INVALID_TOKEN = "invalid-token";
private const string MALFORMED_AUTH_HEADER = "InvalidFormat";
private const string BEARER_TOKEN_MISSING_ERROR = "Bearer token is missing in the HTTP request authorization header.";
private const string INVALID_OAUTH_TOKEN_ERROR = "Invalid OAuth access token.";
private const string POST_METHOD_NOT_ALLOWED_ERROR = "The requested resource does not support http method 'POST'.";

[TestInitialize]
public override void TestInitialize()
{
base.TestInitialize();
EnsureApplicationIsRunning();
}

/// <summary>
/// Test the /testconnection endpoint with authentication
/// </summary>
[TestMethod]
public async Task TestConnectionEndpoint_WithValidAuth_ReturnsOK()
{
var testToken = GetTestToken();
AddAuthorizationHeader(testToken);

var response = await HttpClient.GetAsync($"{BaseUrl}{TEST_CONNECTION_ENDPOINT}");

Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
}

/// <summary>
/// Test the endpoint without authentication
/// </summary>
[TestMethod]
public async Task TestConnectionEndpoint_WithoutAuth_ReturnsInternalServerError()
{
var response = await HttpClient.GetAsync($"{BaseUrl}{TEST_CONNECTION_ENDPOINT}");
var content = await response.Content.ReadAsStringAsync();

Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode);
StringAssert.Contains(content, BEARER_TOKEN_MISSING_ERROR);
}

/// <summary>
/// Test the endpoint with invalid authentication
/// </summary>
[TestMethod]
public async Task TestConnectionEndpoint_WithInvalidAuth_ReturnsInternalServerError()
{
AddAuthorizationHeader(INVALID_TOKEN);

var response = await HttpClient.GetAsync($"{BaseUrl}{TEST_CONNECTION_ENDPOINT}");
var content = await response.Content.ReadAsStringAsync();

Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode);
StringAssert.Contains(content, INVALID_OAUTH_TOKEN_ERROR);
}

/// <summary>
/// Test the endpoint with malformed authorization header
/// </summary>
[TestMethod]
public async Task TestConnectionEndpoint_WithMalformedAuth_ReturnsInternalServerError()
{
HttpClient.DefaultRequestHeaders.Add("Authorization", MALFORMED_AUTH_HEADER);

var response = await HttpClient.GetAsync($"{BaseUrl}{TEST_CONNECTION_ENDPOINT}");
var content = await response.Content.ReadAsStringAsync();

Assert.AreEqual(HttpStatusCode.InternalServerError, response.StatusCode);
StringAssert.Contains(content, INVALID_OAUTH_TOKEN_ERROR);
}

/// <summary>
/// Test the endpoint with POST method (should not be allowed)
/// </summary>
[TestMethod]
public async Task TestConnectionEndpoint_WithPOST_ReturnsMethodNotAllowed()
{
var testToken = GetTestToken();
AddAuthorizationHeader(testToken);

var response = await HttpClient.PostAsync($"{BaseUrl}{TEST_CONNECTION_ENDPOINT}", new StringContent(""));
var content = await response.Content.ReadAsStringAsync();

Assert.AreEqual(HttpStatusCode.MethodNotAllowed, response.StatusCode);
StringAssert.Contains(content, POST_METHOD_NOT_ALLOWED_ERROR);
}

private void AddAuthorizationHeader(string token)
{
HttpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
}
}
}
Loading