test: add tests for API key quota persistence and magic link origin validation

This commit is contained in:
Bereket Engida
2025-12-11 14:26:09 -08:00
parent 15232f142b
commit e299decf23
3 changed files with 115 additions and 2 deletions

View File

@@ -2628,6 +2628,45 @@ describe("api-key", async () => {
expect(retrievedKey?.id).toBe(createdKey?.id);
});
it("verifyApiKey should persist quota updates to the database when fallbackToDatabase is true", async () => {
const { headers, user } = await signInWithTestUser();
const createdKey = await auth.api.createApiKey({
body: {
remaining: 1,
userId: user.id,
},
});
const first = await auth.api.verifyApiKey({
body: {
key: createdKey.key,
},
});
expect(first.valid).toBe(true);
expect(first.key?.remaining).toBe(0);
// Ensure the canonical DB row was updated (not just the cache).
const dbAfterFirst = await auth.api.getApiKey({
query: { id: createdKey.id },
headers,
});
expect(dbAfterFirst.remaining).toBe(0);
// Simulate cache eviction/deletion and ensure we don't repopulate stale allowances.
store.clear();
const second = await auth.api.verifyApiKey({
body: {
key: createdKey.key,
},
});
expect(second.valid).toBe(false);
expect(second.error?.code).toBe("USAGE_EXCEEDED");
});
it("should fallback to database when not found in storage and auto-populate storage", async () => {
const { headers, user } = await signInWithTestUser();

View File

@@ -109,8 +109,20 @@ export async function validateApiKey({
if (apiKey.remaining === 0 && apiKey.refillAmount === null) {
// if there is no more remaining requests, and there is no refill amount, than the key is revoked
try {
if (opts.storage === "secondary-storage") {
// Secondary storage mode: delete from storage
if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
// Secondary storage with fallback: delete from storage and database
await deleteApiKey(ctx, apiKey, opts);
await ctx.context.adapter.delete({
model: API_KEY_TABLE_NAME,
where: [
{
field: "id",
value: apiKey.id,
},
],
});
} else if (opts.storage === "secondary-storage") {
// Secondary storage mode: delete from storage only
await deleteApiKey(ctx, apiKey, opts);
} else {
// Database mode: delete from DB
@@ -182,6 +194,22 @@ export async function validateApiKey({
],
update: updated,
});
} else if (opts.storage === "secondary-storage" && opts.fallbackToDatabase) {
// Secondary storage with fallback: update database and then update storage
const dbUpdated = await ctx.context.adapter.update<ApiKey>({
model: API_KEY_TABLE_NAME,
where: [
{
field: "id",
value: apiKey.id,
},
],
update: updated,
});
if (dbUpdated) {
await setApiKey(ctx, dbUpdated, opts);
newApiKey = dbUpdated;
}
} else {
// Secondary storage mode: update in storage
await setApiKey(ctx, updated, opts);

View File

@@ -326,6 +326,52 @@ describe("magic link verify", async () => {
});
});
describe("magic link verify origin validation", async () => {
it("should reject untrusted callbackURL on verify", async () => {
let verificationEmail: VerificationEmail = {
email: "",
token: "",
url: "",
};
const { customFetchImpl, testUser } = await getTestInstance({
trustedOrigins: ["http://localhost:3000"],
plugins: [
magicLink({
async sendMagicLink(data) {
verificationEmail = data;
},
}),
],
});
const client = createAuthClient({
plugins: [magicLinkClient()],
fetchOptions: {
customFetchImpl,
},
baseURL: "http://localhost:3000",
basePath: "/api/auth",
});
await client.signIn.magicLink({
email: testUser.email,
});
const token =
new URL(verificationEmail.url).searchParams.get("token") || "";
const res = await client.magicLink.verify({
query: {
token,
callbackURL: "http://malicious.com",
},
});
expect(res.error?.status).toBe(403);
expect(res.error?.message).toBe("Invalid callbackURL");
});
});
describe("magic link storeToken", async () => {
it("should store token in hashed", async () => {
let verificationEmail: VerificationEmail = {