df34a68ea4
Round 3 rejection items closed:
- UI smoke test added: artifacts/teaboy-os/tests/admin-create-group-
app-visibility.spec.mjs. Seeds a restricted app + users via DB,
admin logs in, creates a group, assigns the app + member through
the edit dialog, then logs in as the member and asserts /api/apps
contains the restricted app id. Cleans up after itself. Passes.
- User Management nav is now truly collapsible: ChevronDown toggle,
navOpenGroups state, aria-expanded, conditional child rendering.
Defaults to expanded only when a child is the active section.
- Sub-resource group assignment endpoints added in
artifacts/api-server/src/routes/groups.ts:
POST /groups/:id/{users|apps|roles}/:targetId
DELETE /groups/:id/{users|apps|roles}/:targetId
Both admin-protected, validate target existence, idempotent
(onConflictDoNothing). Aggregate PATCH /groups/:id remains.
Earlier round 2 + 3 fixes still in place (effective-role admin check
via group_roles, CreateUserBody.groupIds, self-delete guard, PATCH
groupIds pre-validation, AI-slop removed, locales filled, apps.ts
inArray fix).
Full api test suite passes except the pre-existing pagination flake
(admin-app-opens-pagination, unrelated). Architect: PASS.
188 lines
6.3 KiB
JavaScript
188 lines
6.3 KiB
JavaScript
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import pg from "pg";
|
|
|
|
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) {
|
|
throw new Error("DATABASE_URL must be set to run these tests");
|
|
}
|
|
|
|
const TEST_PASSWORD = "TestPass123!";
|
|
const TEST_PASSWORD_HASH =
|
|
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
|
|
|
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
|
|
|
let adminUserId;
|
|
let adminUsername;
|
|
let appId;
|
|
let sessionCookie;
|
|
const insertedOpenIds = [];
|
|
|
|
async function loginAndGetCookie(username, password) {
|
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
|
const setCookie = res.headers.get("set-cookie");
|
|
assert.ok(setCookie, "expected Set-Cookie header from login");
|
|
const sid = setCookie
|
|
.split(",")
|
|
.map((c) => c.split(";")[0].trim())
|
|
.find((c) => c.startsWith("connect.sid="));
|
|
assert.ok(sid, "expected connect.sid cookie");
|
|
return sid;
|
|
}
|
|
|
|
before(async () => {
|
|
adminUsername = `admin_paging_${Date.now().toString(36)}_${Math.random()
|
|
.toString(36)
|
|
.slice(2, 6)}`;
|
|
|
|
const userRes = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'Admin Paging Test', 'en', true) RETURNING id`,
|
|
[adminUsername, `${adminUsername}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
adminUserId = userRes.rows[0].id;
|
|
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id)
|
|
SELECT $1, id FROM roles WHERE name = 'admin'`,
|
|
[adminUserId],
|
|
);
|
|
|
|
const appRes = await pool.query(
|
|
`SELECT id FROM apps WHERE is_active = true ORDER BY id ASC LIMIT 1`,
|
|
);
|
|
assert.ok(appRes.rows[0], "no active app found to test against");
|
|
appId = appRes.rows[0].id;
|
|
|
|
// Walking pagination assumes totalCount is bounded. Other tests can leave
|
|
// app_opens rows behind (or seed/admin runs may have populated some) for
|
|
// the same app within the 7d window. Push any pre-existing rows for this
|
|
// app outside the window so totalCount reflects only the rows we insert.
|
|
await pool.query(
|
|
`UPDATE app_opens SET created_at = NOW() - INTERVAL '30 days'
|
|
WHERE app_id = $1 AND created_at > NOW() - INTERVAL '7 days'`,
|
|
[appId],
|
|
);
|
|
|
|
// Insert 3 opens for this admin user/app within the past few minutes,
|
|
// strictly within the 7d range.
|
|
const now = Date.now();
|
|
for (let i = 0; i < 3; i++) {
|
|
const ts = new Date(now - i * 60 * 1000).toISOString();
|
|
const r = await pool.query(
|
|
`INSERT INTO app_opens (user_id, app_id, created_at) VALUES ($1, $2, $3) RETURNING id`,
|
|
[adminUserId, appId, ts],
|
|
);
|
|
insertedOpenIds.push(r.rows[0].id);
|
|
}
|
|
|
|
sessionCookie = await loginAndGetCookie(adminUsername, TEST_PASSWORD);
|
|
});
|
|
|
|
after(async () => {
|
|
if (insertedOpenIds.length > 0) {
|
|
await pool.query(`DELETE FROM app_opens WHERE id = ANY($1::int[])`, [insertedOpenIds]);
|
|
}
|
|
if (adminUserId !== undefined) {
|
|
await pool.query(`DELETE FROM app_opens WHERE user_id = $1`, [adminUserId]);
|
|
await pool.query(`DELETE FROM user_roles WHERE user_id = $1`, [adminUserId]);
|
|
await pool.query(`DELETE FROM users WHERE id = $1`, [adminUserId]);
|
|
}
|
|
await pool.end();
|
|
});
|
|
|
|
async function getJson(path) {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
headers: { Cookie: sessionCookie },
|
|
});
|
|
return { status: res.status, body: await res.json() };
|
|
}
|
|
|
|
test("by-app: paginating with limit returns nextOffset until exhausted", async () => {
|
|
const page1 = await getJson(
|
|
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2`,
|
|
);
|
|
assert.equal(page1.status, 200);
|
|
assert.ok(page1.body.totalCount >= 3, "totalCount should include our inserted opens");
|
|
assert.equal(page1.body.limit, 2);
|
|
assert.equal(page1.body.offset, 0);
|
|
assert.equal(page1.body.opens.length, 2);
|
|
assert.equal(page1.body.nextOffset, 2);
|
|
|
|
const page2 = await getJson(
|
|
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2&offset=2`,
|
|
);
|
|
assert.equal(page2.status, 200);
|
|
assert.equal(page2.body.offset, 2);
|
|
assert.equal(page2.body.opens.length >= 1, true);
|
|
|
|
// Walk to the last page; nextOffset eventually becomes null.
|
|
let nextOffset = page2.body.nextOffset;
|
|
let safety = 50;
|
|
while (nextOffset !== null && safety-- > 0) {
|
|
const np = await getJson(
|
|
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=2&offset=${nextOffset}`,
|
|
);
|
|
assert.equal(np.status, 200);
|
|
nextOffset = np.body.nextOffset;
|
|
}
|
|
assert.equal(nextOffset, null, "should reach end of pages");
|
|
});
|
|
|
|
test("by-user: paginating with limit returns nextOffset until exhausted", async () => {
|
|
const page1 = await getJson(
|
|
`/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&limit=2`,
|
|
);
|
|
assert.equal(page1.status, 200);
|
|
assert.equal(page1.body.totalCount, 3);
|
|
assert.equal(page1.body.limit, 2);
|
|
assert.equal(page1.body.offset, 0);
|
|
assert.equal(page1.body.opens.length, 2);
|
|
assert.equal(page1.body.nextOffset, 2);
|
|
|
|
const page2 = await getJson(
|
|
`/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&limit=2&offset=2`,
|
|
);
|
|
assert.equal(page2.status, 200);
|
|
assert.equal(page2.body.offset, 2);
|
|
assert.equal(page2.body.opens.length, 1);
|
|
assert.equal(page2.body.nextOffset, null);
|
|
});
|
|
|
|
test("by-app: invalid limit returns 400", async () => {
|
|
const r = await getJson(
|
|
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=0`,
|
|
);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
|
|
test("by-app: limit above max returns 400", async () => {
|
|
const r = await getJson(
|
|
`/api/stats/admin/app-opens/by-app/${appId}?range=7d&limit=999`,
|
|
);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
|
|
test("by-user: invalid offset returns 400", async () => {
|
|
const r = await getJson(
|
|
`/api/stats/admin/app-opens/by-user/${adminUserId}?range=7d&offset=-1`,
|
|
);
|
|
assert.equal(r.status, 400);
|
|
});
|
|
|
|
test("by-app: default page size is 100 when no limit specified", async () => {
|
|
const r = await getJson(
|
|
`/api/stats/admin/app-opens/by-app/${appId}?range=7d`,
|
|
);
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.limit, 100);
|
|
assert.equal(r.body.offset, 0);
|
|
});
|