@@ -129,26 +129,115 @@ it('does not follow a symlink planted at the temp file path', async () => {
129129 await expect ( storage . get ( 'alpha' ) ) . resolves . toBe ( 'one' ) ;
130130} ) ;
131131
132- it ( 'logs and returns sentinel values when initialization fails' , async ( ) => {
133- const filePath = path . join ( tmpRoot , 'not-a-dir' ) ;
134- await fs . writeFile ( filePath , 'sentinel' , 'utf8' ) ;
132+ it ( 'falls back to in-memory storage instead of following a symlinked storage directory' , async ( ) => {
133+ const victimDir = path . join ( tmpRoot , 'victim-dir' ) ;
134+ await fs . mkdir ( victimDir ) ;
135+ const storagePath = path . join ( tmpRoot , 'ldcache' ) ;
136+ await fs . symlink ( victimDir , storagePath ) ;
135137
136138 const logger = createMockLogger ( ) ;
137- const storage = new NodeStorage ( filePath , logger ) ;
139+ const storage = new NodeStorage ( storagePath , logger ) ;
140+
141+ await storage . set ( 'alpha' , 'one' ) ;
142+ await expect ( storage . get ( 'alpha' ) ) . resolves . toBe ( 'one' ) ;
143+ expect ( logger . error ) . not . toHaveBeenCalled ( ) ;
144+ expect ( logger . warn ) . toHaveBeenCalledWith (
145+ expect . stringContaining ( 'Using in-memory storage as a fallback' ) ,
146+ ) ;
147+
148+ // The victim directory the symlink pointed at was never written to.
149+ await expect ( fs . readdir ( victimDir ) ) . resolves . toEqual ( [ ] ) ;
150+ } ) ;
151+
152+ it ( 'discards a symlink planted at the storage file path instead of reading through it' , async ( ) => {
153+ const victim = path . join ( tmpRoot , 'victim.json' ) ;
154+ await fs . writeFile ( victim , JSON . stringify ( { secret : 'do-not-load' } ) , 'utf8' ) ;
155+ await fs . symlink ( victim , path . join ( tmpRoot , 'ldcache.json' ) ) ;
156+
157+ const logger = createMockLogger ( ) ;
158+ const storage = new NodeStorage ( tmpRoot , logger ) ;
159+
160+ // The symlinked "cache" is discarded rather than followed, so its contents never load.
161+ await expect ( storage . get ( 'secret' ) ) . resolves . toBeNull ( ) ;
162+ expect ( logger . warn ) . toHaveBeenCalledWith (
163+ expect . stringContaining ( 'Discarding malformed flag cache' ) ,
164+ ) ;
165+ expect ( logger . error ) . not . toHaveBeenCalled ( ) ;
166+
167+ // The victim file itself is untouched, and the symlink has been replaced by a real file.
168+ await expect ( fs . readFile ( victim , 'utf8' ) ) . resolves . toBe ( JSON . stringify ( { secret : 'do-not-load' } ) ) ;
169+ await storage . set ( 'alpha' , 'one' ) ;
170+ await expect ( storage . get ( 'alpha' ) ) . resolves . toBe ( 'one' ) ;
171+ const onDisk = await fs . readFile ( path . join ( tmpRoot , 'ldcache.json' ) , 'utf8' ) ;
172+ expect ( JSON . parse ( onDisk ) ) . toEqual ( { alpha : 'one' } ) ;
173+ } ) ;
174+
175+ it ( 'falls back to in-memory storage when a file occupies the storage directory path' , async ( ) => {
176+ // No prerelease (v0) installation has customer data to migrate, so a plain file sitting at
177+ // the storage directory path is just treated as a generic, unrecoverable init failure.
178+ const storagePath = path . join ( tmpRoot , 'ldcache' ) ;
179+ await fs . writeFile ( storagePath , 'not a directory' , 'utf8' ) ;
180+
181+ const logger = createMockLogger ( ) ;
182+ const storage = new NodeStorage ( storagePath , logger ) ;
183+
184+ await storage . set ( 'alpha' , 'one' ) ;
185+ await expect ( storage . get ( 'alpha' ) ) . resolves . toBe ( 'one' ) ;
186+ expect ( logger . error ) . not . toHaveBeenCalled ( ) ;
187+ expect ( logger . warn ) . toHaveBeenCalledTimes ( 1 ) ;
188+ expect ( logger . warn ) . toHaveBeenCalledWith (
189+ expect . stringContaining ( 'Using in-memory storage as a fallback' ) ,
190+ ) ;
191+ expect ( ( await fs . stat ( storagePath ) ) . isDirectory ( ) ) . toBe ( false ) ;
192+ } ) ;
193+
194+ it ( 'falls back to in-memory storage and warns once when the storage directory cannot be created' , async ( ) => {
195+ // An intermediate path segment that is a file (rather than the storage directory itself)
196+ // means mkdir cannot traverse through it, so this must fall back to in-memory storage.
197+ const fileInThePath = path . join ( tmpRoot , 'not-a-dir' ) ;
198+ await fs . writeFile ( fileInThePath , 'sentinel' , 'utf8' ) ;
199+ const storagePath = path . join ( fileInThePath , 'subdir' ) ;
200+
201+ const logger = createMockLogger ( ) ;
202+ const storage = new NodeStorage ( storagePath , logger ) ;
138203
139204 await expect ( storage . get ( 'alpha' ) ) . resolves . toBeNull ( ) ;
140- await expect ( storage . set ( 'alpha' , 'one' ) ) . resolves . toBeUndefined ( ) ;
141- await expect ( storage . clear ( 'alpha' ) ) . resolves . toBeUndefined ( ) ;
205+ await storage . set ( 'alpha' , 'one' ) ;
206+ await expect ( storage . get ( 'alpha' ) ) . resolves . toBe ( 'one' ) ;
207+ await storage . clear ( 'alpha' ) ;
208+ await expect ( storage . get ( 'alpha' ) ) . resolves . toBeNull ( ) ;
142209
143- expect ( logger . error ) . toHaveBeenCalledWith (
144- expect . stringContaining ( 'Error getting key from storage' ) ,
210+ expect ( logger . error ) . not . toHaveBeenCalled ( ) ;
211+ expect ( logger . warn ) . toHaveBeenCalledTimes ( 1 ) ;
212+ expect ( logger . warn ) . toHaveBeenCalledWith (
213+ expect . stringContaining ( 'Using in-memory storage as a fallback' ) ,
145214 ) ;
146- expect ( logger . error ) . toHaveBeenCalledWith (
147- expect . stringContaining ( 'Error setting key in storage' ) ,
215+ } ) ;
216+
217+ it ( 'falls back to in-memory storage when rewriting a discarded malformed cache fails' , async ( ) => {
218+ // The storage directory itself is created successfully, but the rewrite that normally follows
219+ // discarding a malformed cache file fails because a directory occupies the temp-file path.
220+ // This exercises the fallback triggering from a site other than storage-directory creation.
221+ await fs . writeFile ( path . join ( tmpRoot , 'ldcache.json' ) , 'not json' , 'utf8' ) ;
222+ await fs . mkdir ( path . join ( tmpRoot , 'ldcache.json.tmp' ) ) ;
223+
224+ const logger = createMockLogger ( ) ;
225+ const storage = new NodeStorage ( tmpRoot , logger ) ;
226+
227+ await expect ( storage . get ( 'anything' ) ) . resolves . toBeNull ( ) ;
228+ expect ( logger . warn ) . toHaveBeenCalledWith (
229+ expect . stringContaining ( 'Discarding malformed flag cache' ) ,
148230 ) ;
149- expect ( logger . error ) . toHaveBeenCalledWith (
150- expect . stringContaining ( 'Error clearing key from storage ' ) ,
231+ expect ( logger . warn ) . toHaveBeenCalledWith (
232+ expect . stringContaining ( 'Using in-memory storage as a fallback ' ) ,
151233 ) ;
234+ expect ( logger . error ) . not . toHaveBeenCalled ( ) ;
235+
236+ await storage . set ( 'alpha' , 'one' ) ;
237+ await expect ( storage . get ( 'alpha' ) ) . resolves . toBe ( 'one' ) ;
238+
239+ // Persistence is disabled, so the untouched malformed file on disk proves no flush was attempted.
240+ await expect ( fs . readFile ( path . join ( tmpRoot , 'ldcache.json' ) , 'utf8' ) ) . resolves . toBe ( 'not json' ) ;
152241} ) ;
153242
154243it ( 'returns the same singleton across getNodeStorage calls' , ( ) => {
0 commit comments