Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,7 @@
"github-username-confirm-description": "Is this the GitHub profile you want to use?",
"github-username-dialog-description": "Enter your GitHub username and we will verify the profile before saving it.",
"github-username-error-invalid_username": "Enter a valid GitHub username.",
"github-username-error-already_linked": "This GitHub account is already linked to another Capgo account.",
"github-username-error-not_found": "We could not find that GitHub profile.",
"github-username-error-rate_limited": "GitHub is temporarily rate limited. Please try again later.",
"github-username-error-request_failed": "We could not load that GitHub profile. Please try again.",
Expand Down
6 changes: 4 additions & 2 deletions src/pages/settings/account/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import iconFlag from '~icons/heroicons/flag?raw'
import iconName from '~icons/heroicons/user?raw'
import { getRecentEmailOtpVerification } from '~/services/emailOtp'
import { getFormatLocaleOptions, resolveFormatLocale } from '~/services/formatLocale'
import { getGitHubProfile, GitHubProfileError } from '~/services/githubProfile'
import { getGitHubProfile, GitHubProfileError, isGitHubAccountAlreadyLinkedError } from '~/services/githubProfile'
import { pickPhoto, takePhoto } from '~/services/photos'
import { getCurrentPlanNameOrg, isPayingOrg, useSupabase } from '~/services/supabase'
import { useDialogV2Store } from '~/stores/dialogv2'
Expand Down Expand Up @@ -131,7 +131,9 @@ async function confirmGitHubProfile() {

if (error || !user) {
githubProfile.value = null
githubProfileError.value = t('account-error')
githubProfileError.value = isGitHubAccountAlreadyLinkedError(error)
? t('github-username-error-already_linked')
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
: t('account-error')
return
}

Expand Down
7 changes: 7 additions & 0 deletions src/services/githubProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ export function normalizeGitHubUsername(username: string) {
return username.trim()
}

export function isGitHubAccountAlreadyLinkedError(error: unknown): boolean {
return typeof error === 'object'
&& error !== null
&& 'code' in error
&& error.code === '23505'
}

export async function getGitHubProfile(username: string): Promise<GitHubProfile> {
const normalizedUsername = normalizeGitHubUsername(username)
if (!/^(?:[a-z\d]|[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,37}[a-z\d])$/i.test(normalizedUsername))
Expand Down
25 changes: 25 additions & 0 deletions supabase/migrations/20260802171300_enforce_unique_github_id.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
DO $$
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
DECLARE
duplicate_github_id bigint;
duplicate_count bigint;
BEGIN
SELECT github_id, COUNT(*)
INTO duplicate_github_id, duplicate_count
FROM public.users
WHERE github_id IS NOT NULL
GROUP BY github_id
HAVING COUNT(*) > 1
ORDER BY github_id
LIMIT 1;

IF duplicate_github_id IS NOT NULL THEN
RAISE EXCEPTION
'Cannot enforce unique GitHub account links: github_id % is linked to % users',
duplicate_github_id,
duplicate_count;
END IF;
END
$$;

ALTER TABLE public.users
ADD CONSTRAINT users_github_id_key UNIQUE (github_id);
Comment thread
WcaleNieWolny marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
91 changes: 90 additions & 1 deletion supabase/tests/65_test_github_user_id.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
BEGIN;

SELECT plan(3);
SELECT plan(8);

SELECT tests.create_supabase_user(
'github_id_unique_first',
'github_id_unique_first@test.local'
);
SELECT tests.create_supabase_user(
'github_id_unique_second',
'github_id_unique_second@test.local'
);

INSERT INTO public.users (id, email, created_at, updated_at)
VALUES
(
tests.get_supabase_uid('github_id_unique_first'),
'github_id_unique_first@test.local',
NOW(),
NOW()
),
(
tests.get_supabase_uid('github_id_unique_second'),
'github_id_unique_second@test.local',
NOW(),
NOW()
)
ON CONFLICT (id) DO NOTHING;

SELECT ok(
EXISTS (
Expand Down Expand Up @@ -37,6 +62,70 @@ SELECT is(
'users.github_id is nullable'
);

SELECT is(
(
SELECT contype::text
FROM pg_constraint
WHERE conrelid = 'public.users'::regclass
AND conname = 'users_github_id_key'
),
'u',
'users.github_id has the named unique constraint'
);

UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_first');

SELECT throws_ok(
$$
UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_second');
$$,
'23505',
'duplicate key value violates unique constraint "users_github_id_key"',
'different users cannot save the same non-null GitHub ID'
);

SELECT lives_ok(
$$
UPDATE public.users
SET github_id = NULL
WHERE id IN (
tests.get_supabase_uid('github_id_unique_first'),
tests.get_supabase_uid('github_id_unique_second')
);
$$,
'multiple users may have a null GitHub ID'
);

UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_first');

SELECT lives_ok(
$$
UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_first');
$$,
'a user may re-save their own GitHub ID'
);

SELECT lives_ok(
$$
UPDATE public.users
SET github_id = NULL
WHERE id = tests.get_supabase_uid('github_id_unique_first');

UPDATE public.users
SET github_id = 123456789
WHERE id = tests.get_supabase_uid('github_id_unique_second');
$$,
'clearing a GitHub link lets another user claim that ID'
);

SELECT * FROM finish(); -- noqa: AM04

ROLLBACK;
15 changes: 14 additions & 1 deletion tests/github-profile.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { GitHubProfileError } from '../src/services/githubProfile'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getGitHubProfile, normalizeGitHubUsername } from '../src/services/githubProfile'
import { getGitHubProfile, isGitHubAccountAlreadyLinkedError, normalizeGitHubUsername } from '../src/services/githubProfile'

const originalFetch = globalThis.fetch

Expand All @@ -9,6 +9,19 @@ afterEach(() => {
})

describe('github profile lookup', () => {
it('recognizes a PostgREST unique violation as an already-linked GitHub account', () => {
expect(isGitHubAccountAlreadyLinkedError({ code: '23505' })).toBe(true)
})

it('does not treat malformed or unrelated errors as an already-linked GitHub account', () => {
expect(isGitHubAccountAlreadyLinkedError(null)).toBe(false)
expect(isGitHubAccountAlreadyLinkedError(undefined)).toBe(false)
expect(isGitHubAccountAlreadyLinkedError('23505')).toBe(false)
expect(isGitHubAccountAlreadyLinkedError({})).toBe(false)
expect(isGitHubAccountAlreadyLinkedError({ code: 23505 })).toBe(false)
expect(isGitHubAccountAlreadyLinkedError({ code: '42501' })).toBe(false)
})

it('normalizes the entered username and requests the public GitHub API', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
id: 42,
Expand Down
Loading