The panel said "you are not in a voice channel" while the client showed the user sitting in the call, which also disabled the only button that could summon the bot. The SDK learns voice participants from gateway events alone — no endpoint to ask — so a bot that restarted, or missed a VoiceChannelJoin, sees every channel as empty forever, and that looks exactly like nobody listening. The rule now refuses only on proven absence: when the bot can see people in voice somewhere in the server and the requester is not among them. With no presence data at all it accepts an explicitly chosen channel and logs that it is trusting the request, and the panel falls back to the channel picker instead of a dead button. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
4.2 KiB
JavaScript
107 lines
4.2 KiB
JavaScript
// Smoke test: boots the API layer with a stubbed Stoat context and exercises
|
|
// the auth flow + a couple of routes. No real Stoat instance involved.
|
|
process.env.STOAT_API_URL = "http://127.0.0.1:9/api";
|
|
process.env.STOAT_BOT_TOKEN = "test-token";
|
|
process.env.PUBLIC_URL = "http://127.0.0.1:3199";
|
|
process.env.JWT_SECRET = "0123456789abcdef0123456789abcdef";
|
|
process.env.PORT = "3199";
|
|
process.env.LOG_LEVEL = "warn";
|
|
process.env.NODE_ENV = "production";
|
|
|
|
const { startApiServer } = await import("../dist/api/server.js");
|
|
const { MusicManager } = await import("../dist/core/manager.js");
|
|
const { signPanelLink } = await import("../dist/auth/tokens.js");
|
|
|
|
const context = {
|
|
getServerName: () => "Test Server",
|
|
getVoiceChannel: (id) => ({ id, name: "General" }),
|
|
listVoiceChannels: () => [{ id: "vc1", name: "General" }],
|
|
findUserVoiceChannel: () => ({ id: "vc1", name: "General" }),
|
|
hasVoicePresence: () => true,
|
|
listServersForUser: async () => [{ id: "srv1", name: "Test Server", iconUrl: null }],
|
|
isMember: async (s, u) => s === "srv1" && u === "user1",
|
|
canControl: async () => true,
|
|
sendMessage: async () => {},
|
|
};
|
|
|
|
const manager = new MusicManager();
|
|
manager.attachStoat(context);
|
|
const app = await startApiServer({ manager, context });
|
|
|
|
const base = "http://127.0.0.1:3199";
|
|
const results = [];
|
|
const check = (name, ok, detail = "") => results.push({ name, ok, detail });
|
|
|
|
// 1. Unauthenticated access is rejected.
|
|
let res = await fetch(`${base}/api/me`);
|
|
check("GET /api/me without cookie → 401", res.status === 401, `got ${res.status}`);
|
|
|
|
// 2. One-time chat link exchanges for a session cookie.
|
|
const link = await signPanelLink("user1", "tester", "srv1");
|
|
res = await fetch(`${base}/api/auth/link`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ token: link }),
|
|
});
|
|
const linkBody = await res.json();
|
|
const cookie = (res.headers.getSetCookie?.() ?? [])[0]?.split(";")[0] ?? "";
|
|
check("POST /api/auth/link → session", res.ok && linkBody.serverId === "srv1" && cookie.length > 0, JSON.stringify(linkBody));
|
|
|
|
// 3. Authenticated profile lists the servers we share with the bot.
|
|
res = await fetch(`${base}/api/me`, { headers: { cookie } });
|
|
const me = await res.json();
|
|
check("GET /api/me with cookie", res.ok && me.servers?.[0]?.id === "srv1", JSON.stringify(me));
|
|
|
|
// 4. Player state for a server the user belongs to.
|
|
res = await fetch(`${base}/api/servers/srv1/state`, { headers: { cookie } });
|
|
const state = await res.json();
|
|
check(
|
|
"GET state",
|
|
res.ok && state.state.status === "idle" && state.voiceChannels.length === 1,
|
|
JSON.stringify(state).slice(0, 160),
|
|
);
|
|
|
|
// 5. A server the user is not a member of stays hidden.
|
|
res = await fetch(`${base}/api/servers/other/state`, { headers: { cookie } });
|
|
check("GET state for foreign server → 403", res.status === 403, `got ${res.status}`);
|
|
|
|
// 6. An expired/garbage link is refused.
|
|
res = await fetch(`${base}/api/auth/link`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ token: "not-a-jwt" }),
|
|
});
|
|
check("POST /api/auth/link with junk → 400", res.status === 400, `got ${res.status}`);
|
|
|
|
// 7. Actions on a server with no player report a clear error, not a crash.
|
|
res = await fetch(`${base}/api/servers/srv1/actions/pause`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", cookie },
|
|
body: "{}",
|
|
});
|
|
const pause = await res.json();
|
|
check("POST pause without player → 400", res.status === 400 && typeof pause.error === "string", JSON.stringify(pause));
|
|
|
|
// 8. Unknown action.
|
|
res = await fetch(`${base}/api/servers/srv1/actions/explode`, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", cookie },
|
|
body: "{}",
|
|
});
|
|
check("POST unknown action → 404", res.status === 404, `got ${res.status}`);
|
|
|
|
// 9. Static panel bundle is served.
|
|
res = await fetch(`${base}/`);
|
|
const html = await res.text();
|
|
check("GET / serves panel", res.ok && html.includes("<div id=\"root\">"), `status ${res.status}`);
|
|
|
|
await app.close();
|
|
|
|
let failed = 0;
|
|
for (const r of results) {
|
|
if (!r.ok) failed += 1;
|
|
console.log(`${r.ok ? "PASS" : "FAIL"} ${r.name}${r.ok ? "" : ` — ${r.detail}`}`);
|
|
}
|
|
console.log(failed === 0 ? "\nAll checks passed" : `\n${failed} check(s) failed`);
|
|
process.exit(failed === 0 ? 0 : 1);
|