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
36 changes: 30 additions & 6 deletions backend/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,32 @@ function makeAuthRouter({ authService, storageService }) {
}
});

router.get('/storage/discover', requireAuth, async (req, res) => {
try {
const provider = req.query.provider || 'github';
if (provider !== 'github') {
return res.status(400).json({
error: 'Only provider=github is supported in Phase 1',
});
}

const clients = await authService.clientsFor(req.user);
if (!clients.githubUserClient && !clients.githubClient) {
return res.status(400).json({ error: 'GitHub client is not available' });
}

const result = await storageService.discoverAccountStores(provider, clients, {
sessionStorageRef: req.user?.storage || null,
});
return res.json(result);
} catch (err) {
console.error(err);
return res.status(500).json({
error: err.message || 'Failed to discover storage',
});
}
});

router.post('/storage/create', requireAuth, async (req, res) => {
try {
const { name, provider = 'github' } = req.body || {};
Expand All @@ -134,19 +160,17 @@ function makeAuthRouter({ authService, storageService }) {
error: 'Only provider=github is supported for repository creation',
});
}
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'name is required' });
}

const clients = await authService.clientsFor(req.user);
if (!clients.githubUserClient && !clients.githubClient) {
return res.status(400).json({ error: 'GitHub client is not available' });
}

const installUrl = await authService.getInstallationSetupUrl();
const result = await storageService.createGitHubRepository(name, clients, {
installUrl,
});
const result =
typeof name === 'string' && name.trim()
? await storageService.createGitHubRepository(name.trim(), clients, { installUrl })
: await storageService.createNextVizablyGitHubRepository(clients, { installUrl });
return res.status(201).json({
provider: 'github',
storageRef: result.storageRef,
Expand Down
167 changes: 164 additions & 3 deletions backend/services/storageService.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
*/
const crypto = require('crypto');
const { randomUUID } = require('crypto');
const { normalizeGitHubRepoName } = require('../../shared/githubRepoName');
const {
applyVizablyRepoPrefix,
nextVizablyStoreName,
normalizeGitHubRepoName,
VIZABLY_DEFAULT_STORE_NAME,
} = require('../../shared/githubRepoName');
const { collectAllGitHubPages, findInGitHubPages } = require('./githubPagination');

const MANIFEST_PATH = 'vizably.json';
Expand Down Expand Up @@ -160,7 +165,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 @@ -215,6 +220,162 @@ class StorageService {
return { storageRef, needsInstall, installUrl };
}

/**
* Create the next unused default store: `viz_scans`, then `viz_scans-2`, …
*
* @param {StorageClients} clients
* @param {object} [options]
* @param {string} [options.installUrl]
*/
async createNextVizablyGitHubRepository(clients, options = {}) {
const taken = [];
for (let n = 1; n <= 50; n += 1) {
const name = nextVizablyStoreName(taken);
try {
return await this.createGitHubRepository(name, clients, options);
} catch (err) {
if (err.code === 'REPO_NAME_TAKEN') {
taken.push(name);
continue;
}
throw err;
}
}
const err = new Error('Could not find an available Vizably repository name');
err.status = 422;
err.code = 'REPO_NAME_TAKEN';
throw err;
}

/**
* Find existing Vizably account stores (manifest-based, provider-neutral).
* Order: session storageRef → GET expected name (`viz_scans`) → list repos.
*
* @param {'github' | 'google'} provider
* @param {StorageClients} clients
* @param {object} [options]
* @param {object} [options.sessionStorageRef]
* @returns {Promise<{
* provider: string,
* stores: Array<{ storageRef: object, validation: object }>,
* source: 'session' | 'expected-name' | 'list' | null,
* }>}
*/
async discoverAccountStores(provider, clients, options = {}) {
if (provider === 'google') {
return { provider, stores: [], source: null };
}
if (provider !== 'github') {
throw new Error('Unsupported storage provider');
}
return this._discoverGitHubAccountStores(clients, options);
}

/**
* @param {StorageClients} clients
* @param {{ sessionStorageRef?: object }} [options]
* @private
*/
async _discoverGitHubAccountStores(clients, { sessionStorageRef } = {}) {
const octokit = clients.githubUserClient ?? clients.githubClient;
if (!octokit) {
throw new Error('GitHub client is required to discover storage');
}

const consider = async (storageRef) => {
const validation = await this.validateStorage('github', storageRef, clients);
if (!this._isDiscoveredAccountStore(validation)) {
return null;
}
return {
storageRef: {
id: storageRef.id,
full_name: storageRef.full_name,
html_url: storageRef.html_url,
name: storageRef.name || storageRef.full_name?.split('/')[1],
},
validation,
};
};

if (sessionStorageRef?.full_name || sessionStorageRef?.id) {
try {
const hit = await consider(sessionStorageRef);
if (hit) {
return { provider: 'github', stores: [hit], source: 'session' };
}
} catch {
// Stale session ref — fall through to name GET / listing.
}
}

let owner;
try {
const { data: user } = await octokit.rest.users.getAuthenticated();
owner = user.login;
} catch (err) {
throw new Error(
err?.status === 401
? 'GitHub authentication failed. Sign out and sign in again.'
: 'Could not look up your GitHub username.',
);
}

try {
const { data: repo } = await octokit.rest.repos.get({
owner,
repo: VIZABLY_DEFAULT_STORE_NAME,
});
const hit = await consider({
id: repo.node_id,
full_name: repo.full_name,
html_url: repo.html_url,
name: repo.name,
});
if (hit) {
return { provider: 'github', stores: [hit], source: 'expected-name' };
}
} catch (err) {
if (err?.status === 429 || (err?.status === 403 && /rate limit/i.test(String(err.message)))) {
throw err;
}
}

const repos = await this.listGitHubRepos(octokit);
const stores = [];
for (const repo of repos) {
try {
const hit = await consider({
id: repo.id,
full_name: repo.full_name,
html_url: repo.html_url,
});
if (hit) {
stores.push(hit);
}
} catch {
// Skip repos we cannot inspect.
}
}
return { provider: 'github', stores, source: 'list' };
}

/**
* A discovered account store is identified by vizably.json (or legacy
* equalview.json), never by repository name.
* @param {{ status?: string, reason?: string | null }} validation
* @private
*/
_isDiscoveredAccountStore(validation) {
if (!validation) {
return false;
}
if (validation.status === 'loadable' || validation.status === 'incompatible') {
return true;
}
return validation.status === 'invalid' && validation.reason === 'malformed_manifest';
}

/**
* True when a Vizably App installation with Contents write includes this repo.
* Does not fall back to the user's personal push bit — create needs the App.
Expand Down Expand Up @@ -390,7 +551,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
60 changes: 53 additions & 7 deletions backend/tests/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -388,18 +388,64 @@ test('POST /api/auth/storage/create returns storageRef and needsInstall', async
assert.match(res.body.installUrl, /installations\/new/);
});

test('POST /api/auth/storage/create requires name', async () => {
test('GET /api/auth/storage/discover returns discovered stores', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({ githubUserClient: {} }),
getInstallationSetupUrl: async () => 'https://github.com/settings/installations',
clientsFor: async () => ({ githubUserClient: { mock: true } }),
},
storageService: {
discoverAccountStores: async (provider, _clients, options) => {
assert.equal(provider, 'github');
assert.equal(options.sessionStorageRef, AUTHED_USER.storage);
return {
provider: 'github',
stores: [
{
storageRef: {
id: 'R_kg',
full_name: 'sam/viz_scans',
html_url: 'https://github.com/sam/viz_scans',
},
validation: { status: 'loadable' },
},
],
source: 'expected-name',
};
},
},
storageService: {},
});
const res = await request(app).post('/api/auth/storage/create').send({});
assert.equal(res.status, 400);
assert.match(res.body.error, /name is required/);
const res = await request(app).get('/api/auth/storage/discover?provider=github');
assert.equal(res.status, 200);
assert.equal(res.body.source, 'expected-name');
assert.equal(res.body.stores[0].storageRef.full_name, 'sam/viz_scans');
});

test('POST /api/auth/storage/create uses the default store when name is omitted', async () => {
const app = createAuthedApp({
user: AUTHED_USER,
authService: {
clientsFor: async () => ({ githubUserClient: { mock: true } }),
getInstallationSetupUrl: async () =>
'https://github.com/apps/vizably/installations/new',
},
storageService: {
createNextVizablyGitHubRepository: async (_clients, options) => ({
storageRef: {
id: 'R_kgNew',
name: 'viz_scans',
full_name: 'sam/viz_scans',
private: true,
html_url: 'https://github.com/sam/viz_scans',
},
needsInstall: false,
installUrl: options.installUrl,
}),
},
});
const res = await request(app).post('/api/auth/storage/create').send({ provider: 'github' });
assert.equal(res.status, 201);
assert.equal(res.body.storageRef.name, 'viz_scans');
});

test('POST /api/auth/storage/create returns probe failures without needsInstall', async () => {
Expand Down
35 changes: 33 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,30 @@ 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), '');
});

test('nextVizablyStoreName starts at viz_scans then increments', () => {
const {
nextVizablyStoreName,
VIZABLY_DEFAULT_STORE_NAME,
} = require('../../shared/githubRepoName');
assert.equal(nextVizablyStoreName([]), VIZABLY_DEFAULT_STORE_NAME);
assert.equal(nextVizablyStoreName(['viz_scans']), 'viz_scans-2');
assert.equal(nextVizablyStoreName(['viz_scans', 'viz_scans-2']), 'viz_scans-3');
});
Loading