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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
deploy:
./hacks/deploy.sh

.PHONY: test
test:
node --test assets/workspace.test.js assets/crypto.test.js assets/integration.test.js


# BEGIN: lint-install .
# http://github.com/codeGROOVE-dev/lint-install
Expand Down
90 changes: 90 additions & 0 deletions assets/README.tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Automated Tests

## Running Tests

```bash
make test
```

Or directly with Node.js:

```bash
node --test assets/workspace.test.js assets/crypto.test.js
```

## Test Coverage

### `integration.test.js`
Integration tests for app initialization and token handling:

- **State initialization**: Validates null/undefined/non-string token handling
- Handles null accessToken gracefully (prevents TypeError)
- Detects PAT token type (ghp_ prefix)
- Detects OAuth token type (gho_ prefix)
- Handles undefined, empty string, and non-string tokens
- Validates OAuth warning check at user.js:325 (regression test)

- **Async token loading**: Validates async token decryption flow
- Initializes with null token before async load completes
- Successfully sets token after async decryption

### `crypto.test.js`
Tests for the crypto module (`assets/crypto.js`):

- **encryptToken() and decryptToken()**: Validates encryption/decryption
- Successfully encrypts and decrypts tokens
- Produces different ciphertext for same token (random IV)
- Fails to decrypt with wrong username
- Fails to decrypt with wrong domain
- Fails to decrypt with wrong timestamp
- Handles various GitHub token formats (PAT, OAuth, fine-grained)
- Requires all parameters for encryption/decryption
- Handles realistic login scenarios
- Different timestamps produce different encryption keys

### `workspace.test.js`
Tests for the workspace module (`assets/workspace.js`):

- **currentWorkspace()**: Validates workspace detection from subdomain
- Base domain returns `null`
- Reserved subdomains (www, api, etc.) return `null`
- Org subdomains return the org name
- Localhost returns `null`

- **hiddenOrgs()**: Validates reading hidden organizations from cookies
- Returns empty array when no preferences exist
- Reads from workspace-specific cookies
- Handles both personal and org workspaces

- **setHiddenOrgs()**: Validates setting hidden organizations
- Creates workspace-specific cookies
- Handles empty arrays
- Works for both personal and org workspaces

- **toggleOrgVisibility()**: Validates toggling org visibility
- Adds org when not present
- Removes org when present
- Works correctly across multiple toggles

- **initializeDefaults()**: Validates default behavior for org workspaces
- Hides personal account PRs by default in org workspaces
- Does NOT set defaults in personal workspace
- Does NOT override existing preferences
- Requires username cookie to be set
- Maintains independent preferences per workspace

- **Integration tests**: Validates full workflow scenarios
- Users can override defaults by toggling
- Preferences persist across page reloads
- Repeated toggles work correctly (idempotency)
- No duplicate orgs from rapid clicking (regression test)

## Test Framework

Tests use Node.js built-in test runner (available in Node.js 18+). No external dependencies required.

The tests create a mock DOM environment with:
- `MockDocument`: Simulates `document` with cookie storage
- `MockWindow`: Simulates `window` with location/hostname

This allows testing browser-specific code in a Node.js environment.
42 changes: 36 additions & 6 deletions assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const App = (() => {
const state = {
currentUser: null,
viewingUser: null,
accessToken: Auth.getStoredToken(),
accessToken: null, // Will be loaded async in init()
organizations: [],
pullRequests: {
incoming: [],
Expand Down Expand Up @@ -380,7 +380,10 @@ const App = (() => {
const setCookie = (name, value, days) => {
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Strict`;
// Use domain cookie for cross-subdomain persistence
const isSecure = window.location.protocol === "https:";
const securePart = isSecure ? ";Secure" : "";
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;domain=.ready-to-review.dev;SameSite=Lax${securePart}`;
};

const handlePRAction = async (action, prId) => {
Expand Down Expand Up @@ -422,8 +425,29 @@ const App = (() => {
// Load current user
const loadCurrentUser = async () => {
state.currentUser = await Auth.loadCurrentUser();
// Store username and login timestamp in cookies
if (state.currentUser && state.currentUser.login) {
// Only set if not already set (preserve original timestamp)
const existingUsername = getCookie("username");
if (!existingUsername) {
const timestamp = Date.now().toString();
setCookie("username", state.currentUser.login, 365);
setCookie("login_ts", timestamp, 365);
}
}
};

function getCookie(name) {
const nameEQ = `${name}=`;
const ca = document.cookie.split(";");
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === " ") c = c.substring(1, c.length);
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
}
return null;
}

// GitHub API wrapper that uses Auth module
const githubAPI = async (endpoint, options = {}) => {
try {
Expand Down Expand Up @@ -640,6 +664,9 @@ const App = (() => {

// Initialize
const init = async () => {
// Load access token first (async now due to encryption)
state.accessToken = await Auth.getStoredToken();

const urlParams = new URLSearchParams(window.location.search);
const demo = urlParams.get("demo");
const urlContext = parseURL();
Expand Down Expand Up @@ -677,7 +704,7 @@ const App = (() => {
// Handle changelog page routing
if (urlContext && urlContext.isChangelog) {
updateSearchInputVisibility();
const token = Auth.getStoredToken();
const token = await Auth.getStoredToken();
if (token) {
try {
await loadCurrentUser();
Expand Down Expand Up @@ -721,7 +748,7 @@ const App = (() => {
const path = window.location.pathname;
if (path === "/notifications" || path.match(/^\/notifications\/gh\/[^/]+$/)) {
updateSearchInputVisibility();
const token = Auth.getStoredToken();
const token = await Auth.getStoredToken();
if (token) {
try {
await loadCurrentUser();
Expand All @@ -738,7 +765,7 @@ const App = (() => {
// Handle robots page routing
if (path === "/robots" || path.match(/^\/robots\/gh\/[^/]+$/)) {
updateSearchInputVisibility();
const token = Auth.getStoredToken();
const token = await Auth.getStoredToken();
if (!token) {
showToast("Please login to configure Robot Army", "error");
window.location.href = "/";
Expand Down Expand Up @@ -855,7 +882,7 @@ const App = (() => {
const authCodeExchanged = await Auth.handleAuthCodeCallback();

// Re-check for authentication token (it might have been set after module load or auth code exchange)
state.accessToken = Auth.getStoredToken();
state.accessToken = await Auth.getStoredToken();
console.log("[App.init] Checked for access token:", state.accessToken ? "found" : "not found");

// If we just exchanged an auth code successfully, reload to start with fresh state
Expand Down Expand Up @@ -906,6 +933,9 @@ const App = (() => {
updateSearchInputVisibility();
await loadCurrentUser();

// Initialize workspace defaults after user is loaded
Workspace.initializeDefaults();

// If at root URL, redirect to user's page
if (!urlContext && state.currentUser) {
window.location.href = `/u/${state.currentUser.login}`;
Expand Down
92 changes: 60 additions & 32 deletions assets/auth.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Authentication Module for Ready To Review
console.log("[Auth Module] Loading...");

import { Crypto } from "./crypto.js";

// Log URL parameters on page load for debugging OAuth flow (without exposing secrets)
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("code") || urlParams.has("state") || urlParams.has("error")) {
Expand All @@ -18,20 +20,29 @@ export const Auth = (() => {
const CONFIG = {
CLIENT_ID: "Iv23liYmAKkBpvhHAnQQ",
API_BASE: "https://api.github.com",
STORAGE_KEY: "github_token", // Legacy localStorage key (fallback)
COOKIE_KEY: "github_token", // Store all tokens in cookies for cross-subdomain support
COOKIE_KEY: "access_token", // Encrypted token in cookies
OAUTH_REDIRECT_URI: "https://auth.ready-to-review.dev/oauth/callback",
};

// Cookie Functions
function setCookie(name, value, days) {
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);

// SECURITY: Always require HTTPS for cookies containing sensitive data
// Fail loudly on HTTP to catch development/deployment issues early
if (window.location.protocol !== "https:") {
console.error("[SECURITY] Cannot set cookie over insecure HTTP connection");
console.error("[SECURITY] Cookie name:", name);
console.error("[SECURITY] Current protocol:", window.location.protocol);
throw new Error("Cookies can only be set over HTTPS for security");
}

// Use domain cookie to share across all subdomains of ready-to-review.dev
// SameSite=Lax allows cookies to be sent on top-level navigation (OAuth redirects)
const isSecure = window.location.protocol === "https:";
const securePart = isSecure ? ";Secure" : "";
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;domain=.ready-to-review.dev;SameSite=Lax${securePart}`;
// SameSite=Lax: Required for OAuth callback (cross-site navigation with state cookie)
// Secure: Always set - enforced by protocol check above
// HttpOnly: Cannot use - JavaScript needs to read/decrypt tokens
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;domain=.ready-to-review.dev;SameSite=Lax;Secure`;
}

function getCookie(name) {
Expand All @@ -50,35 +61,47 @@ export const Auth = (() => {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;domain=.ready-to-review.dev;`;
}

const getStoredToken = () => {
// Always check cookie first (works across subdomains)
const cookieToken = getCookie(CONFIG.COOKIE_KEY);
if (cookieToken) return cookieToken;

// Fallback to localStorage for legacy tokens, then migrate to cookie
const localToken = localStorage.getItem(CONFIG.STORAGE_KEY);
if (localToken) {
console.log("[Auth] Migrating token from localStorage to cookie for cross-subdomain support");
setCookie(CONFIG.COOKIE_KEY, localToken, 10);
localStorage.removeItem(CONFIG.STORAGE_KEY);
return localToken;
// Get encryption context (username, domain, and timestamp)
const getEncryptionContext = () => {
const username = getCookie("username");
const timestamp = getCookie("login_ts");
const domain = window.location.hostname.split(".").slice(-2).join("."); // Get base domain

return { username, domain, timestamp };
};

const getStoredToken = async () => {
const { username, domain, timestamp } = getEncryptionContext();

const encryptedToken = getCookie(CONFIG.COOKIE_KEY);
if (!encryptedToken || !username || !domain || !timestamp) {
return null;
}

return null;
try {
console.log("[Auth] Decrypting stored token");
return await Crypto.decryptToken(encryptedToken, username, domain, timestamp);
} catch (error) {
console.error("[Auth] Failed to decrypt token, clearing invalid token:", error);
deleteCookie(CONFIG.COOKIE_KEY);
return null;
}
};

const storeToken = (token) => {
// Always store in cookie for cross-subdomain support (10 days)
setCookie(CONFIG.COOKIE_KEY, token, 10);
// Clean up any legacy localStorage token
localStorage.removeItem(CONFIG.STORAGE_KEY);
const storeToken = async (token) => {
const { username, domain, timestamp } = getEncryptionContext();

if (!username || !domain || !timestamp) {
throw new Error("Cannot encrypt token: username, domain, or timestamp missing");
}

console.log("[Auth] Encrypting token for storage");
const encrypted = await Crypto.encryptToken(token, username, domain, timestamp);
setCookie(CONFIG.COOKIE_KEY, encrypted, 10);
};

const clearToken = () => {
// Clear cookie (primary storage)
deleteCookie(CONFIG.COOKIE_KEY);
// Clear localStorage (legacy)
localStorage.removeItem(CONFIG.STORAGE_KEY);
};

const initiateOAuthLogin = () => {
Expand Down Expand Up @@ -196,7 +219,7 @@ export const Auth = (() => {
});

if (response.ok) {
storeToken(token); // Store in cookie
await storeToken(token); // Store encrypted token in cookie
closePATModal();
window.location.reload();
} else {
Expand Down Expand Up @@ -244,8 +267,13 @@ export const Auth = (() => {

const data = await response.json();

// Store token in localStorage
storeToken(data.token);
// Store username and login timestamp (milliseconds since 1970)
const timestamp = Date.now().toString();
setCookie("username", data.username, 365);
setCookie("login_ts", timestamp, 365);

// Store encrypted token
await storeToken(data.token);

console.log("[Auth] Successfully exchanged auth code for token, user:", data.username);

Expand Down Expand Up @@ -279,7 +307,7 @@ export const Auth = (() => {
...options.headers,
};

const token = getStoredToken();
const token = await getStoredToken();
if (token) {
headers.Authorization = `token ${token}`;
}
Expand Down Expand Up @@ -350,7 +378,7 @@ export const Auth = (() => {

// GraphQL API function with retry logic
const githubGraphQL = async (query, variables = {}, retries = 5) => {
const token = getStoredToken();
const token = await getStoredToken();
if (!token) {
throw new Error("No authentication token available");
}
Expand Down
Loading