Files
attack-defense-platform/panel/test_bulk_delete_ui.js
T
root 50cb782ded fix(portal): per-challenge SSH user in web terminal + credential API
The web SSH terminal and the credential API reported `ctfuser` for all 16
challenges, but only the 6 native GEMASTIK XVIII images provision ctfuser.
Every imported XVI/XVII image does `RUN echo root:${PASSWORD} | chpasswd`,
so 10 of 16 participant logins were refused with "Permission denied".

Root causes (all the same class of bug - login hardcoded in the wrong layer):
- main.py websocket ssh handler read st["ssh_user"], a single team-wide value
  defaulting to ctfuser, instead of the per-challenge registry field
- /api/credential proxied the global receiver on :18080, which only knows the
  6 native challenges, so the other 10 returned "Invalid challenge"
- team.html hardcoded the challenge picker to those same 6 challenges, making
  the other 10 unreachable from the terminal entirely
- index.html rendered `<b>ctfuser</b>` and a stale hardcoded SSH port table

Fixes:
- orch.challenge_credential()/all_teams() read the TEAM's state.json, which
  holds the same per-challenge password the panel chpasswds
- gen_receiver_services.py injects SSH_USER_<port> from the registry so the
  receiver's /credential endpoint agrees with the panel
- receiver Challenge.credentials() honours SSH_USER_<port> (ctfuser fallback)
- new /api/team/{idx}/own-challenges feeds the picker; targets now carry
  challenge + ssh_user
- UI takes user and port from the server instead of hardcoding them

Verified: 32/32 credential payloads correct across teams 1-2, and 32/32 real
paramiko SSH logins succeed with whoami confirming the expected account.

Also adds bulk team delete: POST /api/teams/bulk-delete runs one background
thread and is polled via GET /api/teams/bulk-delete/{job_id}, plus per-team
checkboxes with select-all/clear in the UI. Deletion must stay sequential
because delete_team() regenerates shared artifacts at the end.
2026-09-26 16:37:40 +08:00

104 lines
4.7 KiB
JavaScript

// Functional DOM test of the bulk-delete checkbox UI.
// Loads the REAL index.html into jsdom, stubs fetch, and drives the actual
// render + selection functions. Proves: checkboxes render, select-all works,
// the bar appears only when a selection exists, and the payload is right.
const fs = require('fs');
const path = require('path');
const { JSDOM } = require(path.join('/tmp/uitest/node_modules/jsdom'));
const html = fs.readFileSync('/opt/gemastik18-final/panel/static/index.html', 'utf8');
// Fake 4 teams, mirroring the real API shape.
const FAKE_TEAMS = {
teams: [
{ index: 1, label: 'Max', status: 'running', ports: {}, domain: 'max.attackdefense.imrnes.team' },
{ index: 2, label: 'Aryma', status: 'running', ports: {}, domain: 'aryma.attackdefense.imrnes.team' },
{ index: 3, label: 'Ken', status: 'running', ports: {}, domain: 'ken.attackdefense.imrnes.team' },
],
};
const calls = [];
const dom = new JSDOM(html, { runScripts: 'dangerously', url: 'https://attackdefense.imrnes.team/' });
dom.window.fetch = async (url, opts) => {
calls.push({ url: String(url), method: (opts && opts.method) || 'GET', body: opts && opts.body });
const u = String(url);
if (u.endsWith('/api/teams') && !(opts && opts.method)) {
return { ok: true, json: async () => FAKE_TEAMS };
}
if (u.includes('/api/teams/bulk-delete/') && !(opts && opts.method)) {
return { ok: true, json: async () => ({ state: 'done', done: [{ team: 2 }], errors: {} }) };
}
return { ok: true, json: async () => ({ ok: true }) };
};
dom.window.confirm = () => true;
const w = dom.window, d = w.document;
// jsdom keeps top-level `let` out of window, so read it through eval in the page.
const sel_set = () => w.eval('TEAM_SELECTED');
const call = (fn) => w.eval(`${fn}()`);
let pass = 0, fail = 0;
const ok = (name, cond, extra) => {
if (cond) { pass++; console.log(' PASS ' + name); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' -> ' + extra : '')); }
};
(async () => {
// Drive the real page init: it calls loadTeams on DOMContentLoaded.
await w.loadTeams();
const boxes = () => Array.from(d.querySelectorAll('input[id^="teamSel"]'));
ok('one checkbox per team rendered', boxes().length === 3, 'got ' + boxes().length);
ok('checkbox id encodes the team index', boxes()[0] && boxes()[0].id === 'teamSel1',
boxes()[0] && boxes()[0].id);
ok('checkbox onchange passes the index', boxes()[0] && /toggleTeamSelect\(1,/.test(boxes()[0].getAttribute('onchange') || ''),
boxes()[0] && boxes()[0].getAttribute('onchange'));
// Bar hidden with no selection.
const bar = d.getElementById('bulkDelBar');
ok('bar exists', !!bar);
ok('bar hidden when nothing selected', bar && bar.style.display === 'none',
bar && bar.style.display);
// Tick team 2 -> bar appears, count updates.
boxes()[1].checked = true;
boxes()[1].dispatchEvent(new w.Event('change', { bubbles: true }));
ok('bar shows after first tick', bar.style.display !== 'none', bar.style.display);
ok('TEAM_SELECTED holds team2', sel_set().has(2) && sel_set().size === 1,
'size=' + sel_set().size);
ok('bar mentions 1 team', /1\b/.test(bar.textContent), JSON.stringify(bar.textContent.trim().slice(0, 60)));
// Select all -> every team ticked. The button drives selectAllTeams(true).
const allBtn = Array.from(d.querySelectorAll('button'))
.find(b => /selectAllTeams\(true\)/.test(b.getAttribute('onclick') || ''));
ok('select-all button exists', !!allBtn);
allBtn.click();
ok('select-all ticks every box', boxes().every(b => b.checked));
ok('TEAM_SELECTED has all 3', sel_set().size === 3, 'size=' + sel_set().size);
ok('bar shows 3 teams', /3/.test(bar.textContent));
// Bulk delete issues ONE POST with the selected indices.
calls.length = 0;
const goBtn = Array.from(d.querySelectorAll('#bulkDelBar button'))
.find(b => /bulkDeleteTeams\(\)/.test(b.getAttribute('onclick') || ''));
ok('bulk delete button appears in the bar', !!goBtn);
goBtn.click();
await new Promise(r => setTimeout(r, 400));
const post = calls.find(c => c.url.includes('bulk-delete') && c.method === 'POST');
ok('bulk delete POSTs once', !!post, JSON.stringify(calls.map(c => c.method + ' ' + c.url)));
if (post) {
const payload = JSON.parse(post.body);
ok('payload carries all 3 indices', JSON.stringify(payload.indices) === '[1,2,3]',
JSON.stringify(payload));
}
// Clear selection hides the bar again.
call('clearTeamSelection');
ok('clear empties selection', sel_set().size === 0);
ok('bar hidden after clear', bar.style.display === 'none', bar.style.display);
ok('checkboxes cleared too', boxes().every(b => !b.checked));
console.log(`\n ${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);
})();