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
5 changes: 5 additions & 0 deletions __tests__/e2e/auth-flow.e2e.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ jest.mock("~/lib/auth-client", () => ({
getAuthBaseURL: () => "http://localhost:3000",
}));

jest.mock("~/stores/server-store", () => ({
useServerStore: (selector: (s: { ssoProviders: string[] | null }) => unknown) =>
selector({ ssoProviders: ["google"] }),
}));

jest.spyOn(Alert, "alert");

import SignInScreen from "~/app/(auth)/sign-in";
Expand Down
83 changes: 83 additions & 0 deletions __tests__/lib/server-auth-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { fetchServerAuthConfig } from "~/lib/server-auth-config";

describe("fetchServerAuthConfig", () => {
beforeEach(() => {
jest.restoreAllMocks();
});

it("returns config with socialProviders on success", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ socialProviders: ["google", "apple"] }),
}) as jest.Mock;

const result = await fetchServerAuthConfig("https://api.example.com");
expect(result).toEqual({ socialProviders: ["google", "apple"] });
expect(global.fetch).toHaveBeenCalledWith(
"https://api.example.com/api/v1/auth/config",
expect.objectContaining({ method: "GET" }),
);
});

it("strips trailing slashes from the base URL", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ socialProviders: ["google"] }),
}) as jest.Mock;

await fetchServerAuthConfig("https://api.example.com///");
expect(global.fetch).toHaveBeenCalledWith(
"https://api.example.com/api/v1/auth/config",
expect.any(Object),
);
});

it("returns null when response is not ok", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 404,
}) as jest.Mock;

const result = await fetchServerAuthConfig("https://api.example.com");
expect(result).toBeNull();
});

it("returns null when socialProviders is missing", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ someOtherField: "value" }),
}) as jest.Mock;

const result = await fetchServerAuthConfig("https://api.example.com");
expect(result).toBeNull();
});

it("returns null when socialProviders is not an array", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ socialProviders: "google" }),
}) as jest.Mock;

const result = await fetchServerAuthConfig("https://api.example.com");
expect(result).toBeNull();
});

it("returns null on network error", async () => {
global.fetch = jest
.fn()
.mockRejectedValue(new Error("Network error")) as jest.Mock;

const result = await fetchServerAuthConfig("https://unreachable.com");
expect(result).toBeNull();
});

it("returns config with empty socialProviders array", async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ socialProviders: [] }),
}) as jest.Mock;

const result = await fetchServerAuthConfig("https://api.example.com");
expect(result).toEqual({ socialProviders: [] });
});
});
146 changes: 146 additions & 0 deletions __tests__/lib/server-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
const mockReinitializeAuthClient = jest.fn();
const mockSetObjectStackApiUrl = jest.fn();
const mockGetServerUrl = jest.fn();
const mockSetServerUrl = jest.fn();
const mockClearServerUrl = jest.fn();
const mockFetchServerAuthConfig = jest.fn();

jest.mock("~/lib/auth-client", () => ({
reinitializeAuthClient: (...args: unknown[]) =>
mockReinitializeAuthClient(...args),
}));

jest.mock("~/lib/objectstack", () => ({
setObjectStackApiUrl: (...args: unknown[]) =>
mockSetObjectStackApiUrl(...args),
}));

jest.mock("~/lib/server-url", () => ({
getServerUrl: (...args: unknown[]) => mockGetServerUrl(...args),
setServerUrl: (...args: unknown[]) => mockSetServerUrl(...args),
clearServerUrl: (...args: unknown[]) => mockClearServerUrl(...args),
}));

jest.mock("~/lib/server-auth-config", () => ({
fetchServerAuthConfig: (...args: unknown[]) =>
mockFetchServerAuthConfig(...args),
}));

import { useServerStore } from "~/stores/server-store";

describe("server-store", () => {
beforeEach(() => {
jest.clearAllMocks();
useServerStore.setState({
serverUrl: null,
isReady: false,
ssoProviders: null,
});
});

it("has correct initial state", () => {
const state = useServerStore.getState();
expect(state.serverUrl).toBeNull();
expect(state.isReady).toBe(false);
expect(state.ssoProviders).toBeNull();
});

describe("hydrate", () => {
it("sets serverUrl, isReady, and ssoProviders when URL is stored", async () => {
mockGetServerUrl.mockResolvedValue("https://api.example.com");
mockFetchServerAuthConfig.mockResolvedValue({
socialProviders: ["google", "apple"],
});

await useServerStore.getState().hydrate();

const state = useServerStore.getState();
expect(state.serverUrl).toBe("https://api.example.com");
expect(state.isReady).toBe(true);
expect(state.ssoProviders).toEqual(["google", "apple"]);
});

it("retargets clients on hydrate with a URL", async () => {
mockGetServerUrl.mockResolvedValue("https://api.example.com");
mockFetchServerAuthConfig.mockResolvedValue({ socialProviders: [] });

await useServerStore.getState().hydrate();

expect(mockReinitializeAuthClient).toHaveBeenCalledWith(
"https://api.example.com",
);
expect(mockSetObjectStackApiUrl).toHaveBeenCalledWith(
"https://api.example.com",
);
});

it("sets ssoProviders to [] when auth config fetch fails", async () => {
mockGetServerUrl.mockResolvedValue("https://api.example.com");
mockFetchServerAuthConfig.mockResolvedValue(null);

await useServerStore.getState().hydrate();

expect(useServerStore.getState().ssoProviders).toEqual([]);
});

it("sets isReady and empty ssoProviders when no URL is stored", async () => {
mockGetServerUrl.mockResolvedValue(null);

await useServerStore.getState().hydrate();

const state = useServerStore.getState();
expect(state.serverUrl).toBeNull();
expect(state.isReady).toBe(true);
expect(state.ssoProviders).toEqual([]);
});
});

describe("connect", () => {
it("persists URL, retargets clients, and sets ssoProviders", async () => {
mockSetServerUrl.mockResolvedValue(undefined);
mockFetchServerAuthConfig.mockResolvedValue({
socialProviders: ["google"],
});

await useServerStore.getState().connect("https://new.example.com");

expect(mockSetServerUrl).toHaveBeenCalledWith("https://new.example.com");
expect(mockReinitializeAuthClient).toHaveBeenCalledWith(
"https://new.example.com",
);
expect(mockSetObjectStackApiUrl).toHaveBeenCalledWith(
"https://new.example.com",
);

const state = useServerStore.getState();
expect(state.serverUrl).toBe("https://new.example.com");
expect(state.ssoProviders).toEqual(["google"]);
});

it("sets ssoProviders to [] when auth config is unavailable", async () => {
mockSetServerUrl.mockResolvedValue(undefined);
mockFetchServerAuthConfig.mockResolvedValue(null);

await useServerStore.getState().connect("https://new.example.com");

expect(useServerStore.getState().ssoProviders).toEqual([]);
});
});

describe("reset", () => {
it("clears serverUrl and ssoProviders", async () => {
useServerStore.setState({
serverUrl: "https://api.example.com",
ssoProviders: ["google"],
});
mockClearServerUrl.mockResolvedValue(undefined);

await useServerStore.getState().reset();

const state = useServerStore.getState();
expect(state.serverUrl).toBeNull();
expect(state.ssoProviders).toBeNull();
expect(mockClearServerUrl).toHaveBeenCalled();
});
});
});
65 changes: 40 additions & 25 deletions app/(auth)/sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,21 @@ import { SafeAreaView } from "react-native-safe-area-context";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { authClient } from "~/lib/auth-client";
import { useServerStore } from "~/stores/server-store";

const SSO_PROVIDER_LABELS: Record<string, string> = {
google: "Continue with Google",
apple: "Continue with Apple",
github: "Continue with GitHub",
};

const PLATFORM_RESTRICTED: Record<string, string[]> = {
apple: ["ios"],
};

export default function SignInScreen() {
const router = useRouter();
const ssoProviders = useServerStore((s) => s.ssoProviders);
const [email, setEmail] = React.useState("");
const [password, setPassword] = React.useState("");
const [loading, setLoading] = React.useState(false);
Expand Down Expand Up @@ -42,11 +54,11 @@ export default function SignInScreen() {
}
};

const handleSocialSignIn = async (provider: "google" | "apple") => {
const handleSocialSignIn = async (provider: string) => {
setLoading(true);
try {
await authClient.signIn.social({
provider,
provider: provider as Parameters<typeof authClient.signIn.social>[0]["provider"],
callbackURL: "/(tabs)",
});
} catch {
Expand All @@ -56,6 +68,11 @@ export default function SignInScreen() {
}
};

const visibleProviders = (ssoProviders ?? []).filter((id) => {
const platforms = PLATFORM_RESTRICTED[id];
return !platforms || platforms.includes(Platform.OS);
});

return (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
Expand Down Expand Up @@ -117,30 +134,28 @@ export default function SignInScreen() {
</Button>
</View>

<View className="my-8 flex-row items-center">
<View className="h-px flex-1 bg-border" />
<Text className="mx-4 text-sm text-muted-foreground">or</Text>
<View className="h-px flex-1 bg-border" />
</View>
{visibleProviders.length > 0 && (
<>
<View className="my-8 flex-row items-center">
<View className="h-px flex-1 bg-border" />
<Text className="mx-4 text-sm text-muted-foreground">or</Text>
<View className="h-px flex-1 bg-border" />
</View>

<View className="gap-3">
<Button
variant="outline"
onPress={() => handleSocialSignIn("google")}
disabled={loading}
>
Continue with Google
</Button>
{Platform.OS === "ios" && (
<Button
variant="outline"
onPress={() => handleSocialSignIn("apple")}
disabled={loading}
>
Continue with Apple
</Button>
)}
</View>
<View className="gap-3">
{visibleProviders.map((id) => (
<Button
key={id}
variant="outline"
onPress={() => handleSocialSignIn(id)}
disabled={loading}
>
{SSO_PROVIDER_LABELS[id] ?? `Continue with ${id}`}
</Button>
))}
</View>
</>
)}

<View className="mt-8 flex-row justify-center">
<Text className="text-sm text-muted-foreground">
Expand Down
Loading
Loading