b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
179 lines
5.9 KiB
TypeScript
179 lines
5.9 KiB
TypeScript
import type { AuthAdapter, OAuthProfile } from "@emdash-cms/auth";
|
|
import { Role, acceptInviteViaOAuth, createInviteToken, OAuthError } from "@emdash-cms/auth";
|
|
import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
|
|
import type { Kysely } from "kysely";
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
|
|
import type { Database } from "../../../src/database/types.js";
|
|
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
|
|
|
|
const TOKEN_EXTRACT_REGEX = /token=([a-zA-Z0-9_-]+)/;
|
|
|
|
function makeProfile(overrides: Partial<OAuthProfile> = {}): OAuthProfile {
|
|
return {
|
|
id: "google-123",
|
|
email: "invitee@example.com",
|
|
name: "Invitee",
|
|
avatarUrl: null,
|
|
emailVerified: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("acceptInviteViaOAuth", () => {
|
|
let db: Kysely<Database>;
|
|
let adapter: AuthAdapter;
|
|
let adminId: string;
|
|
|
|
beforeEach(async () => {
|
|
db = await setupTestDatabase();
|
|
adapter = createKyselyAdapter(db);
|
|
const admin = await adapter.createUser({
|
|
email: "admin@example.com",
|
|
name: "Admin",
|
|
role: Role.ADMIN,
|
|
emailVerified: true,
|
|
});
|
|
adminId = admin.id;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await teardownTestDatabase(db);
|
|
});
|
|
|
|
async function invite(email: string, role: number = Role.AUTHOR): Promise<string> {
|
|
const { url } = await createInviteToken(
|
|
{ baseUrl: "https://example.com/_emdash" },
|
|
adapter,
|
|
email,
|
|
role,
|
|
adminId,
|
|
);
|
|
const match = url.match(TOKEN_EXTRACT_REGEX);
|
|
if (!match) throw new Error("could not extract invite token");
|
|
return match[1];
|
|
}
|
|
|
|
it("completes the invite, sets the invited role, links the account, and consumes the token", async () => {
|
|
const token = await invite("invitee@example.com", Role.EDITOR);
|
|
|
|
const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token);
|
|
|
|
expect(user.email).toBe("invitee@example.com");
|
|
expect(user.role).toBe(Role.EDITOR);
|
|
|
|
const account = await adapter.getOAuthAccount("google", "google-123");
|
|
expect(account?.userId).toBe(user.id);
|
|
|
|
// Single-use: the consumed token can no longer be replayed.
|
|
await expect(
|
|
acceptInviteViaOAuth(adapter, "google", makeProfile({ id: "google-999" }), token),
|
|
).rejects.toMatchObject({ code: "invite_invalid" });
|
|
});
|
|
|
|
it("matches the invited email case-insensitively", async () => {
|
|
const token = await invite("Invitee@Example.com");
|
|
|
|
// Invited as "Invitee@Example.com", provider reports "invitee@example.com":
|
|
// the differing case still completes the invite (email is normalized on store).
|
|
const user = await acceptInviteViaOAuth(
|
|
adapter,
|
|
"google",
|
|
makeProfile({ email: "invitee@example.com" }),
|
|
token,
|
|
);
|
|
|
|
expect(user.email.toLowerCase()).toBe("invitee@example.com");
|
|
expect(await adapter.getOAuthAccount("google", "google-123")).not.toBeNull();
|
|
});
|
|
|
|
it("rejects when the OAuth email does not match the invite", async () => {
|
|
const token = await invite("invitee@example.com");
|
|
|
|
await expect(
|
|
acceptInviteViaOAuth(
|
|
adapter,
|
|
"google",
|
|
makeProfile({ email: "someone-else@example.com" }),
|
|
token,
|
|
),
|
|
).rejects.toMatchObject({ code: "invite_email_mismatch" });
|
|
|
|
// No account was created for the mismatched email.
|
|
expect(await adapter.getUserByEmail("someone-else@example.com")).toBeNull();
|
|
});
|
|
|
|
it("rejects with a distinct code when the provider has not verified the email", async () => {
|
|
const token = await invite("invitee@example.com");
|
|
|
|
await expect(
|
|
acceptInviteViaOAuth(adapter, "google", makeProfile({ emailVerified: false }), token),
|
|
).rejects.toMatchObject({ code: "invite_email_unverified" });
|
|
|
|
expect(await adapter.getUserByEmail("invitee@example.com")).toBeNull();
|
|
});
|
|
|
|
it("consumes the invite token when linking to a pre-existing account", async () => {
|
|
const token = await invite("invitee@example.com");
|
|
// A user with the invited email is created another way after the invite is
|
|
// issued (e.g. an admin-created user or a passkey-accept race).
|
|
await adapter.createUser({
|
|
email: "invitee@example.com",
|
|
name: "Invitee",
|
|
role: Role.AUTHOR,
|
|
emailVerified: true,
|
|
});
|
|
|
|
const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token);
|
|
expect(user.email).toBe("invitee@example.com");
|
|
expect(await adapter.getOAuthAccount("google", "google-123")).not.toBeNull();
|
|
|
|
// Single-use: the linked-and-consumed token cannot be replayed.
|
|
await expect(
|
|
acceptInviteViaOAuth(adapter, "google", makeProfile({ id: "google-777" }), token),
|
|
).rejects.toMatchObject({ code: "invite_invalid" });
|
|
});
|
|
|
|
it("rejects (and does not consume) when the already-linked account's user has a different email", async () => {
|
|
const token = await invite("invitee@example.com");
|
|
// An OAuth identity is already linked to a user whose EmDash email differs
|
|
// from the invited address (e.g. the email was changed after linking).
|
|
const other = await adapter.createUser({
|
|
email: "other@example.com",
|
|
name: "Other",
|
|
role: Role.AUTHOR,
|
|
emailVerified: true,
|
|
});
|
|
await adapter.createOAuthAccount({
|
|
provider: "google",
|
|
providerAccountId: "google-linked",
|
|
userId: other.id,
|
|
});
|
|
|
|
await expect(
|
|
acceptInviteViaOAuth(
|
|
adapter,
|
|
"google",
|
|
makeProfile({ id: "google-linked", email: "invitee@example.com" }),
|
|
token,
|
|
),
|
|
).rejects.toMatchObject({ code: "invite_email_mismatch" });
|
|
|
|
// The invite was not consumed: a fresh, correctly-matched identity still works.
|
|
const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token);
|
|
expect(user.email).toBe("invitee@example.com");
|
|
});
|
|
|
|
it("rejects an invalid or unknown invite token", async () => {
|
|
await expect(
|
|
acceptInviteViaOAuth(adapter, "google", makeProfile(), "not-a-real-token"),
|
|
).rejects.toMatchObject({ code: "invite_invalid" });
|
|
});
|
|
|
|
it("throws OAuthError (not InviteError) so the callback maps it to a message", async () => {
|
|
await expect(
|
|
acceptInviteViaOAuth(adapter, "google", makeProfile(), "not-a-real-token"),
|
|
).rejects.toBeInstanceOf(OAuthError);
|
|
});
|
|
});
|