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
278 changes: 276 additions & 2 deletions src/auth/store.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import {
mkdir,
mkdtemp,
readFile,
rm,
utimes,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { type } from "arktype";

import { createAuthStore, type BaseTokens } from "./store.js";
import { withMockedModule } from "../../tests/helpers/mock-module.js";
import type { BaseTokens } from "./store.js";

let failingUnlinkPath: string | undefined;
await withMockedModule(
import.meta.resolve("node:fs/promises"),
(real: typeof import("node:fs/promises")) => ({
...real,
unlink: async (...args: Parameters<typeof real.unlink>) => {
if (args[0] === failingUnlinkPath) throw new Error("lock release failed");
return real.unlink(...args);
},
}),
);

const { createAuthStore } = await import("./store.js");

type TestTokens = BaseTokens & { accountId?: string };

Expand Down Expand Up @@ -294,6 +316,64 @@ describe("createAuthStore", () => {
}
});

test("surfaces a credential lock release failure after a successful write", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-release-error-"));
try {
const store = createAuthStore<TestTokens>({
filename: "test-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
});
failingUnlinkPath = `${store.authPath(home)}.lock`;

await expect(
store.saveProfile(
{
name: "work",
tokens: { access: "a", refresh: "r", expiresAt: 1 },
createdAt: 1,
},
home,
),
).rejects.toThrow("lock release failed");
} finally {
failingUnlinkPath = undefined;
await rm(home, { recursive: true, force: true });
}
});

test("does not mask a read-modify-write callback failure during release", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-error-precedence-"));
try {
const store = createAuthStore<TestTokens>({
filename: "test-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
});
const profile = {
name: "work",
tokens: { access: "a", refresh: "r", expiresAt: 1 },
createdAt: 1,
};
await store.saveProfile(profile, home);

const failingStore = createAuthStore<TestTokens>({
filename: "test-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: (_value: unknown): _value is TestTokens => {
throw new Error("validator failed");
},
});
failingUnlinkPath = `${store.authPath(home)}.lock`;
await expect(failingStore.saveProfile(profile, home)).rejects.toThrow(
"validator failed",
);
} finally {
failingUnlinkPath = undefined;
await rm(home, { recursive: true, force: true });
}
});

test("releases the credential lock when a read-modify-write callback fails", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-error-"));
try {
Expand Down Expand Up @@ -328,6 +408,200 @@ describe("createAuthStore", () => {
}
});

test("takes over a dead-holder lock instead of timing out", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-takeover-"));
try {
const store = createAuthStore<TestTokens>({
filename: "test-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
});
const lockPath = `${store.authPath(home)}.lock`;
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
// The maximum pid_t can never be a live holder: kill(pid, 0) answers
// ESRCH (or EINVAL), both of which read as dead.
await writeFile(lockPath, `${2_147_483_647}`, { mode: 0o600 });

const profile = {
name: "work",
tokens: { access: "a", refresh: "r", expiresAt: 1 },
createdAt: 1,
};
await expect(store.saveProfile(profile, home)).resolves.toBeUndefined();
expect(await store.loadProfile("work", home)).toEqual(profile);
await expect(readFile(lockPath, "utf8")).rejects.toThrow("ENOENT");
} finally {
await rm(home, { recursive: true, force: true });
}
});

test("takes over a dead-holder pid:counter claim instead of timing out", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-takeover-claim-"));
try {
const store = createAuthStore<TestTokens>({
filename: "test-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
});
const lockPath = `${store.authPath(home)}.lock`;
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
// The NEW pid:counter claim format with a certainly-dead PID: kill(pid, 0)
// answers ESRCH (or EINVAL), both of which read as dead. The counter leg
// must not stop holderPid from reading the pid leg.
await writeFile(lockPath, `${2_147_483_647}:99`, { mode: 0o600 });

const profile = {
name: "work",
tokens: { access: "a", refresh: "r", expiresAt: 1 },
createdAt: 1,
};
await expect(store.saveProfile(profile, home)).resolves.toBeUndefined();
expect(await store.loadProfile("work", home)).toEqual(profile);
await expect(readFile(lockPath, "utf8")).rejects.toThrow("ENOENT");
} finally {
await rm(home, { recursive: true, force: true });
}
});

test("waits on a live-holder lock and times out without touching it", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-live-"));
try {
const store = createAuthStore<TestTokens>({
filename: "test-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
lockTimeoutMs: 100,
});
const lockPath = `${store.authPath(home)}.lock`;
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
await writeFile(lockPath, `${process.pid}`, { mode: 0o600 });

await expect(
store.saveProfile(
{
name: "work",
tokens: { access: "a", refresh: "r", expiresAt: 1 },
createdAt: 1,
},
home,
),
).rejects.toThrow(
`Timed out waiting for OAuth credential lock ${lockPath}. ` +
"If no Corbits process is running, remove this lock file manually and retry.",
);
expect(await readFile(lockPath, "utf8")).toBe(`${process.pid}`);
} finally {
await rm(home, { recursive: true, force: true });
}
});

test("waits on a live-holder pid:counter claim and times out without touching it", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-live-claim-"));
try {
const store = createAuthStore<TestTokens>({
filename: "test-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
lockTimeoutMs: 100,
});
const lockPath = `${store.authPath(home)}.lock`;
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
// The NEW pid:counter claim format held by this live process. Never
// signal it; the waiter must read the pid leg as alive, time out, and
// leave the claim byte-identical.
const claim = `${process.pid}:42`;
await writeFile(lockPath, claim, { mode: 0o600 });

await expect(
store.saveProfile(
{
name: "work",
tokens: { access: "a", refresh: "r", expiresAt: 1 },
createdAt: 1,
},
home,
),
).rejects.toThrow(
`Timed out waiting for OAuth credential lock ${lockPath}. ` +
"If no Corbits process is running, remove this lock file manually and retry.",
);
expect(await readFile(lockPath, "utf8")).toBe(claim);
} finally {
await rm(home, { recursive: true, force: true });
}
});

test("takes over a stale legacy lock but waits on a fresh one", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-legacy-"));
try {
const staleStore = createAuthStore<TestTokens>({
filename: "stale-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
});
const staleLockPath = `${staleStore.authPath(home)}.lock`;
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
await writeFile(staleLockPath, "legacy-orphan", { mode: 0o600 });
await utimes(staleLockPath, new Date(), new Date(Date.now() - 60_000));

const profile = {
name: "work",
tokens: { access: "a", refresh: "r", expiresAt: 1 },
createdAt: 1,
};
await expect(
staleStore.saveProfile(profile, home),
).resolves.toBeUndefined();
expect(await staleStore.loadProfile("work", home)).toEqual(profile);

const freshStore = createAuthStore<TestTokens>({
filename: "fresh-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
lockTimeoutMs: 100,
});
const freshLockPath = `${freshStore.authPath(home)}.lock`;
await writeFile(freshLockPath, "legacy-orphan", { mode: 0o600 });
await expect(freshStore.saveProfile(profile, home)).rejects.toThrow(
"Timed out waiting for OAuth credential lock",
);
expect(await readFile(freshLockPath, "utf8")).toBe("legacy-orphan");
} finally {
await rm(home, { recursive: true, force: true });
}
});

test("saves when a contended lock vanishes mid-wait", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-vanish-"));
try {
const store = createAuthStore<TestTokens>({
filename: "test-auth.json",
settingsDirName: TEST_SETTINGS_DIR,
isTokens: isTestTokens,
});
const lockPath = `${store.authPath(home)}.lock`;
await mkdir(join(home, TEST_SETTINGS_DIR), { recursive: true });
await writeFile(lockPath, `${2_147_483_647}`, { mode: 0o600 });

// Yank the stale lock out from under the waiter: whether the waiter
// observes the dead PID, an ENOENT read, or an ENOENT unlink, it must
// retry the exclusive create and land the save — never throw ENOENT.
const pending = store.saveProfile(
{
name: "work",
tokens: { access: "a", refresh: "r", expiresAt: 1 },
createdAt: 1,
},
home,
);
await rm(lockPath, { force: true });
await expect(pending).resolves.toBeUndefined();
expect((await store.loadProfile("work", home))?.tokens.access).toBe("a");
} finally {
await rm(home, { recursive: true, force: true });
}
});

test("fails closed with manual recovery guidance when an orphan lock exists", async () => {
const home = await mkdtemp(join(tmpdir(), "oauth-store-orphan-"));
try {
Expand Down
Loading
Loading