Skip to content
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
86 changes: 51 additions & 35 deletions __tests__/AzureSqlAction.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as fs from 'fs';
import * as path from 'path';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import AzureSqlAction, { IBuildAndPublishInputs, IDacpacActionInputs, ISqlActionInputs, ActionType, SqlPackageAction } from "../src/AzureSqlAction";
import AzureSqlActionHelper from "../src/AzureSqlActionHelper";
import DotnetUtils from '../src/DotnetUtils';
import SqlConnectionConfig from '../src/SqlConnectionConfig';
import SqlUtils from '../src/SqlUtils';
import Constants from '../src/Constants';

jest.mock('fs');

Expand Down Expand Up @@ -39,38 +39,46 @@ describe('AzureSqlAction tests', () => {
expect(getSqlPackagePathSpy).toHaveBeenCalledTimes(1);
});

it('executes sql action for SqlAction type', async () => {
const inputs = getInputs(ActionType.SqlAction) as ISqlActionInputs;
const action = new AzureSqlAction(inputs);

const fsSpy = jest.spyOn(fs, 'readFileSync').mockReturnValue('select * from table1');
const sqlSpy = jest.spyOn(SqlUtils, 'executeSql').mockResolvedValue();

await action.execute();

expect(fsSpy).toHaveBeenCalledTimes(1);
expect(sqlSpy).toHaveBeenCalledTimes(1);
expect(sqlSpy).toHaveBeenCalledWith(inputs.connectionConfig, 'select * from table1');
describe('sql script action tests for different auth types', async () => {
// Format: [test case description, connection string, expected sqlcmd arguments]
const testCases = [
['SQL login', 'Server=testServer.database.windows.net;Database=testDB;User Id=testUser;Password=placeholder', '-S testServer.database.windows.net -d testDB -U "testUser" -i "./TestFile.sql" -t 20'],
['AAD password', 'Server=testServer.database.windows.net;Database=testDB;Authentication=Active Directory Password;User Id=testAADUser;Password=placeholder', '-S testServer.database.windows.net -d testDB --authentication-method=ActiveDirectoryPassword -U "testAADUser" -i "./TestFile.sql" -t 20'],
['AAD service principal', 'Server=testServer.database.windows.net;Database=testDB;Authentication=Active Directory Service Principal;User Id=appId;Password=placeholder', '-S testServer.database.windows.net -d testDB --authentication-method=ActiveDirectoryServicePrincipal -U "appId" -i "./TestFile.sql" -t 20'],
['AAD default', 'Server=testServer.database.windows.net;Database=testDB;Authentication=Active Directory Default;', '-S testServer.database.windows.net -d testDB --authentication-method=ActiveDirectoryDefault -i "./TestFile.sql" -t 20']
];

it.each(testCases)('%s', async (testCase, connectionString, expectedSqlCmdCall) => {
const inputs = getInputs(ActionType.SqlAction, connectionString) as ISqlActionInputs;
const action = new AzureSqlAction(inputs);
const sqlcmdExe = process.platform === 'win32' ? 'sqlcmd.exe' : 'sqlcmd';

const execSpy = jest.spyOn(exec, 'exec').mockResolvedValue(0);
const exportVariableSpy = jest.spyOn(core, 'exportVariable');

await action.execute();

expect(execSpy).toHaveBeenCalledTimes(1);
expect(execSpy).toHaveBeenCalledWith(`"${sqlcmdExe}" ${expectedSqlCmdCall}`);

// Except for AAD default, password/client secret should be set as SqlCmdPassword environment variable
if (inputs.connectionConfig.Config['authentication']?.type !== 'azure-active-directory-default') {
expect(exportVariableSpy).toHaveBeenCalledTimes(1);
expect(exportVariableSpy).toHaveBeenCalledWith(Constants.sqlcmdPasswordEnvVarName, "placeholder");
}
else {
expect(exportVariableSpy).not.toHaveBeenCalled();
}
})
});

it('throws if sql action cannot read file', async () => {
const inputs = getInputs(ActionType.SqlAction) as ISqlActionInputs;
const action = new AzureSqlAction(inputs);
const fsSpy = jest.spyOn(fs, 'readFileSync').mockImplementation(() => {
throw new Error('Cannot read file');
});
it('throws if SqlCmd.exe fails to execute sql', async () => {
let inputs = getInputs(ActionType.SqlAction) as ISqlActionInputs;
let action = new AzureSqlAction(inputs);

let error: Error | undefined;
try {
await action.execute();
}
catch (e) {
error = e;
}
jest.spyOn(exec, 'exec').mockRejectedValue(1);

expect(fsSpy).toHaveBeenCalledTimes(1);
expect(error).toBeDefined();
expect(error!.message).toMatch(`Cannot read contents of file ./TestFile.sql due to error 'Cannot read file'.`);
expect(await action.execute().catch(() => null)).rejects;
});

it('should build and publish database project', async () => {
Expand Down Expand Up @@ -126,11 +134,19 @@ describe('AzureSqlAction tests', () => {
});
});

function getInputs(actionType: ActionType) {
/**
* Gets test inputs used by the SQL action based on actionType.
* @param actionType The action type used for testing
* @param connectionString The custom connection string to be used for the test. If not specified, a default one using SQL login will be used.
* @returns An ActionInputs objects based on the given action type.
*/
function getInputs(actionType: ActionType, connectionString: string = '') {

const defaultConnectionString = 'Server=testServer.database.windows.net;Initial Catalog=testDB;User Id=testUser;Password=placeholder';
const config = connectionString ? new SqlConnectionConfig(connectionString) : new SqlConnectionConfig(defaultConnectionString);

switch(actionType) {
case ActionType.DacpacAction: {
const config = new SqlConnectionConfig('Server=testServer.database.windows.net;Initial Catalog=testDB;User Id=testUser;Password=placeholder');
return {
serverName: config.Config.server,
actionType: ActionType.DacpacAction,
Expand All @@ -141,19 +157,19 @@ function getInputs(actionType: ActionType) {
} as IDacpacActionInputs;
}
case ActionType.SqlAction: {
const config = new SqlConnectionConfig('Server=testServer.database.windows.net;Initial Catalog=testDB;User Id=testUser;Password=placeholder');
return {
serverName: config.Config.server,
actionType: ActionType.SqlAction,
connectionConfig: config,
sqlFile: './TestFile.sql'
sqlFile: './TestFile.sql',
additionalArguments: '-t 20'
} as ISqlActionInputs;
}
case ActionType.BuildAndPublish: {
return {
serverName: 'testServer.database.windows.net',
actionType: ActionType.BuildAndPublish,
connectionConfig: new SqlConnectionConfig('Server=testServer.database.windows.net;Initial Catalog=testDB;User Id=testUser;Password=placeholder'),
connectionConfig: config,
projectFile: './TestProject.sqlproj',
buildArguments: '--verbose --test "test value"'
} as IBuildAndPublishInputs
Expand Down
73 changes: 0 additions & 73 deletions __tests__/SqlUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,80 +143,7 @@ describe('SqlUtils tests', () => {
expect(errorSpy).toHaveBeenNthCalledWith(1, 'Fake error 1');
expect(errorSpy).toHaveBeenNthCalledWith(2, 'Fake error 2');
expect(errorSpy).toHaveBeenNthCalledWith(3, 'Fake error 3');
})

it('should execute sql script', async () => {
const connectionConfig = getConnectionConfig();
const connectSpy = jest.spyOn(mssql, 'connect').mockImplementation(() => {
// Successful connection
return new mssql.ConnectionPool(connectionConfig.Config);
});
const querySpy = jest.spyOn(mssql.ConnectionPool.prototype, 'query').mockImplementation(() => {
return {
recordsets: [{test: "11"}, {test: "22"}],
rowsAffected: [1, 2]
};
});
const consoleSpy = jest.spyOn(console, 'log');

await SqlUtils.executeSql(connectionConfig, 'select * from Table1');

expect(connectSpy).toHaveBeenCalledTimes(1);
expect(querySpy).toHaveBeenCalledTimes(1);
expect(consoleSpy).toHaveBeenCalledTimes(4);
expect(consoleSpy).toHaveBeenNthCalledWith(1, 'Rows affected: 1');
expect(consoleSpy).toHaveBeenNthCalledWith(2, 'Result: {"test":"11"}');
expect(consoleSpy).toHaveBeenNthCalledWith(3, 'Rows affected: 2');
expect(consoleSpy).toHaveBeenNthCalledWith(4, 'Result: {"test":"22"}');
});

it('should fail to execute sql due to connection error', async () => {
const connectSpy = jest.spyOn(mssql, 'connect').mockImplementation(() => {
throw new mssql.ConnectionError(new Error('Failed to connect'));
});
const errorSpy = jest.spyOn(core, 'error');

let error: Error | undefined;
try {
await SqlUtils.executeSql(getConnectionConfig(), 'select * from Table1');
}
catch (e) {
error = e;
}

expect(connectSpy).toHaveBeenCalledTimes(1);
expect(error).toBeDefined();
expect(error!.message).toMatch('Failed to execute query.');
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith('Failed to connect');
});

it('should fail to execute sql due to request error', async () => {
const connectSpy = jest.spyOn(mssql, 'connect').mockImplementation(() => {
// Successful connection
return new mssql.ConnectionPool('');
});
const querySpy = jest.spyOn(mssql.ConnectionPool.prototype, 'query').mockImplementation(() => {
throw new mssql.RequestError(new Error('Failed to query'));
})
const errorSpy = jest.spyOn(core, 'error');

let error: Error | undefined;
try {
await SqlUtils.executeSql(getConnectionConfig(), 'select * from Table1');
}
catch (e) {
error = e;
}

expect(connectSpy).toHaveBeenCalledTimes(1);
expect(querySpy).toHaveBeenCalledTimes(1);
expect(error).toBeDefined();
expect(error!.message).toMatch('Failed to execute query.');
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).toHaveBeenCalledWith('Failed to query');
});

});

function getConnectionConfig(): SqlConnectionConfig {
Expand Down
8 changes: 4 additions & 4 deletions __tests__/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
import { AuthorizerFactory } from 'azure-actions-webclient/AuthorizerFactory';

import run from "../src/main";
Expand All @@ -16,9 +15,10 @@ jest.mock('../src/AzureSqlResourceManager');
jest.mock('../src/Setup');

describe('main.ts tests', () => {
afterEach(() => {
beforeEach(() => {
jest.restoreAllMocks();
})
jest.clearAllMocks();
});

it('gets inputs and executes build and publish action', async () => {
const resolveFilePathSpy = jest.spyOn(AzureSqlActionHelper, 'resolveFilePath').mockReturnValue('./TestProject.sqlproj');
Expand All @@ -45,7 +45,7 @@ describe('main.ts tests', () => {
expect(AzureSqlAction).toHaveBeenCalled();
expect(detectIPAddressSpy).toHaveBeenCalled();
expect(getAuthorizerSpy).not.toHaveBeenCalled();
expect(getInputSpy).toHaveBeenCalledTimes(10);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was wrong as the mocks weren't getting cleared correctly before.

expect(getInputSpy).toHaveBeenCalledTimes(9);
expect(resolveFilePathSpy).toHaveBeenCalled();
expect(addFirewallRuleSpy).not.toHaveBeenCalled();
expect(actionExecuteSpy).toHaveBeenCalled();
Expand Down
2 changes: 1 addition & 1 deletion lib/main.js

Large diffs are not rendered by default.

51 changes: 42 additions & 9 deletions src/AzureSqlAction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as fs from 'fs';
import * as path from 'path';
import * as core from '@actions/core';
import * as exec from '@actions/exec';
Expand All @@ -7,7 +6,6 @@ import AzureSqlActionHelper from './AzureSqlActionHelper';
import DotnetUtils from './DotnetUtils';
import Constants from './Constants';
import SqlConnectionConfig from './SqlConnectionConfig';
import SqlUtils from './SqlUtils';

export enum ActionType {
DacpacAction,
Expand Down Expand Up @@ -90,15 +88,50 @@ export default class AzureSqlAction {

private async _executeSqlFile(inputs: ISqlActionInputs) {
core.debug('Begin executing sql script');
let scriptContents: string;
try {
scriptContents = fs.readFileSync(inputs.sqlFile, "utf8");

// sqlcmd should be added to PATH already, we just need to see if need to add ".exe" for Windows
let sqlCmdPath: string;
switch (process.platform) {
case "win32":
sqlCmdPath = "sqlcmd.exe";
break;
case "linux":
case "darwin":
sqlCmdPath = "sqlcmd";
break;
default:
throw new Error(`Platform ${process.platform} is not supported.`);
}
catch (e) {
throw new Error(`Cannot read contents of file ${inputs.sqlFile} due to error '${e.message}'.`);

// Determine the correct sqlcmd arguments based on the auth type in connectionConfig
let sqlcmdCall = `"${sqlCmdPath}" -S ${inputs.serverName} -d ${inputs.connectionConfig.Config.database}`;
const authentication = inputs.connectionConfig.Config['authentication'];
switch (authentication?.type) {
case undefined:
// No authentication type defaults SQL login
sqlcmdCall += ` -U "${inputs.connectionConfig.Config.user}"`;
core.exportVariable(Constants.sqlcmdPasswordEnvVarName, inputs.connectionConfig.Config.password);
break;

case 'azure-active-directory-default':
sqlcmdCall += ` --authentication-method=ActiveDirectoryDefault`;
break;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for AD default does the user know they need to set the various environment variables like AZURE_CLIENT_ID and AZURE_TENANT_ID?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll update the README for documentation

case 'azure-active-directory-password':
sqlcmdCall += ` --authentication-method=ActiveDirectoryPassword -U "${authentication.options.userName}"`;
core.exportVariable(Constants.sqlcmdPasswordEnvVarName, authentication.options.password);
break;

case 'azure-active-directory-service-principal-secret':
sqlcmdCall += ` --authentication-method=ActiveDirectoryServicePrincipal -U "${inputs.connectionConfig.Config.user}"`;
core.exportVariable(Constants.sqlcmdPasswordEnvVarName, authentication.options.clientSecret);
break;

default:
throw new Error(`Authentication type ${authentication.type} is not supported.`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could support integrated auth on Windows now, and eventually on Linux when microsoft/go-mssqldb#35 is merged to the driver.

}
await SqlUtils.executeSql(inputs.connectionConfig, scriptContents);

await exec.exec(`${sqlcmdCall} -i "${inputs.sqlFile}" ${inputs.additionalArguments}`);

console.log(`Successfully executed SQL file on target database.`);
}
Expand Down
2 changes: 2 additions & 0 deletions src/Constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export default class Constants {

static readonly dacpacExtension = ".dacpac";
static readonly sqlprojExtension = ".sqlproj";

static readonly sqlcmdPasswordEnvVarName = "SQLCMDPASSWORD";
}
34 changes: 0 additions & 34 deletions src/SqlUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,40 +116,6 @@ export default class SqlUtils {
return ipAddress;
}

/**
* Opens a new connection to the database and executes a T-SQL script on it.
* @param connectionConfig Config object for the connection
* @param command T-SQL to be executed on the connection
*/
static async executeSql(connectionConfig: SqlConnectionConfig, command: string): Promise<void> {
let pool: mssql.ConnectionPool | undefined;
try {
pool = await mssql.connect(connectionConfig.Config);
const result = await pool.query(command);

// Display result
for (let i = 0; i < result.recordsets.length; i++) {
console.log(`Rows affected: ${result.rowsAffected[i]}`);
// Displays query result as JSON string. Future improvement: IRecordSet has function toTable()
// which returns more metadata about the query results that can be used to build a cleaner output
console.log(`Result: ${JSON.stringify(result.recordsets[i])}`);
}
}
catch (connectionError) {
if (connectionError instanceof mssql.MSSQLError) {
this.reportMSSQLError(connectionError);
throw new Error('Failed to execute query.');
}
else {
// Unknown error
throw connectionError;
}
}
finally {
pool?.close();
}
}

/**
* Outputs the contents of a MSSQLError to the Github Action console.
* MSSQLError may contain a single error or an AggregateError.
Expand Down