|
1 | 1 | import { describe, expect, test } from "bun:test"; |
2 | | -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; |
| 2 | +import { mkdir, mkdtemp, readFile, rm, utimes, writeFile } from "node:fs/promises"; |
3 | 3 | import { tmpdir } from "node:os"; |
4 | 4 | import { join } from "node:path"; |
5 | 5 | import { type } from "arktype"; |
@@ -328,6 +328,142 @@ describe("createAuthStore", () => { |
328 | 328 | } |
329 | 329 | }); |
330 | 330 |
|
| 331 | + test("takes over a dead-holder lock instead of timing out", async () => { |
| 332 | + const home = await mkdtemp(join(tmpdir(), "oauth-store-takeover-")); |
| 333 | + try { |
| 334 | + const store = createAuthStore<TestTokens>({ |
| 335 | + filename: "test-auth.json", |
| 336 | + settingsDirName: TEST_SETTINGS_DIR, |
| 337 | + isTokens: isTestTokens, |
| 338 | + }); |
| 339 | + const lockPath = `${store.authPath(home)}.lock`; |
| 340 | + await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true }); |
| 341 | + // The maximum pid_t can never be a live holder: kill(pid, 0) answers |
| 342 | + // ESRCH (or EINVAL), both of which read as dead. |
| 343 | + await writeFile(lockPath, `${2_147_483_647}`, { mode: 0o600 }); |
| 344 | + |
| 345 | + const profile = { |
| 346 | + name: "work", |
| 347 | + tokens: { access: "a", refresh: "r", expiresAt: 1 }, |
| 348 | + createdAt: 1, |
| 349 | + }; |
| 350 | + await expect(store.saveProfile(profile, home)).resolves.toBeUndefined(); |
| 351 | + expect(await store.loadProfile("work", home)).toEqual(profile); |
| 352 | + await expect(readFile(lockPath, "utf8")).rejects.toThrow("ENOENT"); |
| 353 | + } finally { |
| 354 | + await rm(home, { recursive: true, force: true }); |
| 355 | + } |
| 356 | + }); |
| 357 | + |
| 358 | + test("waits on a live-holder lock and times out without touching it", async () => { |
| 359 | + const home = await mkdtemp(join(tmpdir(), "oauth-store-live-")); |
| 360 | + try { |
| 361 | + const store = createAuthStore<TestTokens>({ |
| 362 | + filename: "test-auth.json", |
| 363 | + settingsDirName: TEST_SETTINGS_DIR, |
| 364 | + isTokens: isTestTokens, |
| 365 | + lockTimeoutMs: 100, |
| 366 | + }); |
| 367 | + const lockPath = `${store.authPath(home)}.lock`; |
| 368 | + await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true }); |
| 369 | + await writeFile(lockPath, `${process.pid}`, { mode: 0o600 }); |
| 370 | + |
| 371 | + await expect( |
| 372 | + store.saveProfile( |
| 373 | + { |
| 374 | + name: "work", |
| 375 | + tokens: { access: "a", refresh: "r", expiresAt: 1 }, |
| 376 | + createdAt: 1, |
| 377 | + }, |
| 378 | + home, |
| 379 | + ), |
| 380 | + ).rejects.toThrow( |
| 381 | + `Timed out waiting for OAuth credential lock ${lockPath}. ` + |
| 382 | + "If no Corbits process is running, remove this lock file manually and retry.", |
| 383 | + ); |
| 384 | + expect(await readFile(lockPath, "utf8")).toBe(`${process.pid}`); |
| 385 | + } finally { |
| 386 | + await rm(home, { recursive: true, force: true }); |
| 387 | + } |
| 388 | + }); |
| 389 | + |
| 390 | + test("takes over a stale legacy lock but waits on a fresh one", async () => { |
| 391 | + const home = await mkdtemp(join(tmpdir(), "oauth-store-legacy-")); |
| 392 | + try { |
| 393 | + const staleStore = createAuthStore<TestTokens>({ |
| 394 | + filename: "stale-auth.json", |
| 395 | + settingsDirName: TEST_SETTINGS_DIR, |
| 396 | + isTokens: isTestTokens, |
| 397 | + }); |
| 398 | + const staleLockPath = `${staleStore.authPath(home)}.lock`; |
| 399 | + await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true }); |
| 400 | + await writeFile(staleLockPath, "legacy-orphan", { mode: 0o600 }); |
| 401 | + await utimes( |
| 402 | + staleLockPath, |
| 403 | + new Date(), |
| 404 | + new Date(Date.now() - 60_000), |
| 405 | + ); |
| 406 | + |
| 407 | + const profile = { |
| 408 | + name: "work", |
| 409 | + tokens: { access: "a", refresh: "r", expiresAt: 1 }, |
| 410 | + createdAt: 1, |
| 411 | + }; |
| 412 | + await expect( |
| 413 | + staleStore.saveProfile(profile, home), |
| 414 | + ).resolves.toBeUndefined(); |
| 415 | + expect(await staleStore.loadProfile("work", home)).toEqual(profile); |
| 416 | + |
| 417 | + const freshStore = createAuthStore<TestTokens>({ |
| 418 | + filename: "fresh-auth.json", |
| 419 | + settingsDirName: TEST_SETTINGS_DIR, |
| 420 | + isTokens: isTestTokens, |
| 421 | + lockTimeoutMs: 100, |
| 422 | + }); |
| 423 | + const freshLockPath = `${freshStore.authPath(home)}.lock`; |
| 424 | + await writeFile(freshLockPath, "legacy-orphan", { mode: 0o600 }); |
| 425 | + await expect( |
| 426 | + freshStore.saveProfile(profile, home), |
| 427 | + ).rejects.toThrow("Timed out waiting for OAuth credential lock"); |
| 428 | + expect(await readFile(freshLockPath, "utf8")).toBe("legacy-orphan"); |
| 429 | + } finally { |
| 430 | + await rm(home, { recursive: true, force: true }); |
| 431 | + } |
| 432 | + }); |
| 433 | + |
| 434 | + test("saves when a contended lock vanishes mid-wait", async () => { |
| 435 | + const home = await mkdtemp(join(tmpdir(), "oauth-store-vanish-")); |
| 436 | + try { |
| 437 | + const store = createAuthStore<TestTokens>({ |
| 438 | + filename: "test-auth.json", |
| 439 | + settingsDirName: TEST_SETTINGS_DIR, |
| 440 | + isTokens: isTestTokens, |
| 441 | + }); |
| 442 | + const lockPath = `${store.authPath(home)}.lock`; |
| 443 | + await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true }); |
| 444 | + await writeFile(lockPath, `${2_147_483_647}`, { mode: 0o600 }); |
| 445 | + |
| 446 | + // Yank the stale lock out from under the waiter: whether the waiter |
| 447 | + // observes the dead PID, an ENOENT read, or an ENOENT unlink, it must |
| 448 | + // retry the exclusive create and land the save — never throw ENOENT. |
| 449 | + const pending = store.saveProfile( |
| 450 | + { |
| 451 | + name: "work", |
| 452 | + tokens: { access: "a", refresh: "r", expiresAt: 1 }, |
| 453 | + createdAt: 1, |
| 454 | + }, |
| 455 | + home, |
| 456 | + ); |
| 457 | + await rm(lockPath, { force: true }); |
| 458 | + await expect(pending).resolves.toBeUndefined(); |
| 459 | + expect((await store.loadProfile("work", home))?.tokens.access).toBe( |
| 460 | + "a", |
| 461 | + ); |
| 462 | + } finally { |
| 463 | + await rm(home, { recursive: true, force: true }); |
| 464 | + } |
| 465 | + }); |
| 466 | + |
331 | 467 | test("fails closed with manual recovery guidance when an orphan lock exists", async () => { |
332 | 468 | const home = await mkdtemp(join(tmpdir(), "oauth-store-orphan-")); |
333 | 469 | try { |
|
0 commit comments