Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions controller/acp_controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -1928,8 +1928,10 @@ public function update_category_data(&$category_data, $copy_perm_from_id): array
$row = $this->db->sql_fetchrow($result);
$this->db->sql_freeresult($result);

$category_data_sql['left_id'] = $row['right_id'] + 1;
$category_data_sql['right_id'] = $row['right_id'] + 2;
// MAX() of an empty table is NULL, which MSSQL's driver
// returns as an empty string
$category_data_sql['left_id'] = (int) $row['right_id'] + 1;
$category_data_sql['right_id'] = (int) $row['right_id'] + 2;
}

$sql = 'INSERT INTO ' . $this->categories_table . ' ' . $this->db->sql_build_array('INSERT', $category_data_sql);
Expand Down Expand Up @@ -2094,7 +2096,8 @@ protected function move_category($from_id, $to_id): array
$row = $this->db->sql_fetchrow($result);
$this->db->sql_freeresult($result);

$diff = '+ ' . ($row['right_id'] - $from_data['left_id'] + 1);
// (int): MAX() over no rows is NULL, an empty string from MSSQL's driver
$diff = '+ ' . ((int) $row['right_id'] - (int) $from_data['left_id'] + 1);
}

$sql = 'UPDATE ' . $this->categories_table . '
Expand Down
3 changes: 2 additions & 1 deletion controller/article.php
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,8 @@ public function show(): \Symfony\Component\HttpFoundation\Response
if ($comment_topic_id)
{
$count = -1;
$sql = 'SELECT DISTINCT p.poster_id, p.post_time, p.post_subject, p.post_text, p.bbcode_uid, p.bbcode_bitfield, u.user_id, u.username
// No DISTINCT: one user per post, and MSSQL cannot compare post_text
$sql = 'SELECT p.poster_id, p.post_time, p.post_subject, p.post_text, p.bbcode_uid, p.bbcode_bitfield, u.user_id, u.username
FROM ' . POSTS_TABLE . ' p, ' . USERS_TABLE . ' u
WHERE p.topic_id = ' . (int) $comment_topic_id . '
AND (p.poster_id = u.user_id)
Expand Down
4 changes: 3 additions & 1 deletion controller/posting.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ public function post_article(): \Symfony\Component\HttpFoundation\Response
$delete_allowed = $edit_allowed = $article_author_id = false;
if ($mode === 'edit' || $mode === 'delete')
{
$sql = 'SELECT DISTINCT a.*, c.category_name, c.category_id
// No DISTINCT: one category per article, and MSSQL cannot compare
// the text columns DISTINCT would have to
$sql = 'SELECT a.*, c.category_name, c.category_id
FROM ' . $this->articles_table . ' a, ' . $this->categories_table . ' c
WHERE article_id = ' . $art_id . '
AND (c.category_id = a.article_category_id)';
Expand Down
4 changes: 3 additions & 1 deletion controller/search.php
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,9 @@ public function main(): \Symfony\Component\HttpFoundation\Response
{
$sql_sort = $sort_by_sql[$sort_key] . (($sort_dir == 'a') ? ' ASC' : ' DESC');

$sql = 'SELECT DISTINCT a.*, u.user_id, u.username, u.user_colour
// No DISTINCT: one user per article, and MSSQL cannot compare the
// text columns DISTINCT would have to
$sql = 'SELECT a.*, u.user_id, u.username, u.user_colour
FROM ' . $this->articles_table . ' a
LEFT JOIN ' . USERS_TABLE . ' u ON (a.author_id = u.user_id AND a.author_type = 0)
WHERE ' . $this->db->sql_in_set('article_id', $id_ary) . '
Expand Down
59 changes: 59 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -1507,3 +1507,62 @@ also showed on the hierarchical index, and nothing showed once it was switched o

Not covered: a board that never ran kinerity in a browser (the migration's off-by-default path is
covered by PHPUnit), and other languages (the default text is English, as kinerity's was).

## Enabling on MSSQL - 11/12/2026

The extension could not be enabled on a fresh MSSQL board, on phpBB 3.3 or 4.0:

- `version_1_0_0` inserts the seven `kb_options` rows with fixed ids. MSSQL refuses an explicit
value for an identity column unless `IDENTITY_INSERT` is on, so the insert now switches it on
and off around the rows on MSSQL, the way phpBB's own installer fills its ACL options.
- `version_1_0_0` and `version_1_0_5` put numbers into `kb_config.config_value`, a text column
(`ntext` on MSSQL), which MSSQL refuses ("Operand type clash: int is incompatible with ntext").
The values are now strings.
- `version_1_0_0` ran its extension-group query twice and never freed the first result, and
`version_2_0_0` never freed its `kb_config` result. MSSQL then refused the next transaction
("New transaction is not allowed because there are other threads running in the session").

Once enabled, two more things failed on MSSQL:

- Adding the first category added 1 to `MAX(right_id)` of an empty table. phpBB's MSSQL driver
returns that NULL as an empty string, and `'' + 1` is a TypeError in PHP 8. Moving a category
to the root had the same sum. Both now cast to an integer.
- Search, the article edit form and article comments used `SELECT DISTINCT` over rows with text
columns, which MSSQL cannot compare. Each join matches one row (one author, one category, one
poster), so `DISTINCT` is dropped.

`kb_mssql_migration_test.php` (7 tests) checks the `IDENTITY_INSERT` statements on MSSQL and
their absence elsewhere, the string config values, and that no result is left open.
`kb_acp_first_category_test.php` checks the first root category with both NULL and an empty
string from `MAX()`. Both files fail on the code before this change.

`tests/browser/kb_page_sweep.js` is a read-only check for any seeded board: it logs in as the
seed-forum administrator, opens the index, a category, an article, search, the queue, Manage
Your Articles, recent changes and its RSS feed, the add and edit forms, and every Knowledge Base
ACP module, and fails on a server error, a PHP error or phpBB's SQL error page. It loads only the
HTML (styles, scripts and images are skipped), because PHP's built-in server serves them slowly.

```bash
KB_TEST_URL=http://localhost:8171 node /path/to/playwright-skill/run.js tests/browser/kb_page_sweep.js
```

Claude built a fresh seed-forum board with the full Knowledge Base fixtures for each of phpBB
3.3.19 and 4.0.0-a3-dev on SQLite, MySQL 8.4, MariaDB, PostgreSQL 16 and MSSQL (SQL Server 2022),
ten boards. On each, the sweep loaded 24 of 24 pages cleanly, and disabling, purging and enabling
again through phpBB's CLI left no Knowledge Base migrations, tables, settings or ACP modules after
the purge and all 40 migrations done after the enable. MSSQL boards run PHP 8.4 in seed-forum's
driver image and are served with `bin/serve-board.py`; on a 3.3 board served that way, turn
`enable_mod_rewrite` off first, since PHP's built-in server rewrites no clean URLs.

The fresh-PostgreSQL fix (#12) gives `version_2_0_30`'s new option an explicit id, which MSSQL also
only takes with `IDENTITY_INSERT` on, so `version_2_0_30::add_permission()` now switches it on and off
around that insert on MSSQL as well; `kb_mssql_migration_test.php` covers it. The PostgreSQL boards,
and an MSSQL 3.3 board run a second time, used this change merged with that fix and the wrapper,
before #12 reached `main`.

Building these boards needed three seed-forum fixes: a cache purge after enabling (seeds could not
find the extension's services), GD in the MSSQL PHP image (the image fixtures), and several
workers for `serve-board.py` (a browser's unused early connection held the single one).

The PHPUnit suite passes on phpBB 3.3.x and master with the MySQL and PostgreSQL fixtures above:
511 tests, none skipped.
53 changes: 34 additions & 19 deletions migrations/version_1_0_0.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,6 @@ public function set_default_config()
FROM ' . $this->table_prefix . 'extensions e, ' . $this->table_prefix . 'extension_groups g
WHERE e.group_id = g.group_id
AND g.allow_group = 1';
$this->db->sql_query($sql);
$result = $this->db->sql_query($sql);
while ($row = $this->db->sql_fetchrow($result))
{
Expand Down Expand Up @@ -388,18 +387,23 @@ public function set_default_config()
$result = $this->db->sql_query($sql);
$row = $this->db->sql_fetchrow($result);
$this->db->sql_freeresult($result);
// Every value is a string: config_value is a text column, and MSSQL
// refuses to put a number into one ("Operand type clash: int is
// incompatible with ntext")
$rows = [
'allow_attachments' => '0',
'max_filesize' => (string) $max_filesize,
'extensions' => $extensions,
'thumbnail' => '0',
'max_attachments' => '1',
];
if (empty($row))
{
$sql = 'INSERT INTO ' . $this->table_prefix . 'kb_config' . ' (config_name, config_value) VALUES
(\'allow_attachments\', 0),
(\'anounce\', 0),
(\'articles_per_page\', 10),
(\'forum_id\', 0),
(\'max_filesize\', ' . $max_filesize . '),
(\'extensions\', \'' . $extensions . '\'),
(\'thumbnail\', 0),
(\'max_attachments\', 1)
';
$rows += [
'anounce' => '0',
'articles_per_page' => '10',
'forum_id' => '0',
];
}
else
{
Expand All @@ -413,15 +417,14 @@ public function set_default_config()
$sql = 'ALTER TABLE ' . $this->table_prefix . 'kb_config CHANGE config_value config_value TEXT CHARACTER SET utf8 COLLATE utf8_bin';
$this->db->sql_query($sql);
}
$sql = 'INSERT INTO ' . $this->table_prefix . 'kb_config' . ' (config_name, config_value) VALUES
(\'allow_attachments\', 0),
(\'max_filesize\', ' . $max_filesize . '),
(\'extensions\', \'' . $extensions . '\'),
(\'thumbnail\', 0),
(\'max_attachments\', 1)
';
}
$this->db->sql_query($sql);

$sql_ary = [];
foreach ($rows as $config_name => $config_value)
{
$sql_ary[] = ['config_name' => $config_name, 'config_value' => $config_value];
}
$this->db->sql_multi_insert($this->table_prefix . 'kb_config', $sql_ary);
}

public function update_kb_options_table()
Expand Down Expand Up @@ -451,7 +454,19 @@ public function update_kb_options_table()
'is_local' => 1,
];
}

// MSSQL only takes the fixed ids above with IDENTITY_INSERT on,
// the same way phpBB's own installer fills its ACL options
$identity_insert = strpos($this->db->get_sql_layer(), 'mssql') === 0;
if ($identity_insert)
{
$this->db->sql_query('SET IDENTITY_INSERT ' . $this->table_prefix . 'kb_options ON');
}
$this->db->sql_multi_insert($this->table_prefix . 'kb_options', $sql_ary);
if ($identity_insert)
{
$this->db->sql_query('SET IDENTITY_INSERT ' . $this->table_prefix . 'kb_options OFF');
}
}
}

Expand Down
8 changes: 6 additions & 2 deletions migrations/version_1_0_5.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,12 @@ public function update_data()

public function add_config()
{
$sql = 'INSERT INTO ' . $this->table_prefix . 'kb_config' . ' (config_name, config_value) VALUES
(\'sort_type\', 0)';
// A string: config_value is a text column, which MSSQL will not
// compare with or fill from a number
$sql = 'INSERT INTO ' . $this->table_prefix . 'kb_config ' . $this->db->sql_build_array('INSERT', [
'config_name' => 'sort_type',
'config_value' => '0',
]);
$this->db->sql_query($sql);
}
}
2 changes: 2 additions & 0 deletions migrations/version_2_0_0.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ public function update_schema()
{
$this->config[$row['config_name']] = $row['config_value'];
}
// MSSQL refuses to start a transaction while a result is still open
$this->db->sql_freeresult($result);

// Drop table
return [
Expand Down
10 changes: 10 additions & 0 deletions migrations/version_2_0_30.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,19 @@ public function add_permission(): void
$next_id = (int) $this->db->sql_fetchfield('max_id') + 1;
$this->db->sql_freeresult($result);

// MSSQL only takes an explicit id with IDENTITY_INSERT on
$identity_insert = strpos($this->db->get_sql_layer(), 'mssql') === 0;
if ($identity_insert)
{
$this->db->sql_query('SET IDENTITY_INSERT ' . $this->table_prefix . 'kb_options ON');
}
$this->db->sql_query('INSERT INTO ' . $this->table_prefix . 'kb_options ' . $this->db->sql_build_array('INSERT', [
'auth_option_id' => $next_id, 'auth_option' => 'kb_m_change_author', 'is_global' => 0, 'is_local' => 1,
]));
if ($identity_insert)
{
$this->db->sql_query('SET IDENTITY_INSERT ' . $this->table_prefix . 'kb_options OFF');
}
}
}

Expand Down
121 changes: 121 additions & 0 deletions tests/browser/kb_page_sweep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Load every main Knowledge Base page and every Knowledge Base ACP module as
// the seeded administrator, and fail on a server error, a PHP error or
// phpBB's SQL error page. Read-only: it opens pages and submits nothing but
// the login forms, so it suits any seed-forum board on any database.
//
// KB_TEST_URL is the board's base URL, without a trailing slash. Routes go
// through app.php, so the board needs no clean-URL rewriting.
// KB_ARTIFACT_DIR optionally saves a screenshot of each page that fails.
const {chromium} = require('playwright');
const fs = require('node:fs');

const base = process.env.KB_TEST_URL;
if (!/^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(base || '')) throw new Error('Set KB_TEST_URL to a disposable local board, for example http://localhost:8171');
const out = process.env.KB_ARTIFACT_DIR;
const password = 'KbTest1234!';

// Text that only shows up when a page broke
const ERROR_MARKERS = ['SQL ERROR', 'Fatal error', 'Parse error', 'Uncaught', 'General Error', '[phpBB Debug]', 'Warning</b>:', 'Notice</b>:', 'Deprecated</b>:'];

// phpBB refuses a form submitted within two seconds of it being made
async function waitForFormAge(page) {
await page.waitForFunction(() => {
const field = document.querySelector('[name="creation_time"]');
return field && Date.now() / 1000 > Number(field.value) + 2;
});
}

(async () => {
const browser = await chromium.launch({headless: true});
const page = await browser.newPage({viewport: {width: 1280, height: 900}});
// PHP's built-in server (MSSQL boards) serves styles and scripts slowly, so
// pages are checked once their HTML is in, without waiting for every asset
page.setDefaultTimeout(120000);
const ready = {waitUntil: 'domcontentloaded'};
// Only the HTML is checked, and scripts would hold up domcontentloaded
await page.route(/\.(css|js|png|gif|jpe?g|svg|ico|woff2?|ttf)(\?|$)/, route => route.abort());
const results = [];

async function check(label, url) {
const response = await page.goto(url, ready);
const body = await page.content();
const problems = [];
if (!response || response.status() >= 500) problems.push(`HTTP ${response ? response.status() : 'no response'}`);
for (const marker of ERROR_MARKERS) {
if (body.includes(marker)) problems.push(marker);
}
if (problems.length) {
// phpBB's error page message, for the SQL error and query
const text = await page.locator('body').innerText();
const at = Math.max(0, text.search(/SQL ERROR|Fatal error|General Error|Warning|Notice/));
problems.push(JSON.stringify(text.slice(at, at + 600)));
}
if (problems.length && out) {
fs.mkdirSync(out, {recursive: true});
await page.screenshot({path: `${out}/${label.replace(/[^a-z0-9]+/gi, '-')}.png`, fullPage: true});
}
results.push({label, status: response ? response.status() : 0, problems});
return body;
}

try {
// Board login
await page.goto(`${base}/ucp.php?mode=login`, ready);
await page.locator('#username').fill('admin');
await page.locator('#password').fill(password);
await waitForFormAge(page);
await page.locator('[name="login"][type="submit"]').click();
await page.waitForURL(url => !url.search.includes('mode=login'), ready);

// Front end: the index, then the first category and article it links to
await check('index', `${base}/app.php/knowledgebase`);
const category = await firstLink(page, 'knowledgebase/category?id=');
const article = await firstLink(page, 'knowledgebase/article?k=');
if (!category || !article) throw new Error('The index links to no category or article; seed the board first');
await check('category', category);
await check('article', article);
await check('search', `${base}/app.php/knowledgebase/search?keywords=guide&sf=all&cid=0`);
await check('queue', `${base}/app.php/knowledgebase/queue`);
await check('my articles', `${base}/app.php/knowledgebase/my_articles`);
await check('recent changes', `${base}/app.php/knowledgebase/recent_changes`);
await check('recent changes rss', `${base}/app.php/knowledgebase/recent_changes.rss`);
await check('posting form', `${base}/app.php/knowledgebase/posting?mode=add`);
await check('edit form', `${base}/app.php/knowledgebase/posting?mode=edit&id=${new URL(article).searchParams.get('k')}`);

// ACP: it needs the session id in the address, and the password again
const settings = '/adm/index.php?i=-phpbbmodders-knowledgebase-acp-config_module&mode=settings';
await page.goto(`${base}${settings}&sid=${await sessionCookie(page)}`, ready);
if (await page.locator('input[type="password"]').count()) {
await page.locator('input[type="password"]').fill(password);
await waitForFormAge(page);
// phpBB 4.0 keeps the ACP session in the cookie, so the address may not change
await Promise.all([page.waitForNavigation(ready), page.locator('[name="login"][type="submit"]').click()]);
}
await check('acp settings', `${base}${settings}&sid=${await sessionCookie(page)}`);
const modules = await page.locator('#menu a[href*="i=-phpbbmodders-knowledgebase-acp-"]').evaluateAll(links => links.map(link => [link.textContent.trim(), link.href]));
if (!modules.length) throw new Error('No Knowledge Base modules in the ACP menu');
for (const [label, href] of modules) await check(`acp ${label}`, href);
} finally {
await browser.close();
}

const failed = results.filter(result => result.problems.length);
for (const result of results) console.log(`${result.problems.length ? 'FAIL' : 'ok '} ${result.status} ${result.label}${result.problems.length ? ': ' + result.problems.join(', ') : ''}`);
console.log(`${results.length - failed.length} of ${results.length} pages loaded cleanly`);
process.exitCode = failed.length ? 1 : 0;
})();

// The first link on the page whose address contains the text, or null
async function firstLink(page, text) {
return page.locator('a').evaluateAll((links, part) => {
// Skip same-page anchors, which load nothing
const link = links.find(a => a.href.includes(part) && !a.href.includes('#'));
return link ? link.href : null;
}, text);
}

// The session id from phpBB's cookie, which every ACP address must carry
async function sessionCookie(page) {
const cookie = (await page.context().cookies()).find(item => item.name.endsWith('_sid'));
return cookie ? cookie.value : '';
}
Loading
Loading