348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
import { afterEach, describe, expect, it } from "vitest";
|
|
import { type FastifyInstance } from "fastify";
|
|
import { buildApp } from "../src/app.js";
|
|
import { type HeimdallConfig } from "../src/config.js";
|
|
import { entitlementFacts, grantFacts, identityFacts } from "../src/facts.js";
|
|
import { type OAuthProviderRuntime } from "../src/oauth.js";
|
|
import { verifyJwt } from "../src/signing.js";
|
|
|
|
function createTestConfig(): HeimdallConfig {
|
|
return {
|
|
serviceName: "heimdall",
|
|
host: "127.0.0.1",
|
|
port: 4100,
|
|
publicBaseUrl: "https://heimdall.gamecult.org",
|
|
issuer: "https://heimdall.gamecult.org",
|
|
sessionTtlSeconds: 3600,
|
|
stateTtlSeconds: 600,
|
|
completionTtlSeconds: 300,
|
|
storage: {
|
|
backend: "memory",
|
|
applySchemaOnStartup: true,
|
|
},
|
|
apps: {
|
|
repixelizer: {
|
|
discordGuildId: "gamecult-guild",
|
|
discordAllowedRoleIds: ["role-repixelizer"],
|
|
},
|
|
},
|
|
providers: {
|
|
discord: { clientId: "discord-client", clientSecret: "discord-secret" },
|
|
patreon: { clientId: "patreon-client", clientSecret: "patreon-secret" },
|
|
github: { clientId: "github-client", clientSecret: "github-secret" },
|
|
twitch: { clientId: "twitch-client", clientSecret: "twitch-secret" },
|
|
youtube: { clientId: "youtube-client", clientSecret: "youtube-secret" },
|
|
},
|
|
};
|
|
}
|
|
|
|
function createMockDiscordRuntime(): OAuthProviderRuntime {
|
|
return {
|
|
async exchangeAuthorizationCode() {
|
|
return {
|
|
accessToken: "discord-access-token",
|
|
refreshToken: "discord-refresh-token",
|
|
tokenType: "Bearer",
|
|
scope: ["identify", "email", "guilds.members.read"],
|
|
expiresAt: "2026-04-26T13:00:00.000Z",
|
|
raw: { source: "test" },
|
|
};
|
|
},
|
|
async resolveIdentity() {
|
|
return {
|
|
provider: "discord",
|
|
providerUserId: "discord-user-123",
|
|
username: "meta",
|
|
displayName: "Meta",
|
|
primaryEmail: "meta@gamecult.org",
|
|
profile: { id: "discord-user-123", username: "meta" },
|
|
};
|
|
},
|
|
async evaluateEntitlements({ callback }) {
|
|
return {
|
|
facts: [entitlementFacts.appAccess],
|
|
snapshots: [
|
|
{
|
|
accountId: callback.accountId,
|
|
provider: "discord",
|
|
scope: "repixelizer:discord_role_access:gamecult-guild",
|
|
evaluatedAt: "2026-04-26T12:00:00.000Z",
|
|
isAllowed: true,
|
|
reasonCode: "matched_role",
|
|
reasonDetail: "Matched Repixelizer access role.",
|
|
rawSummaryJson: {
|
|
guildId: "gamecult-guild",
|
|
matchedRoles: ["role-repixelizer"],
|
|
},
|
|
},
|
|
],
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
const apps: Awaited<ReturnType<typeof buildApp>>[] = [];
|
|
|
|
function getPublicKey(app: FastifyInstance) {
|
|
return (
|
|
app as FastifyInstance & {
|
|
heimdallContext: {
|
|
keys: {
|
|
publicKey: Parameters<typeof verifyJwt>[1];
|
|
};
|
|
};
|
|
}
|
|
).heimdallContext.keys.publicKey;
|
|
}
|
|
|
|
async function startDiscordSignIn(app: FastifyInstance): Promise<string> {
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/oauth/discord/start",
|
|
payload: {
|
|
appSlug: "repixelizer",
|
|
mode: "sign_in",
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
return response.json().stateToken as string;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
while (apps.length) {
|
|
await apps.pop()?.close();
|
|
}
|
|
});
|
|
|
|
describe("Heimdall service", () => {
|
|
it("serves discovery and JWKS metadata", async () => {
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
apps.push(app);
|
|
|
|
const discoveryResponse = await app.inject({
|
|
method: "GET",
|
|
url: "/.well-known/heimdall-configuration",
|
|
});
|
|
expect(discoveryResponse.statusCode).toBe(200);
|
|
expect(discoveryResponse.json()).toEqual(
|
|
expect.objectContaining({
|
|
issuer: "https://heimdall.gamecult.org",
|
|
jwksUri: "https://heimdall.gamecult.org/.well-known/jwks.json",
|
|
})
|
|
);
|
|
|
|
const jwksResponse = await app.inject({
|
|
method: "GET",
|
|
url: "/.well-known/jwks.json",
|
|
});
|
|
expect(jwksResponse.statusCode).toBe(200);
|
|
expect(jwksResponse.json()).toEqual(
|
|
expect.objectContaining({
|
|
keys: [
|
|
expect.objectContaining({
|
|
alg: "EdDSA",
|
|
kid: expect.any(String),
|
|
use: "sig",
|
|
}),
|
|
],
|
|
})
|
|
);
|
|
});
|
|
|
|
it("builds signed OAuth start state for supported providers", async () => {
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
apps.push(app);
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/oauth/discord/start",
|
|
payload: {
|
|
appSlug: "repixelizer",
|
|
mode: "sign_in",
|
|
returnTo: "https://repixelizer.gamecult.org/app/",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
const payload = response.json();
|
|
expect(payload.authorizationUrl).toContain("discord.com/oauth2/authorize");
|
|
|
|
const verified = verifyJwt(payload.stateToken as string, getPublicKey(app));
|
|
expect(verified.valid).toBe(true);
|
|
if (verified.valid) {
|
|
expect(verified.payload).toEqual(
|
|
expect.objectContaining({
|
|
typ: "heimdall_oauth_state",
|
|
provider: "discord",
|
|
app_slug: "repixelizer",
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
it("completes a provider callback and issues a verified Repixelizer claim", async () => {
|
|
const app = await buildApp({
|
|
config: createTestConfig(),
|
|
oauthRuntimes: {
|
|
discord: createMockDiscordRuntime(),
|
|
},
|
|
});
|
|
apps.push(app);
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
});
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
const payload = response.json();
|
|
expect(payload.completion).toEqual(
|
|
expect.objectContaining({
|
|
code: expect.any(String),
|
|
redeemEndpoint: "https://heimdall.gamecult.org/v1/apps/repixelizer/auth-completions/redeem",
|
|
})
|
|
);
|
|
expect(payload.sharedCapabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
|
|
expect(payload.account).toEqual(
|
|
expect.objectContaining({
|
|
displayName: "Meta",
|
|
primaryEmail: "meta@gamecult.org",
|
|
})
|
|
);
|
|
expect(payload.entitlements.facts).toEqual(expect.arrayContaining([entitlementFacts.appAccess]));
|
|
|
|
const verified = verifyJwt(payload.accessToken as string, getPublicKey(app));
|
|
expect(verified.valid).toBe(true);
|
|
if (verified.valid) {
|
|
expect(verified.payload).toEqual(
|
|
expect.objectContaining({
|
|
typ: "heimdall_access",
|
|
aud: "repixelizer",
|
|
account_id: payload.account.id,
|
|
})
|
|
);
|
|
expect(verified.payload.facts).toEqual(
|
|
expect.arrayContaining([identityFacts.authenticated, entitlementFacts.appAccess])
|
|
);
|
|
expect(verified.payload.capabilities).toEqual(expect.arrayContaining(["app_access", "queue_submit"]));
|
|
}
|
|
});
|
|
|
|
it("redeems a completion code exactly once", async () => {
|
|
const app = await buildApp({
|
|
config: createTestConfig(),
|
|
oauthRuntimes: {
|
|
discord: createMockDiscordRuntime(),
|
|
},
|
|
});
|
|
apps.push(app);
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
const callbackResponse = await app.inject({
|
|
method: "GET",
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
});
|
|
const completionCode = callbackResponse.json().completion.code as string;
|
|
|
|
const redeemResponse = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/apps/repixelizer/auth-completions/redeem",
|
|
payload: {
|
|
completionCode,
|
|
},
|
|
});
|
|
|
|
expect(redeemResponse.statusCode).toBe(201);
|
|
expect(redeemResponse.json()).toEqual(
|
|
expect.objectContaining({
|
|
status: "success",
|
|
provider: "discord",
|
|
appSlug: "repixelizer",
|
|
})
|
|
);
|
|
|
|
const secondRedeemResponse = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/apps/repixelizer/auth-completions/redeem",
|
|
payload: {
|
|
completionCode,
|
|
},
|
|
});
|
|
|
|
expect(secondRedeemResponse.statusCode).toBe(410);
|
|
});
|
|
|
|
it("renders a browser handoff page that posts a completion code instead of a token", async () => {
|
|
const app = await buildApp({
|
|
config: createTestConfig(),
|
|
oauthRuntimes: {
|
|
discord: createMockDiscordRuntime(),
|
|
},
|
|
});
|
|
apps.push(app);
|
|
|
|
const stateToken = await startDiscordSignIn(app);
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: `/v1/oauth/discord/callback?code=test-code&state=${encodeURIComponent(stateToken)}`,
|
|
headers: {
|
|
accept: "text/html",
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.headers["content-type"]).toContain("text/html");
|
|
expect(response.body).toContain("window.opener.postMessage");
|
|
expect(response.body).toContain("heimdall_completion_code");
|
|
expect(response.body).not.toContain("heimdall_access_token");
|
|
});
|
|
|
|
it("issues direct claims from provider-agnostic entitlement facts", async () => {
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
apps.push(app);
|
|
|
|
const response = await app.inject({
|
|
method: "POST",
|
|
url: "/v1/apps/repixelizer/claims/issue",
|
|
payload: {
|
|
accountId: "acct_repixelizer_001",
|
|
displayName: "Meta",
|
|
facts: [entitlementFacts.appAccess, grantFacts.operator],
|
|
linkedIdentities: [
|
|
{
|
|
provider: "discord",
|
|
providerUserId: "123456789",
|
|
username: "meta",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(response.statusCode).toBe(201);
|
|
const payload = response.json();
|
|
expect(payload.sharedCapabilities).toEqual(
|
|
expect.arrayContaining(["app_access", "queue_submit", "admin_access"])
|
|
);
|
|
});
|
|
|
|
it("exposes StreamPixels hybrid capability seams without granting them blindly", async () => {
|
|
const app = await buildApp({ config: createTestConfig() });
|
|
apps.push(app);
|
|
|
|
const profileResponse = await app.inject({
|
|
method: "GET",
|
|
url: "/v1/apps/streampixels",
|
|
});
|
|
|
|
expect(profileResponse.statusCode).toBe(200);
|
|
expect(profileResponse.json()).toEqual(
|
|
expect.objectContaining({
|
|
slug: "streampixels",
|
|
managedConnectionProviders: ["twitch", "youtube"],
|
|
})
|
|
);
|
|
});
|
|
});
|