WebHooker/tests/admin-api.test.ts
RhenCloud 3f6f7f17b5
feat(groups): host-based forge sources with optional display name
- forgeSources entries are now { host, type, name? }: the repository URL's
  hostname is matched case-insensitively against host (github.com for GitHub,
  distinct hosts for multiple Gitea instances); the footer label is the
  optional name, falling back to the host
- GroupEditor renders one row per source: host input + type select + optional
  display name (grid layout); hostname validation mirrors the server
- fix: apiFetch sends Content-Type: application/json — h3's readBody only
  parses JSON bodies with that header, so every PUT/POST from the refactored
  console arrived as a raw string and failed with 'groups must be an array'
- hardening: readJsonBody (admin + actions) JSON-parses string bodies so curl
  and older clients without the content-type header still work
- regression test: groups PUT without content-type + forgeSources round-trip
- docs: groups.md/message-format.md (en/zh), AGENTS.md, config.example.yaml
2026-08-14 08:49:48 +08:00

284 lines
9.2 KiB
TypeScript

import { describe, it, expect } from "bun:test";
import {
adminGroupRename,
adminGroupRoutesGet,
adminApiGroupsPut,
adminApiMe,
} from "../server/lib/web/admin";
import { createAdminSession, adminCookie } from "../server/lib/web/session";
import { loadGroups } from "../server/lib/web/groups";
import { loadRoutes } from "../server/lib/config";
import { createInvite, listInvites } from "../server/lib/web/invites";
import { getTenantSecret, setTenantSecret } from "../server/lib/web/tenants";
import { makeEvent, responseStatus } from "./helpers";
import type { Env, Route } from "../server/lib/types";
function createMockKV(): KVNamespace {
const store = new Map<string, string>();
return {
get: async (key: string, type?: string) => {
const v = store.get(key);
if (v == null) return null;
if (type === "json") return JSON.parse(v);
return v;
},
put: async (key: string, value: string) => {
store.set(key, value);
},
delete: async (key: string) => {
store.delete(key);
},
list: async () => ({
keys: [...store.keys()].map((k) => ({ name: k })),
list_complete: true,
cacheStatus: null,
}),
} as unknown as KVNamespace;
}
function createMockDB(): D1Database {
return {
prepare: () => ({
bind: () => ({
run: async () => ({ success: true }),
all: async () => ({ results: [] }),
}),
}),
} as unknown as D1Database;
}
function createEnv(overrides: Partial<Env> = {}): Env {
return {
GITHUB_WEBHOOK_SECRET: "secret",
KV: createMockKV(),
DB: createMockDB(),
...overrides,
};
}
describe("admin handlers (h3)", () => {
it("renames an owned group and follows routes, secret and invites", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{
id: "old-team",
name: "Old Team",
adminIds: [],
members: [{ login: "alice", role: "owner" }],
},
]),
);
await kv.put(
"config:routes",
JSON.stringify([
{
id: "r1",
name: "R1",
enabled: true,
groupId: "old-team",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
] as Route[]),
);
await setTenantSecret(kv, "old-team");
const originalSecret = (await getTenantSecret(kv, "old-team"))!;
await createInvite(kv, {
groupId: "old-team",
role: "viewer",
expiresAt: Date.now() + 86400_000,
createdBy: "alice",
});
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = makeEvent("/api/groups/old-team/rename", {
method: "PUT",
headers: { cookie: adminCookie(sessionId), "content-type": "application/json" },
body: JSON.stringify({ newId: "new-team" }),
env,
});
const result = (await adminGroupRename(event, "old-team")) as { ok?: boolean };
expect(responseStatus(event)).toBe(200);
expect(result.ok).toBe(true);
const groups = await loadGroups(kv);
expect(groups).toHaveLength(1);
expect(groups[0]!.id).toBe("new-team");
expect(groups[0]!.name).toBe("Old Team");
const routes = await loadRoutes(kv);
expect(routes[0]!.groupId).toBe("new-team");
expect(await getTenantSecret(kv, "new-team")).toBe(originalSecret);
expect(await getTenantSecret(kv, "old-team")).toBeNull();
const invites = await listInvites(kv, "new-team");
expect(invites).toHaveLength(1);
expect(invites[0]!.groupId).toBe("new-team");
expect(await listInvites(kv, "old-team")).toHaveLength(0);
});
it("accepts a groups PUT without the application/json content-type", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "team", name: "Team", adminIds: [], members: [{ login: "alice", role: "owner" }] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
// The console's fetch helper always declares application/json, but curl
// and older clients may omit it — h3's readBody then returns the raw
// string, which used to fail validation with "groups must be an array".
const event = makeEvent("/api/groups", {
method: "PUT",
headers: { cookie: adminCookie(sessionId) },
body: JSON.stringify({
groups: [
{
id: "team",
name: "Team",
adminIds: [],
members: [{ login: "alice", role: "owner" }],
forgeSources: [{ host: "git.example.com", type: "gitea", name: "内网 Gitea" }],
},
],
}),
env,
});
const result = (await adminApiGroupsPut(event)) as { ok?: boolean };
expect(responseStatus(event)).toBe(200);
expect(result.ok).toBe(true);
const groups = await loadGroups(kv);
expect(groups).toHaveLength(1);
expect(groups[0]!.forgeSources).toEqual([
{ host: "git.example.com", type: "gitea", name: "内网 Gitea" },
]);
});
it("forbids non-owner members from renaming", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "team", name: "Team", adminIds: [], members: [{ login: "bob", role: "admin" }] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "2002", "bob");
const event = makeEvent("/api/groups/team/rename", {
method: "PUT",
headers: { cookie: adminCookie(sessionId), "content-type": "application/json" },
body: JSON.stringify({ newId: "new-team" }),
env,
});
await adminGroupRename(event, "team");
expect(responseStatus(event)).toBe(403);
});
it("rejects invalid, duplicate or unchanged ids", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "team", name: "Team", adminIds: [], members: [{ login: "alice", role: "owner" }] },
{ id: "other", name: "Other", adminIds: [] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const make = (newId: string) => {
const event = makeEvent("/api/groups/team/rename", {
method: "PUT",
headers: { cookie: adminCookie(sessionId), "content-type": "application/json" },
body: JSON.stringify({ newId }),
env,
});
return adminGroupRename(event, "team").then(() => responseStatus(event));
};
expect(await make("Bad ID!")).toBe(400);
expect(await make("team")).toBe(400);
expect(await make("other")).toBe(400);
});
it("returns 401 without a session", async () => {
const env = createEnv();
const event = makeEvent("/api/groups/team/rename", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ newId: "new-team" }),
env,
});
await expect(adminGroupRename(event, "team")).rejects.toMatchObject({ statusCode: 401 });
});
it("returns 404 for an unknown group", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "mine", name: "Mine", adminIds: [], members: [{ login: "alice", role: "owner" }] },
]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const event = makeEvent("/api/groups/nope/rename", {
method: "PUT",
headers: { cookie: adminCookie(sessionId), "content-type": "application/json" },
body: JSON.stringify({ newId: "new-team" }),
env,
});
const result = (await adminGroupRename(event, "nope")) as { error?: string };
expect(responseStatus(event)).toBe(404);
expect(result.error).toBe("Group not found");
});
it("me and group routes scoping work", async () => {
const kv = createMockKV();
await kv.put(
"config:groups",
JSON.stringify([
{ id: "mine", name: "Mine", adminIds: [], members: [{ login: "alice", role: "owner" }] },
{ id: "theirs", name: "Theirs", adminIds: [], members: [{ login: "bob", role: "owner" }] },
]),
);
await kv.put(
"config:routes",
JSON.stringify([
{
id: "mine-r",
name: "Mine R",
enabled: true,
groupId: "mine",
filters: [{ type: "event", match: "push" }],
targets: [{ channelId: "111" }],
},
] as Route[]),
);
const env = createEnv({ KV: kv });
const sessionId = await createAdminSession(kv, "1001", "alice");
const meEvent = makeEvent("/api/me", { headers: { cookie: adminCookie(sessionId) }, env });
const me = (await adminApiMe(meEvent)) as { groups?: Array<{ id: string }>; isSuper?: boolean };
expect(me.groups?.map((g) => g.id)).toEqual(["mine"]);
expect(me.isSuper).toBe(false);
const routesEvent = makeEvent("/api/groups/mine/routes", {
headers: { cookie: adminCookie(sessionId) },
env,
});
const groupRoutes = (await adminGroupRoutesGet(routesEvent, "mine")) as {
routes?: Array<{ id: string }>;
};
expect(groupRoutes.routes?.map((r) => r.id)).toEqual(["mine-r"]);
});
});