2020-02-11 14:49:46 +00:00
import * as http from 'http'
import * as io from '../../io/src/io'
import * as net from 'net'
import * as path from 'path'
2021-11-29 22:19:02 +00:00
import { mocked } from 'ts-jest/utils'
import { exec , execSync } from 'child_process'
import { createGunzip } from 'zlib'
import { promisify } from 'util'
2020-03-12 13:50:27 +00:00
import { UploadHttpClient } from '../src/internal/upload-http-client'
2020-02-11 14:49:46 +00:00
import * as core from '@actions/core'
import { promises as fs } from 'fs'
2020-03-12 13:50:27 +00:00
import { getRuntimeUrl } from '../src/internal/config-variables'
2020-02-11 14:49:46 +00:00
import { HttpClient , HttpClientResponse } from '@actions/http-client'
import {
ArtifactResponse ,
PatchArtifactSizeSuccessResponse
2020-03-12 13:50:27 +00:00
} from '../src/internal/contracts'
import { UploadSpecification } from '../src/internal/upload-specification'
2020-04-08 14:55:18 +00:00
import { getArtifactUrl } from '../src/internal/utils'
2020-09-18 15:30:00 +00:00
import { UploadOptions } from '../src/internal/upload-options'
2020-02-11 14:49:46 +00:00
const root = path . join ( __dirname , '_temp' , 'artifact-upload' )
const file1Path = path . join ( root , 'file1.txt' )
const file2Path = path . join ( root , 'file2.txt' )
const file3Path = path . join ( root , 'folder1' , 'file3.txt' )
const file4Path = path . join ( root , 'folder1' , 'file4.txt' )
const file5Path = path . join ( root , 'folder1' , 'folder2' , 'folder3' , 'file5.txt' )
let file1Size = 0
let file2Size = 0
let file3Size = 0
let file4Size = 0
let file5Size = 0
2020-03-12 13:50:27 +00:00
jest . mock ( '../src/internal/config-variables' )
2020-02-11 14:49:46 +00:00
jest . mock ( '@actions/http-client' )
describe ( 'Upload Tests' , ( ) = > {
beforeAll ( async ( ) = > {
// mock all output so that there is less noise when running tests
jest . spyOn ( console , 'log' ) . mockImplementation ( ( ) = > { } )
jest . spyOn ( core , 'debug' ) . mockImplementation ( ( ) = > { } )
jest . spyOn ( core , 'info' ) . mockImplementation ( ( ) = > { } )
jest . spyOn ( core , 'warning' ) . mockImplementation ( ( ) = > { } )
2020-04-08 14:55:18 +00:00
jest . spyOn ( core , 'error' ) . mockImplementation ( ( ) = > { } )
2020-02-11 14:49:46 +00:00
// setup mocking for calls that got through the HttpClient
setupHttpClientMock ( )
// clear temp directory and create files that will be "uploaded"
await io . rmRF ( root )
await fs . mkdir ( path . join ( root , 'folder1' , 'folder2' , 'folder3' ) , {
recursive : true
} )
await fs . writeFile ( file1Path , 'this is file 1' )
await fs . writeFile ( file2Path , 'this is file 2' )
await fs . writeFile ( file3Path , 'this is file 3' )
await fs . writeFile ( file4Path , 'this is file 4' )
await fs . writeFile ( file5Path , 'this is file 5' )
/ *
Directory structure for files that get created :
root /
file1 . txt
file2 . txt
folder1 /
file3 . txt
file4 . txt
folder2 /
folder3 /
file5 . txt
* /
file1Size = ( await fs . stat ( file1Path ) ) . size
file2Size = ( await fs . stat ( file2Path ) ) . size
file3Size = ( await fs . stat ( file3Path ) ) . size
file4Size = ( await fs . stat ( file4Path ) ) . size
file5Size = ( await fs . stat ( file5Path ) ) . size
} )
/ * *
* Artifact Creation Tests
* /
it ( 'Create Artifact - Success' , async ( ) = > {
const artifactName = 'valid-artifact-name'
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
const response = await uploadHttpClient . createArtifactInFileContainer (
artifactName
)
expect ( response . containerId ) . toEqual ( '13' )
expect ( response . size ) . toEqual ( - 1 )
expect ( response . signedContent ) . toEqual ( 'false' )
expect ( response . fileContainerResourceUrl ) . toEqual (
` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
)
expect ( response . type ) . toEqual ( 'actions_storage' )
expect ( response . name ) . toEqual ( artifactName )
expect ( response . url ) . toEqual (
` ${ getRuntimeUrl ( ) } _apis/pipelines/1/runs/1/artifacts?artifactName= ${ artifactName } `
)
} )
it ( 'Create Artifact - Failure' , async ( ) = > {
const artifactName = 'invalid-artifact-name'
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
expect (
uploadHttpClient . createArtifactInFileContainer ( artifactName )
) . rejects . toEqual (
new Error (
2020-12-18 20:40:50 +00:00
` Create Artifact Container failed: The artifact name invalid-artifact-name is not valid. Request URL ${ getArtifactUrl ( ) } `
2020-02-11 14:49:46 +00:00
)
)
} )
2020-09-18 15:30:00 +00:00
it ( 'Create Artifact - Retention Less Than Min Value Error' , async ( ) = > {
const artifactName = 'valid-artifact-name'
const options : UploadOptions = {
retentionDays : - 1
}
const uploadHttpClient = new UploadHttpClient ( )
expect (
uploadHttpClient . createArtifactInFileContainer ( artifactName , options )
) . rejects . toEqual ( new Error ( 'Invalid retention, minimum value is 1.' ) )
} )
2020-04-09 15:14:12 +00:00
it ( 'Create Artifact - Storage Quota Error' , async ( ) = > {
const artifactName = 'storage-quota-hit'
const uploadHttpClient = new UploadHttpClient ( )
expect (
uploadHttpClient . createArtifactInFileContainer ( artifactName )
) . rejects . toEqual (
new Error (
2020-12-18 20:40:50 +00:00
'Create Artifact Container failed: Artifact storage quota has been hit. Unable to upload any new artifacts'
2020-04-09 15:14:12 +00:00
)
)
} )
2020-02-11 14:49:46 +00:00
/ * *
* Artifact Upload Tests
* /
it ( 'Upload Artifact - Success' , async ( ) = > {
/ * *
* Normally search . findFilesToUpload ( ) would be used for providing information about what to upload . These tests however
* focuses solely on the upload APIs so searchResult [ ] will be hard - coded
* /
const artifactName = 'successful-artifact'
const uploadSpecification : UploadSpecification [ ] = [
{
absoluteFilePath : file1Path ,
uploadFilePath : ` ${ artifactName } /file1.txt `
} ,
{
absoluteFilePath : file2Path ,
uploadFilePath : ` ${ artifactName } /file2.txt `
} ,
{
absoluteFilePath : file3Path ,
uploadFilePath : ` ${ artifactName } /folder1/file3.txt `
} ,
{
absoluteFilePath : file4Path ,
uploadFilePath : ` ${ artifactName } /folder1/file4.txt `
} ,
{
absoluteFilePath : file5Path ,
uploadFilePath : ` ${ artifactName } /folder1/folder2/folder3/file5.txt `
}
]
const expectedTotalSize =
file1Size + file2Size + file3Size + file4Size + file5Size
const uploadUrl = ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
const uploadResult = await uploadHttpClient . uploadArtifactToFileContainer (
uploadUrl ,
uploadSpecification
)
expect ( uploadResult . failedItems . length ) . toEqual ( 0 )
2020-03-12 13:50:27 +00:00
expect ( uploadResult . uploadSize ) . toEqual ( expectedTotalSize )
2020-02-11 14:49:46 +00:00
} )
2021-11-29 22:19:02 +00:00
function hasMkfifo ( ) : boolean {
try {
// make sure we drain the stdout
2021-12-14 20:50:50 +00:00
return (
process . platform !== 'win32' &&
execSync ( 'which mkfifo' ) . toString ( ) . length > 0
)
2021-11-29 22:19:02 +00:00
} catch ( e ) {
return false
}
}
const withMkfifoIt = hasMkfifo ( ) ? it : it.skip
withMkfifoIt (
'Upload Artifact with content from named pipe - Success' ,
async ( ) = > {
// create a named pipe 'pipe' with content 'hello pipe'
const content = Buffer . from ( 'hello pipe' )
const pipeFilePath = path . join ( root , 'pipe' )
await promisify ( exec ) ( 'mkfifo pipe' , { cwd : root } )
// don't want to await here as that would block until read
fs . writeFile ( pipeFilePath , content )
const artifactName = 'successful-artifact'
const uploadSpecification : UploadSpecification [ ] = [
{
absoluteFilePath : pipeFilePath ,
uploadFilePath : ` ${ artifactName } /pipe `
}
]
const uploadUrl = ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
const uploadHttpClient = new UploadHttpClient ( )
const uploadResult = await uploadHttpClient . uploadArtifactToFileContainer (
uploadUrl ,
uploadSpecification
)
// accesses the ReadableStream that was passed into sendStream
// eslint-disable-next-line @typescript-eslint/unbound-method
const stream = mocked ( HttpClient . prototype . sendStream ) . mock . calls [ 0 ] [ 2 ]
expect ( stream ) . not . toBeNull ( )
// decompresses the passed stream
const data : Buffer [ ] = [ ]
for await ( const chunk of stream . pipe ( createGunzip ( ) ) ) {
data . push ( Buffer . isBuffer ( chunk ) ? chunk : Buffer.from ( chunk as string ) )
}
const uploaded = Buffer . concat ( data )
expect ( uploadResult . failedItems . length ) . toEqual ( 0 )
expect ( uploaded ) . toEqual ( content )
}
)
2020-02-11 14:49:46 +00:00
it ( 'Upload Artifact - Failed Single File Upload' , async ( ) = > {
const uploadSpecification : UploadSpecification [ ] = [
{
absoluteFilePath : file1Path ,
uploadFilePath : ` this-file-upload-will-fail `
}
]
const uploadUrl = ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
const uploadResult = await uploadHttpClient . uploadArtifactToFileContainer (
uploadUrl ,
uploadSpecification
)
expect ( uploadResult . failedItems . length ) . toEqual ( 1 )
2020-03-12 13:50:27 +00:00
expect ( uploadResult . uploadSize ) . toEqual ( 0 )
2020-02-11 14:49:46 +00:00
} )
it ( 'Upload Artifact - Partial Upload Continue On Error' , async ( ) = > {
const artifactName = 'partial-artifact'
const uploadSpecification : UploadSpecification [ ] = [
{
absoluteFilePath : file1Path ,
uploadFilePath : ` ${ artifactName } /file1.txt `
} ,
{
absoluteFilePath : file2Path ,
uploadFilePath : ` ${ artifactName } /file2.txt `
} ,
{
absoluteFilePath : file3Path ,
uploadFilePath : ` ${ artifactName } /folder1/file3.txt `
} ,
{
absoluteFilePath : file4Path ,
uploadFilePath : ` this-file-upload-will-fail `
} ,
{
absoluteFilePath : file5Path ,
uploadFilePath : ` ${ artifactName } /folder1/folder2/folder3/file5.txt `
}
]
const expectedPartialSize = file1Size + file2Size + file4Size + file5Size
const uploadUrl = ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
const uploadResult = await uploadHttpClient . uploadArtifactToFileContainer (
uploadUrl ,
uploadSpecification ,
{ continueOnError : true }
)
expect ( uploadResult . failedItems . length ) . toEqual ( 1 )
2020-03-12 13:50:27 +00:00
expect ( uploadResult . uploadSize ) . toEqual ( expectedPartialSize )
2020-02-11 14:49:46 +00:00
} )
it ( 'Upload Artifact - Partial Upload Fail Fast' , async ( ) = > {
const artifactName = 'partial-artifact'
const uploadSpecification : UploadSpecification [ ] = [
{
absoluteFilePath : file1Path ,
uploadFilePath : ` ${ artifactName } /file1.txt `
} ,
{
absoluteFilePath : file2Path ,
uploadFilePath : ` ${ artifactName } /file2.txt `
} ,
{
absoluteFilePath : file3Path ,
uploadFilePath : ` ${ artifactName } /folder1/file3.txt `
} ,
{
absoluteFilePath : file4Path ,
uploadFilePath : ` this-file-upload-will-fail `
} ,
{
absoluteFilePath : file5Path ,
uploadFilePath : ` ${ artifactName } /folder1/folder2/folder3/file5.txt `
}
]
const expectedPartialSize = file1Size + file2Size + file3Size
const uploadUrl = ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
const uploadResult = await uploadHttpClient . uploadArtifactToFileContainer (
uploadUrl ,
uploadSpecification ,
{ continueOnError : false }
)
expect ( uploadResult . failedItems . length ) . toEqual ( 2 )
2020-03-12 13:50:27 +00:00
expect ( uploadResult . uploadSize ) . toEqual ( expectedPartialSize )
2020-02-11 14:49:46 +00:00
} )
it ( 'Upload Artifact - Failed upload with no options' , async ( ) = > {
const artifactName = 'partial-artifact'
const uploadSpecification : UploadSpecification [ ] = [
{
absoluteFilePath : file1Path ,
uploadFilePath : ` ${ artifactName } /file1.txt `
} ,
{
absoluteFilePath : file2Path ,
uploadFilePath : ` ${ artifactName } /file2.txt `
} ,
{
absoluteFilePath : file3Path ,
uploadFilePath : ` ${ artifactName } /folder1/file3.txt `
} ,
{
absoluteFilePath : file4Path ,
uploadFilePath : ` this-file-upload-will-fail `
} ,
{
absoluteFilePath : file5Path ,
uploadFilePath : ` ${ artifactName } /folder1/folder2/folder3/file5.txt `
}
]
const expectedPartialSize = file1Size + file2Size + file3Size + file5Size
const uploadUrl = ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
const uploadResult = await uploadHttpClient . uploadArtifactToFileContainer (
uploadUrl ,
uploadSpecification
)
expect ( uploadResult . failedItems . length ) . toEqual ( 1 )
2020-03-12 13:50:27 +00:00
expect ( uploadResult . uploadSize ) . toEqual ( expectedPartialSize )
2020-02-11 14:49:46 +00:00
} )
it ( 'Upload Artifact - Failed upload with empty options' , async ( ) = > {
const artifactName = 'partial-artifact'
const uploadSpecification : UploadSpecification [ ] = [
{
absoluteFilePath : file1Path ,
uploadFilePath : ` ${ artifactName } /file1.txt `
} ,
{
absoluteFilePath : file2Path ,
uploadFilePath : ` ${ artifactName } /file2.txt `
} ,
{
absoluteFilePath : file3Path ,
uploadFilePath : ` ${ artifactName } /folder1/file3.txt `
} ,
{
absoluteFilePath : file4Path ,
uploadFilePath : ` this-file-upload-will-fail `
} ,
{
absoluteFilePath : file5Path ,
uploadFilePath : ` ${ artifactName } /folder1/folder2/folder3/file5.txt `
}
]
const expectedPartialSize = file1Size + file2Size + file3Size + file5Size
const uploadUrl = ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
const uploadResult = await uploadHttpClient . uploadArtifactToFileContainer (
uploadUrl ,
uploadSpecification ,
{ }
)
expect ( uploadResult . failedItems . length ) . toEqual ( 1 )
2020-03-12 13:50:27 +00:00
expect ( uploadResult . uploadSize ) . toEqual ( expectedPartialSize )
2020-02-11 14:49:46 +00:00
} )
/ * *
* Artifact Association Tests
* /
it ( 'Associate Artifact - Success' , async ( ) = > {
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
expect ( async ( ) = > {
uploadHttpClient . patchArtifactSize ( 130 , 'my-artifact' )
} ) . not . toThrow ( )
} )
it ( 'Associate Artifact - Not Found' , async ( ) = > {
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
expect (
uploadHttpClient . patchArtifactSize ( 100 , 'non-existent-artifact' )
) . rejects . toThrow (
'An Artifact with the name non-existent-artifact was not found'
)
} )
it ( 'Associate Artifact - Error' , async ( ) = > {
2020-03-12 13:50:27 +00:00
const uploadHttpClient = new UploadHttpClient ( )
2020-02-11 14:49:46 +00:00
expect (
uploadHttpClient . patchArtifactSize ( - 2 , 'my-artifact' )
2020-12-18 20:40:50 +00:00
) . rejects . toThrow (
'Finalize artifact upload failed: Artifact service responded with 400'
)
2020-02-11 14:49:46 +00:00
} )
/ * *
* Helpers used to setup mocking for the HttpClient
* /
async function emptyMockReadBody ( ) : Promise < string > {
return new Promise ( resolve = > {
resolve ( )
} )
}
function setupHttpClientMock ( ) : void {
/ * *
* Mocks Post calls that are used during Artifact Creation tests
*
* Simulates success and non - success status codes depending on the artifact name along with an appropriate
* payload that represents an expected response
* /
jest
. spyOn ( HttpClient . prototype , 'post' )
. mockImplementation ( async ( requestdata , data ) = > {
// parse the input data and use the provided artifact name as part of the response
const inputData = JSON . parse ( data )
const mockMessage = new http . IncomingMessage ( new net . Socket ( ) )
let mockReadBody = emptyMockReadBody
if ( inputData . Name === 'invalid-artifact-name' ) {
mockMessage . statusCode = 400
2020-04-09 15:14:12 +00:00
} else if ( inputData . Name === 'storage-quota-hit' ) {
mockMessage . statusCode = 403
2020-02-11 14:49:46 +00:00
} else {
mockMessage . statusCode = 201
const response : ArtifactResponse = {
containerId : '13' ,
size : - 1 ,
signedContent : 'false' ,
fileContainerResourceUrl : ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 ` ,
type : 'actions_storage' ,
name : inputData.Name ,
url : ` ${ getRuntimeUrl ( ) } _apis/pipelines/1/runs/1/artifacts?artifactName= ${
inputData . Name
} `
}
const returnData : string = JSON . stringify ( response , null , 2 )
mockReadBody = async function ( ) : Promise < string > {
return new Promise ( resolve = > {
resolve ( returnData )
} )
}
}
return new Promise < HttpClientResponse > ( resolve = > {
resolve ( {
message : mockMessage ,
readBody : mockReadBody
} )
} )
} )
/ * *
* Mocks SendStream calls that are made during Artifact Upload tests
*
* A 500 response is used to simulate a failed upload stream . The uploadUrl can be set to
* include 'fail' to specify that the upload should fail
* /
jest
. spyOn ( HttpClient . prototype , 'sendStream' )
. mockImplementation ( async ( verb , requestUrl ) = > {
const mockMessage = new http . IncomingMessage ( new net . Socket ( ) )
mockMessage . statusCode = 200
if ( requestUrl . includes ( 'fail' ) ) {
mockMessage . statusCode = 500
}
return new Promise < HttpClientResponse > ( resolve = > {
resolve ( {
message : mockMessage ,
readBody : emptyMockReadBody
} )
} )
} )
/ * *
* Mocks Patch calls that are made during Artifact Association tests
*
* Simulates success and non - success status codes depending on the input size along with an appropriate
* payload that represents an expected response
* /
jest
. spyOn ( HttpClient . prototype , 'patch' )
. mockImplementation ( async ( requestdata , data ) = > {
const inputData = JSON . parse ( data )
const mockMessage = new http . IncomingMessage ( new net . Socket ( ) )
// Get the name from the end of requestdata. Will be something like https://www.example.com/_apis/pipelines/workflows/15/artifacts?api-version=6.0-preview&artifactName=my-artifact
const artifactName = requestdata . split ( '=' ) [ 2 ]
let mockReadBody = emptyMockReadBody
if ( inputData . Size < 1 ) {
mockMessage . statusCode = 400
} else if ( artifactName === 'non-existent-artifact' ) {
mockMessage . statusCode = 404
} else {
mockMessage . statusCode = 200
const response : PatchArtifactSizeSuccessResponse = {
containerId : 13 ,
size : inputData.Size ,
signedContent : 'false' ,
type : 'actions_storage' ,
name : artifactName ,
url : ` ${ getRuntimeUrl ( ) } _apis/pipelines/1/runs/1/artifacts?artifactName= ${ artifactName } ` ,
uploadUrl : ` ${ getRuntimeUrl ( ) } _apis/resources/Containers/13 `
}
const returnData : string = JSON . stringify ( response , null , 2 )
mockReadBody = async function ( ) : Promise < string > {
return new Promise ( resolve = > {
resolve ( returnData )
} )
}
}
return new Promise < HttpClientResponse > ( resolve = > {
resolve ( {
message : mockMessage ,
readBody : mockReadBody
} )
} )
} )
}
} )