Skip to content
Open
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
6 changes: 3 additions & 3 deletions backend/services/storageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
const crypto = require('crypto');
const { randomUUID } = require('crypto');
const { normalizeGitHubRepoName } = require('../../shared/githubRepoName');
const { applyVizablyRepoPrefix } = require('../../shared/githubRepoName');

const MANIFEST_PATH = 'vizably.json';
/** Pre-rename store root — still loadable; rewritten to `MANIFEST_PATH` on load. */
Expand Down Expand Up @@ -155,7 +155,7 @@ class StorageService {
* Create a private empty GitHub repo for the signed-in user (App UAT).
* Does not initialize a Vizably store — caller runs fit-check then init.
*
* @param {string} name repository name (not owner/name)
* @param {string} name repository name (not owner/name); stored as `viz_<name>`
* @param {StorageClients} clients must include githubUserClient (or githubClient as UAT)
* @param {object} [options]
* @param {string} [options.installUrl] App install URL when needsInstall
Expand Down Expand Up @@ -374,7 +374,7 @@ class StorageService {
throw err;
}

const normalized = normalizeGitHubRepoName(name);
const normalized = applyVizablyRepoPrefix(name);
if (!normalized) {
const err = new Error('Repository name is required');
err.status = 400;
Expand Down
25 changes: 23 additions & 2 deletions backend/tests/githubRepoName.test.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
/**
* Unit tests for shared GitHub repo name normalization (#85).
* Unit tests for shared GitHub repository name helpers.
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const { normalizeGitHubRepoName } = require('../../shared/githubRepoName');
const {
normalizeGitHubRepoName,
applyVizablyRepoPrefix,
VIZABLY_REPO_PREFIX,
} = require('../../shared/githubRepoName');

test('normalizeGitHubRepoName trims leading and trailing whitespace', () => {
assert.equal(normalizeGitHubRepoName(' vizably-scans '), 'vizably-scans');
Expand All @@ -23,3 +27,20 @@ test('normalizeGitHubRepoName returns empty for whitespace-only input', () => {
assert.equal(normalizeGitHubRepoName(null), '');
assert.equal(normalizeGitHubRepoName(undefined), '');
});

test('applyVizablyRepoPrefix prepends viz_ after normalizing', () => {
assert.equal(applyVizablyRepoPrefix('scans'), 'viz_scans');
assert.equal(applyVizablyRepoPrefix(' accessibility results '), 'viz_accessibility-results');
assert.equal(applyVizablyRepoPrefix('reports'), `${VIZABLY_REPO_PREFIX}reports`);
});

test('applyVizablyRepoPrefix is idempotent when the prefix is already present', () => {
assert.equal(applyVizablyRepoPrefix('viz_scans'), 'viz_scans');
assert.equal(applyVizablyRepoPrefix('VIZ_reports'), 'viz_reports');
assert.equal(applyVizablyRepoPrefix(' viz_my-repo '), 'viz_my-repo');
});

test('applyVizablyRepoPrefix returns empty for blank input', () => {
assert.equal(applyVizablyRepoPrefix(' '), '');
assert.equal(applyVizablyRepoPrefix(null), '');
});
63 changes: 43 additions & 20 deletions backend/tests/storageService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,8 @@ test('checkGitHubRepoNameAvailability returns available on 404', async () => {
githubUserClient: client,
});
assert.equal(result.status, 'available');
assert.equal(result.full_name, 'sam/fresh-repo');
assert.equal(result.normalizedName, 'viz_fresh-repo');
assert.equal(result.full_name, 'sam/viz_fresh-repo');
});

test('checkGitHubRepoNameAvailability returns taken when repo exists', async () => {
Expand All @@ -323,7 +324,8 @@ test('checkGitHubRepoNameAvailability returns taken when repo exists', async ()
githubUserClient: client,
});
assert.equal(result.status, 'taken');
assert.match(result.message, /already exists/);
assert.equal(result.normalizedName, 'viz_site-audits');
assert.match(result.message, /viz_site-audits/);
});

test('checkGitHubRepoNameAvailability returns invalid for bad names', async () => {
Expand All @@ -343,14 +345,15 @@ test('createGitHubRepository creates a private empty repo and returns storageRef
{
id: 1,
contents: 'write',
repos: ['sam/vizably-new'],
repos: ['sam/viz_scans'],
},
],
});
const result = await storageService.createGitHubRepository('vizably-new', {
const result = await storageService.createGitHubRepository('scans', {
githubUserClient: client,
});
assert.equal(result.storageRef.full_name, 'sam/vizably-new');
assert.equal(result.storageRef.full_name, 'sam/viz_scans');
assert.equal(result.storageRef.name, 'viz_scans');
assert.equal(result.storageRef.id, 'R_kgNew');
assert.equal(result.needsInstall, false);
assert.equal(result.installUrl, null);
Expand All @@ -368,11 +371,12 @@ test('createGitHubRepository sets needsInstall when App cannot write yet', async
],
});
const result = await storageService.createGitHubRepository(
'vizably-new',
'scans',
{ githubUserClient: client },
{ installUrl: 'https://github.com/apps/vizably/installations/new' },
);
assert.equal(result.needsInstall, true);
assert.equal(result.storageRef.name, 'viz_scans');
assert.equal(
result.installUrl,
'https://github.com/apps/vizably/installations/new',
Expand All @@ -391,10 +395,11 @@ test('createGitHubRepository skips install hop when installation covers all repo
},
],
});
const result = await storageService.createGitHubRepository('vizably-new', {
const result = await storageService.createGitHubRepository('scans', {
githubUserClient: client,
});
assert.equal(result.needsInstall, false);
assert.equal(result.storageRef.name, 'viz_scans');
});

test('createGitHubRepository surfaces rate limits instead of needsInstall', async () => {
Expand All @@ -407,13 +412,13 @@ test('createGitHubRepository surfaces rate limits instead of needsInstall', asyn
};
const client = createMockGitHubClient({ installationProbeError: probeErr });
await assert.rejects(
() => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }),
() => storageService.createGitHubRepository('scans', { githubUserClient: client }),
(err) => {
assert.equal(err.code, 'GITHUB_RATE_LIMITED');
assert.equal(err.status, 429);
assert.match(err.message, /rate-limited/i);
assert.match(err.message, /do not reinstall/i);
assert.equal(err.storageRef?.full_name, 'sam/vizably-new');
assert.equal(err.storageRef?.full_name, 'sam/viz_scans');
return true;
},
);
Expand All @@ -425,12 +430,12 @@ test('createGitHubRepository surfaces network failures instead of needsInstall',
probeErr.code = 'ENOTFOUND';
const client = createMockGitHubClient({ installationProbeError: probeErr });
await assert.rejects(
() => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }),
() => storageService.createGitHubRepository('scans', { githubUserClient: client }),
(err) => {
assert.equal(err.code, 'GITHUB_NETWORK_ERROR');
assert.equal(err.status, 503);
assert.match(err.message, /network/i);
assert.equal(err.storageRef?.name, 'vizably-new');
assert.equal(err.storageRef?.name, 'viz_scans');
return true;
},
);
Expand All @@ -443,7 +448,7 @@ test('createGitHubRepository surfaces auth failures instead of needsInstall', as
probeErr.response = { data: { message: 'Bad credentials' }, headers: {} };
const client = createMockGitHubClient({ installationProbeError: probeErr });
await assert.rejects(
() => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }),
() => storageService.createGitHubRepository('scans', { githubUserClient: client }),
(err) => {
assert.equal(err.code, 'GITHUB_AUTH_FAILED');
assert.equal(err.status, 401);
Expand All @@ -460,7 +465,7 @@ test('createGitHubRepository surfaces GitHub outages instead of needsInstall', a
probeErr.response = { data: { message: 'Server Error' }, headers: {} };
const client = createMockGitHubClient({ installationProbeError: probeErr });
await assert.rejects(
() => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }),
() => storageService.createGitHubRepository('scans', { githubUserClient: client }),
(err) => {
assert.equal(err.code, 'GITHUB_UNAVAILABLE');
assert.equal(err.status, 503);
Expand All @@ -474,7 +479,7 @@ test('createGitHubRepository rejects invalid names', async () => {
const storageService = new StorageService();
const client = createMockGitHubClient();
await assert.rejects(
() => storageService.createGitHubRepository('sam/vizably-new', { githubUserClient: client }),
() => storageService.createGitHubRepository('sam/scans', { githubUserClient: client }),
/name only/,
);
await assert.rejects(
Expand All @@ -487,22 +492,40 @@ test('createGitHubRepository rejects invalid names', async () => {
);
});

test('createGitHubRepository normalizes whitespace before create', async () => {
test('createGitHubRepository prefixes and normalizes whitespace before create', async () => {
const storageService = new StorageService();
const client = createMockGitHubClient({
installationProbe: [
{
id: 1,
contents: 'write',
repos: ['sam/vizably-new'],
repos: ['sam/viz_accessibility-results'],
},
],
});
const result = await storageService.createGitHubRepository(' vizably new ', {
const result = await storageService.createGitHubRepository(' accessibility results ', {
githubUserClient: client,
});
assert.equal(result.storageRef.full_name, 'sam/vizably-new');
assert.equal(result.storageRef.name, 'vizably-new');
assert.equal(result.storageRef.full_name, 'sam/viz_accessibility-results');
assert.equal(result.storageRef.name, 'viz_accessibility-results');
});

test('createGitHubRepository does not double-prefix an existing viz_ name', async () => {
const storageService = new StorageService();
const client = createMockGitHubClient({
installationProbe: [
{
id: 1,
contents: 'write',
repository_selection: 'all',
repos: [],
},
],
});
const result = await storageService.createGitHubRepository('viz_reports', {
githubUserClient: client,
});
assert.equal(result.storageRef.name, 'viz_reports');
});

test('createGitHubRepository maps name-taken conflicts', async () => {
Expand All @@ -518,7 +541,7 @@ test('createGitHubRepository maps name-taken conflicts', async () => {
const client = createMockGitHubClient({ createRepoError: conflict });
await assert.rejects(
() => storageService.createGitHubRepository('taken', { githubUserClient: client }),
/already exists/,
/viz_taken/,
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ scans/index scans/ dir (init or cancel)

The user can also choose **"Create a new repo/folder"** instead of selecting an
existing one — that is just the `initializable` path against a freshly created
store.
store. For GitHub, Vizably always creates the repository as `viz_<name>`
(for example `viz_scans`, `viz_reports`) so Vizably-managed storage is easy to
recognize and harder to confuse with unrelated repos. The prefix is applied on
create and name-availability checks; selecting an existing repo is unchanged.

### Identity model — read this first

Expand Down Expand Up @@ -539,8 +542,8 @@ await octokit.repos.listForAuthenticatedUser({ visibility: 'all', per_page: 100
// Existence / fit-check read — get the manifest blob
await octokit.repos.getContent({ owner, repo, path: 'vizably.json' }); // 404 ⇒ no manifest

// Create a repo for the "new" path
await octokit.repos.createForAuthenticatedUser({ name, private: true });
// Create a repo for the "new" path — name is always `viz_<normalized>`
await octokit.repos.createForAuthenticatedUser({ name: 'viz_scans', private: true });

// Atomic-ish write (pass sha to update; omit to create)
await octokit.repos.createOrUpdateFileContents({ owner, repo, path, message, content, branch, sha });
Expand Down
Loading