@@ -5,10 +5,28 @@ import {
55import { Readable } from 'stream' ;
66
77const mockSend = jest . fn ( ) ;
8+ const mockGetSignedUrl = jest . fn ( ) ;
9+
10+ class MockGetObjectCommand {
11+ constructor ( public readonly input : unknown ) {
12+ Object . assign ( this , input as object ) ;
13+ }
14+ }
15+
16+ class MockHeadObjectCommand {
17+ constructor ( public readonly input : unknown ) {
18+ Object . assign ( this , input as object ) ;
19+ }
20+ }
821
922jest . mock ( '@aws-sdk/client-s3' , ( ) => ( {
1023 S3Client : jest . fn ( ) . mockImplementation ( ( ) => ( { send : mockSend } ) ) ,
11- GetObjectCommand : jest . fn ( ) . mockImplementation ( ( input ) => input ) ,
24+ GetObjectCommand : MockGetObjectCommand ,
25+ HeadObjectCommand : MockHeadObjectCommand ,
26+ } ) ) ;
27+
28+ jest . mock ( '@aws-sdk/s3-request-presigner' , ( ) => ( {
29+ getSignedUrl : ( ...args : unknown [ ] ) => mockGetSignedUrl ( ...args ) ,
1230} ) ) ;
1331
1432import { DeviceAgentService } from './device-agent.service' ;
@@ -92,6 +110,123 @@ describe('DeviceAgentService', () => {
92110 } ) ;
93111 } ) ;
94112
113+ describe ( 'getUpdateFile' , ( ) => {
114+ it ( 'streams .yml manifests directly from S3' , async ( ) => {
115+ const mockStream = new Readable ( { read ( ) { } } ) ;
116+ mockSend . mockResolvedValue ( {
117+ Body : mockStream ,
118+ ContentLength : 859 ,
119+ } ) ;
120+
121+ const result = await service . getUpdateFile ( { filename : 'latest-mac.yml' } ) ;
122+
123+ expect ( result ) . toEqual ( {
124+ kind : 'stream' ,
125+ stream : mockStream ,
126+ contentType : 'text/yaml' ,
127+ contentLength : 859 ,
128+ } ) ;
129+ expect ( mockGetSignedUrl ) . not . toHaveBeenCalled ( ) ;
130+ } ) ;
131+
132+ it ( 'redirects binary downloads to a presigned S3 URL signed for GET' , async ( ) => {
133+ mockGetSignedUrl . mockResolvedValue ( 'https://s3.example.com/signed-zip-url' ) ;
134+
135+ const result = await service . getUpdateFile ( {
136+ filename : 'CompAI-Device-Agent-1.0.5-arm64.zip' ,
137+ } ) ;
138+
139+ expect ( result ) . toEqual ( {
140+ kind : 'redirect' ,
141+ url : 'https://s3.example.com/signed-zip-url' ,
142+ } ) ;
143+ expect ( mockSend ) . not . toHaveBeenCalled ( ) ;
144+ expect ( mockGetSignedUrl ) . toHaveBeenCalledTimes ( 1 ) ;
145+ const [ , command ] = mockGetSignedUrl . mock . calls [ 0 ] ;
146+ expect ( command ) . toBeInstanceOf ( MockGetObjectCommand ) ;
147+ expect ( command ) . toMatchObject ( {
148+ Bucket : 'test-bucket' ,
149+ Key : 'device-agent/production/updates/CompAI-Device-Agent-1.0.5-arm64.zip' ,
150+ } ) ;
151+ } ) ;
152+
153+ it . each ( [
154+ 'CompAI-Device-Agent-1.0.5-arm64.zip' ,
155+ 'CompAI-Device-Agent-1.0.5-setup.exe' ,
156+ 'CompAI-Device-Agent-1.0.5-arm64.dmg' ,
157+ 'CompAI-Device-Agent-1.0.5-x86_64.AppImage' ,
158+ 'CompAI-Device-Agent-1.0.5-arm64.zip.blockmap' ,
159+ ] ) ( 'redirects binary file %s' , async ( filename ) => {
160+ mockGetSignedUrl . mockResolvedValue ( 'https://s3.example.com/signed' ) ;
161+
162+ const result = await service . getUpdateFile ( { filename } ) ;
163+
164+ expect ( result ) . toEqual ( {
165+ kind : 'redirect' ,
166+ url : 'https://s3.example.com/signed' ,
167+ } ) ;
168+ } ) ;
169+
170+ it ( 'throws NotFoundException for invalid filenames' , async ( ) => {
171+ await expect (
172+ service . getUpdateFile ( { filename : '../etc/passwd' } ) ,
173+ ) . rejects . toThrow ( NotFoundException ) ;
174+ await expect (
175+ service . getUpdateFile ( { filename : 'foo.txt' } ) ,
176+ ) . rejects . toThrow ( NotFoundException ) ;
177+ } ) ;
178+
179+ it ( 'throws NotFoundException when S3 returns NoSuchKey for a yml manifest' , async ( ) => {
180+ const error = new Error ( 'Not found' ) ;
181+ error . name = 'NoSuchKey' ;
182+ mockSend . mockRejectedValue ( error ) ;
183+
184+ await expect (
185+ service . getUpdateFile ( { filename : 'latest-mac.yml' } ) ,
186+ ) . rejects . toThrow ( NotFoundException ) ;
187+ } ) ;
188+ } ) ;
189+
190+ describe ( 'headUpdateFile' , ( ) => {
191+ it ( 'returns metadata for .yml manifests' , async ( ) => {
192+ mockSend . mockResolvedValue ( { ContentLength : 859 } ) ;
193+
194+ const result = await service . headUpdateFile ( {
195+ filename : 'latest-mac.yml' ,
196+ } ) ;
197+
198+ expect ( result ) . toEqual ( {
199+ kind : 'stream' ,
200+ contentType : 'text/yaml' ,
201+ contentLength : 859 ,
202+ } ) ;
203+ expect ( mockGetSignedUrl ) . not . toHaveBeenCalled ( ) ;
204+ } ) ;
205+
206+ it ( 'redirects binary HEAD requests to a URL signed with HeadObjectCommand' , async ( ) => {
207+ mockGetSignedUrl . mockResolvedValue ( 'https://s3.example.com/signed-head' ) ;
208+
209+ const result = await service . headUpdateFile ( {
210+ filename : 'CompAI-Device-Agent-1.0.5-arm64.zip' ,
211+ } ) ;
212+
213+ expect ( result ) . toEqual ( {
214+ kind : 'redirect' ,
215+ url : 'https://s3.example.com/signed-head' ,
216+ } ) ;
217+ expect ( mockSend ) . not . toHaveBeenCalled ( ) ;
218+ expect ( mockGetSignedUrl ) . toHaveBeenCalledTimes ( 1 ) ;
219+ const [ , command ] = mockGetSignedUrl . mock . calls [ 0 ] ;
220+ // S3 signs each HTTP method separately; a GET-signed URL would be
221+ // rejected when used with a HEAD request.
222+ expect ( command ) . toBeInstanceOf ( MockHeadObjectCommand ) ;
223+ expect ( command ) . toMatchObject ( {
224+ Bucket : 'test-bucket' ,
225+ Key : 'device-agent/production/updates/CompAI-Device-Agent-1.0.5-arm64.zip' ,
226+ } ) ;
227+ } ) ;
228+ } ) ;
229+
95230 describe ( 'downloadWindowsAgent' , ( ) => {
96231 it ( 'should return stream, filename, and contentType on success' , async ( ) => {
97232 const mockStream = new Readable ( { read ( ) { } } ) ;
0 commit comments