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
5 changes: 5 additions & 0 deletions hub/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ TEST_DATABASE_URL=postgresql://replica:replica@127.0.0.1:5432/postgres
# GitHub proves identity by default. Select legacy-repository only for compatibility.
HUB_ADMISSION_POLICY=hub-managed

# Generate separate host-held values. Bootstrap can enroll the first administrator once.
# Recovery works only when no active administrator remains and never reopens bootstrap.
# HUB_BOOTSTRAP_SECRET=<openssl rand -hex 32>
# HUB_ADMIN_RECOVERY_SECRET=<openssl rand -hex 32>

# Select the GitHub source-integration credential provider. See docs/identity.md.
HUB_GITHUB_CREDENTIAL_PROVIDER=database

Expand Down
4 changes: 2 additions & 2 deletions hub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Hub is being rebuilt on `build/hub/new` using TypeScript, Express, React, Vite, and Vitest. This branch lets contributors start the new app locally. It is not ready to replace the existing Hub.

The API exposes its health check and GitHub authentication and session routes. The frontend provides the minimal GitHub sign-in and sign-out surface with light and dark themes. Existing components, features, and pages remain in Storybook, but they are not connected to product APIs.
The API exposes its health check, GitHub authentication, sessions, and installation membership administration. The frontend provides GitHub sign-in, one-use administrator enrollment and recovery, and a minimal membership administration surface. Existing prototype components, features, and pages remain in Storybook, but they are not connected to the new product APIs.

Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. GitHub sign-in works when its dedicated client credentials are configured. The database has no product models; invitations, membership administration, product authorization, saved product data, live updates, and deployment are not implemented.
Contributors can use PostgreSQL and Prisma locally or in the devcontainer. The [background-job foundation](docs/background-jobs.md) provides PostgreSQL-backed BullMQ queues and independent on-demand and scheduled workers, without product jobs yet. GitHub sign-in works when its dedicated client credentials are configured. Hub-owned admission, invitations, membership roles and lifecycle, administrator recovery, and session revocation are implemented. Prototype authorization, saved prototype data, live updates, product jobs, and deployment are not implemented.

Use [Development](docs/development.md) to start the API and frontend. Review [Architecture](docs/architecture.md), [code architecture conventions](docs/backend-architecture.md), and [coding conventions](docs/coding-conventions.md) before adding behavior.

Expand Down
295 changes: 295 additions & 0 deletions hub/app/javascript/application/authentication/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ThemeProvider } from "@primer/react-brand"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"

Expand All @@ -19,6 +20,55 @@ function sessionResponse(
)
}

const administratorSession = {
authenticated: true,
configured: true,
csrfToken: "csrf-token",
user: {
id: "administrator",
provider: "github",
username: "administrator",
displayName: "Administrator",
avatarUrl: null,
},
membership: {
id: "administrator-membership",
role: "administrator",
status: "active",
},
}

const administration = {
memberships: {
memberships: [],
nextCursor: null,
},
invitations: {
invitations: [
{
id: "invitation",
provider: "github",
issuer: "https://github.com",
subject: "42",
role: "member",
expiresAt: "2026-11-05T18:00:00.000Z",
consumedAt: null,
revokedAt: null,
},
],
nextCursor: null,
},
}

function renderApplication(fetch: ReturnType<typeof vi.fn>) {
vi.stubGlobal("fetch", fetch)
render(
<ThemeProvider>
<AuthenticationApplication />
</ThemeProvider>,
)
}

describe("AuthenticationApplication()", () => {
it("shows a generic callback error and removes its query marker", async () => {
window.history.replaceState({}, "", "/?authentication=failed")
Expand Down Expand Up @@ -70,4 +120,249 @@ describe("AuthenticationApplication()", () => {
)
expect(fetchMock).toHaveBeenCalledTimes(2)
})

it("loads and appends later membership and invitation pages from their cursors", async () => {
const firstPage = {
memberships: {
memberships: [
{
id: "first-membership",
status: "active",
role: "member",
user: {
id: "first-user",
displayName: "First member",
externalIdentities: [],
},
},
],
nextCursor: "membership-cursor",
},
invitations: {
invitations: [
{
id: "first-invitation",
provider: "github",
issuer: "https://github.com",
subject: "first-subject",
role: "member",
expiresAt: "2026-11-05T18:00:00.000Z",
consumedAt: null,
revokedAt: null,
},
],
nextCursor: "invitation-cursor",
},
}
const fetch = vi
.fn()
.mockResolvedValueOnce(Response.json(administratorSession))
.mockResolvedValueOnce(Response.json(firstPage))
.mockResolvedValueOnce(
Response.json({
memberships: {
memberships: [
{
id: "second-membership",
status: "suspended",
role: "member",
user: {
id: "second-user",
displayName: "Second member",
externalIdentities: [],
},
},
],
nextCursor: null,
},
invitations: firstPage.invitations,
}),
)
.mockResolvedValueOnce(
Response.json({
memberships: firstPage.memberships,
invitations: {
invitations: [
{
id: "second-invitation",
provider: "github",
issuer: "https://github.com",
subject: "second-subject",
role: "member",
expiresAt: "2026-11-06T18:00:00.000Z",
consumedAt: null,
revokedAt: null,
},
],
nextCursor: null,
},
}),
)
renderApplication(fetch)

fireEvent.click(await screen.findByRole("button", { name: "Load more memberships" }))
expect(await screen.findByText("Second member")).toBeInTheDocument()
expect(screen.getByText("First member")).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith("/api/administration?membershipsCursor=membership-cursor", {
credentials: "same-origin",
})

fireEvent.click(screen.getByRole("button", { name: "Load more invitations" }))
expect(await screen.findByText("second-subject")).toBeInTheDocument()
expect(screen.getByText("first-subject")).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith("/api/administration?invitationsCursor=invitation-cursor", {
credentials: "same-origin",
})
expect(
screen.queryByRole("button", { name: /^Load more (memberships|invitations)$/ }),
).not.toBeInTheDocument()
})

it.each([
{
name: "an expired session",
status: 401,
error: "sign_in_required",
message: "Sign in to continue.",
},
{
name: "lost administrator access",
status: 403,
error: "administrator_required",
message: "Administrator access is required.",
},
])("removes stale controls when loading more detects $name", async (failure) => {
const firstPage = {
memberships: {
memberships: [],
nextCursor: "membership-cursor",
},
invitations: {
invitations: [],
nextCursor: null,
},
}
const fetch = vi
.fn()
.mockResolvedValueOnce(Response.json(administratorSession))
.mockResolvedValueOnce(Response.json(firstPage))
.mockResolvedValueOnce(
Response.json(
{ error: failure.error, message: failure.message },
{ status: failure.status },
),
)
.mockResolvedValueOnce(
Response.json({
authenticated: false,
configured: true,
csrfToken: "replacement-csrf-token",
}),
)
renderApplication(fetch)

fireEvent.click(await screen.findByRole("button", { name: "Load more memberships" }))

expect(await screen.findByRole("alert")).toHaveTextContent(failure.message)
expect(screen.queryByRole("heading", { name: "Hub membership" })).not.toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Load more memberships" })).not.toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith("/api/session", { credentials: "same-origin" })
})

it("starts same-identity verification when an administration mutation needs fresh authorization", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(Response.json(administratorSession))
.mockResolvedValueOnce(Response.json(administration))
.mockResolvedValueOnce(
Response.json(
{
error: "fresh_authorization_required",
message: "Verify your identity again.",
},
{ status: 403 },
),
)
.mockResolvedValueOnce(Response.json({ authorizationUrl: "https://github.example/verify" }))
renderApplication(fetch)

fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" }))

await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
"/api/session/verify",
expect.objectContaining({
method: "POST",
credentials: "same-origin",
headers: { "X-CSRF-Token": "csrf-token" },
}),
),
)
expect(screen.queryByText("fresh_authorization_required")).not.toBeInTheDocument()
})

it("shows the server-readable message for other administration failures", async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(Response.json(administratorSession))
.mockResolvedValueOnce(Response.json(administration))
.mockResolvedValueOnce(
Response.json(
{
error: "invitation_changed",
message: "The invitation is no longer available.",
},
{ status: 409 },
),
)
renderApplication(fetch)

fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" }))

expect(await screen.findByRole("alert")).toHaveTextContent(
"The invitation is no longer available.",
)
expect(screen.getByRole("alert")).not.toHaveTextContent("invitation_changed")
})

it.each([
{
name: "an expired session",
status: 401,
error: "sign_in_required",
message: "Sign in to continue.",
},
{
name: "lost administrator access",
status: 403,
error: "administrator_required",
message: "Administrator access is required.",
},
])("reloads session state and removes stale controls after $name", async (failure) => {
const fetch = vi
.fn()
.mockResolvedValueOnce(Response.json(administratorSession))
.mockResolvedValueOnce(Response.json(administration))
.mockResolvedValueOnce(
Response.json(
{ error: failure.error, message: failure.message },
{ status: failure.status },
),
)
.mockResolvedValueOnce(
Response.json({
authenticated: false,
configured: true,
csrfToken: "replacement-csrf-token",
}),
)
renderApplication(fetch)

fireEvent.click(await screen.findByRole("button", { name: "Revoke invitation" }))

expect(await screen.findByRole("alert")).toHaveTextContent(failure.message)
expect(screen.queryByRole("heading", { name: "Hub membership" })).not.toBeInTheDocument()
expect(screen.queryByRole("button", { name: "Revoke invitation" })).not.toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith("/api/session", { credentials: "same-origin" })
})
})
Loading
Loading