current commit
261001.1 · sqlite: restore connection state on reopen
'use strict';

const common = require('../common');
common.skipIfSQLiteMissing();

const assert = require('node:assert');
const { Database, constants } = require('node:sqlite');
const { suite, it } = require('node:test');

suite('Database.prototype.setAuthorizer()', () => {
  const createTestDatabase = () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE users (id INTEGER, name TEXT)');
    return db;
  };

  it('receives correct parameters for SELECT operations', (t) => {
    const authorizer = t.mock.fn(() => constants.SQLITE_OK);
    const db = createTestDatabase();

    db.setAuthorizer(authorizer);
    db.prepare('SELECT id FROM users').get();

    assert.strictEqual(authorizer.mock.callCount(), 2);
    const callArguments = authorizer.mock.calls.map((call) => call.arguments);

    assert.deepStrictEqual(
      callArguments,
      [
        [constants.SQLITE_SELECT, null, null, null, null],
        [constants.SQLITE_READ, 'users', 'id', 'main', null],
      ]
    );
  });

  it('receives correct parameters for INSERT operations', (t) => {
    const authorizer = t.mock.fn(() => constants.SQLITE_OK);
    const db = createTestDatabase();

    db.setAuthorizer(authorizer);
    db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'node');

    assert.strictEqual(authorizer.mock.callCount(), 1);

    const callArguments = authorizer.mock.calls.map((call) => call.arguments);
    assert.deepStrictEqual(
      callArguments,
      [[constants.SQLITE_INSERT, 'users', null, 'main', null]],
    );
  });

  it('allows operations when authorizer returns SQLITE_OK', () => {
    const db = new Database(':memory:');
    db.setAuthorizer(() => constants.SQLITE_OK);

    db.exec('CREATE TABLE users (id INTEGER, name TEXT)');
    const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();

    assert.strictEqual(tables[0].name, 'users');
  });

  it('blocks operations when authorizer returns SQLITE_DENY', () => {
    const db = new Database(':memory:');
    db.setAuthorizer(() => constants.SQLITE_DENY);

    assert.throws(() => {
      db.exec('SELECT 1');
    }, {
      code: 'ERR_SQLITE_ERROR',
      message: /not authorized/
    });
  });

  it('ignores SELECT operations when authorizer returns SQLITE_IGNORE', () => {
    const db = createTestDatabase();
    db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice');

    db.setAuthorizer((actionCode) => {
      if (actionCode === constants.SQLITE_SELECT) {
        return constants.SQLITE_IGNORE;
      }
      return constants.SQLITE_OK;
    });

    // SELECT should be ignored and return no results
    const result = db.prepare('SELECT * FROM users').all();
    assert.deepStrictEqual(result, []);
  });

  it('ignores READ operations when authorizer returns SQLITE_IGNORE', () => {
    const db = createTestDatabase();
    db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice');

    db.setAuthorizer((actionCode, arg1, arg2) => {
      if (actionCode === constants.SQLITE_READ && arg1 === 'users' && arg2 === 'name') {
        return constants.SQLITE_IGNORE;
      }
      return constants.SQLITE_OK;
    });

    // Reading the 'name' column should be ignored, returning NULL
    const result = db.prepare('SELECT id, name FROM users WHERE id = 1').get();
    assert.strictEqual(result.id, 1);
    assert.strictEqual(result.name, null);
  });

  it('ignores INSERT operations when authorizer returns SQLITE_IGNORE', () => {
    const db = createTestDatabase();

    db.setAuthorizer((actionCode) => {
      if (actionCode === constants.SQLITE_INSERT) {
        return constants.SQLITE_IGNORE;
      }
      return constants.SQLITE_OK;
    });

    db.prepare('INSERT INTO users (id, name) VALUES (?, ?)').run(1, 'Alice');

    // Verify no data was inserted
    const count = db.prepare('SELECT COUNT(*) as count FROM users').get();
    assert.strictEqual(count.count, 0);
  });

  it('ignores UPDATE operations when authorizer returns SQLITE_IGNORE', () => {
    const db = createTestDatabase();
    db.exec("INSERT INTO users (id, name) VALUES (1, 'Alice')");

    db.setAuthorizer((actionCode) => {
      if (actionCode === constants.SQLITE_UPDATE) {
        return constants.SQLITE_IGNORE;
      }
      return constants.SQLITE_OK;
    });

    db.prepare('UPDATE users SET name = ? WHERE id = ?').run('Bob', 1);

    // Verify data was not updated
    const result = db.prepare('SELECT name FROM users WHERE id = 1').get();
    assert.strictEqual(result.name, 'Alice');
  });

  it('ignores DELETE operations when authorizer returns SQLITE_IGNORE', () => {
    const db = createTestDatabase();
    db.exec("INSERT INTO users (id, name) VALUES (1, 'Alice')");

    db.setAuthorizer(() => constants.SQLITE_IGNORE);

    db.prepare('DELETE FROM users WHERE id = ?').run(1);

    db.setAuthorizer(null);

    // Verify data was not deleted
    const count = db.prepare('SELECT COUNT(*) as count FROM users').get();
    assert.strictEqual(count.count, 1);
  });

  it('rethrows error when authorizer throws error', () => {
    const db = new Database(':memory:');
    db.setAuthorizer(() => {
      throw new Error('Unknown error');
    });

    assert.throws(() => {
      db.exec('SELECT 1');
    }, {
      message: 'Unknown error'
    });
  });

  it('throws error when authorizer returns nothing', () => {
    const db = new Database(':memory:');
    db.setAuthorizer(() => {
    });

    assert.throws(() => {
      db.exec('SELECT 1');
    }, {
      message: 'Authorizer callback must return an integer authorization code'
    });
  });

  it('throws error when authorizer returns NaN', () => {
    const db = new Database(':memory:');
    db.setAuthorizer(() => {
      return '1';
    });

    assert.throws(() => {
      db.exec('SELECT 1');
    }, {
      message: 'Authorizer callback must return an integer authorization code'
    });
  });

  it('throws error when authorizer returns a invalid code', () => {
    const db = new Database(':memory:');
    db.setAuthorizer(() => {
      return 3;
    });

    assert.throws(() => {
      db.exec('SELECT 1');
    }, {
      message: 'Authorizer callback returned a invalid authorization code'
    });
  });

  it('clears authorizer when set to null', (t) => {
    const authorizer = t.mock.fn(() => constants.SQLITE_OK);
    const db = new Database(':memory:');
    const statement = db.prepare('SELECT 1');

    // Set authorizer and verify it's called
    db.setAuthorizer(authorizer);
    statement.run();
    assert.strictEqual(authorizer.mock.callCount(), 1);

    // Clear authorizer and verify it's no longer called
    db.setAuthorizer(null);
    statement.run();
    assert.strictEqual(authorizer.mock.callCount(), 1);
  });

  it('throws when callback is a string', () => {
    const db = new Database(':memory:');

    assert.throws(() => {
      db.setAuthorizer('not a function');
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "callback" argument must be a function/
    });
  });

  it('throws when callback is a number', () => {
    const db = new Database(':memory:');

    assert.throws(() => {
      db.setAuthorizer(1);
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "callback" argument must be a function/
    });
  });

  it('throws when callback is an object', () => {
    const db = new Database(':memory:');

    assert.throws(() => {
      db.setAuthorizer({});
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "callback" argument must be a function/
    });
  });

  it('throws when callback is an array', () => {
    const db = new Database(':memory:');

    assert.throws(() => {
      db.setAuthorizer([]);
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "callback" argument must be a function/
    });
  });

  it('throws when callback is undefined', () => {
    const db = new Database(':memory:');

    assert.throws(() => {
      db.setAuthorizer();
    }, {
      code: 'ERR_INVALID_ARG_TYPE',
      message: /The "callback" argument must be a function/
    });
  });

  it('throws if database is not open', () => {
    const db = new Database(':memory:');
    db.close();

    assert.throws(() => {
      db.setAuthorizer(() => constants.SQLITE_OK);
    }, {
      code: 'ERR_INVALID_STATE',
      message: 'database is not open',
    });
  });

  it('remains installed after close() and open()', (t) => {
    const db = new Database(':memory:');
    const authorizer = t.mock.fn(() => constants.SQLITE_DENY);
    db.setAuthorizer(authorizer);

    assert.throws(() => {
      db.exec('CREATE TABLE x (a)');
    }, { code: 'ERR_SQLITE_ERROR' });
    const callsBefore = authorizer.mock.callCount();
    assert.ok(callsBefore > 0);

    db.close();
    db.open();

    assert.throws(() => {
      db.exec('CREATE TABLE x (a)');
    }, { code: 'ERR_SQLITE_ERROR' });
    assert.ok(authorizer.mock.callCount() > callsBefore);
  });

  it('stays cleared after close() and open()', () => {
    const db = new Database(':memory:');
    db.setAuthorizer(() => constants.SQLITE_DENY);
    db.setAuthorizer(null);

    db.close();
    db.open();

    db.exec('CREATE TABLE x (a)');
  });
});

// SQLite forbids an authorizer callback from modifying the connection that
// invoked it, which includes preparing and stepping statements.
// See https://www.sqlite.org/c3ref/set_authorizer.html.
suite('authorizer callback reentrancy', () => {
  const expectedError = 'ERR_INVALID_STATE: database cannot be accessed ' +
    'from an authorizer callback';
  const steppingError =
    'ERR_INVALID_STATE: statement is already being executed';

  // Calls each of `cases` from inside an authorizer callback, and returns a
  // `name -> outcome` map of what each one threw.
  const runInAuthorizer = (db, cases) => {
    const outcomes = {};
    for (const [name, fn] of Object.entries(cases)) {
      let ran = false;
      db.setAuthorizer(() => {
        if (!ran) {
          ran = true;
          try {
            fn();
            outcomes[name] = 'did not throw';
          } catch (err) {
            outcomes[name] = `${err.code}: ${err.message}`;
          }
        }
        return constants.SQLITE_OK;
      });
      db.exec('SELECT 1');
      db.setAuthorizer(null);
      if (!ran) {
        outcomes[name] = 'authorizer callback did not run';
      }
    }
    return outcomes;
  };

  // Builds the expected `name -> outcome` map for the given case names.
  const allRejected = (cases) => Object.fromEntries(
    Object.keys(cases).map((name) => [name, expectedError]),
  );

  it('rejects database methods', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    const cases = {
      prepare: () => db.prepare('SELECT 1'),
      exec: () => db.exec('SELECT 1'),
      setAuthorizer: () => db.setAuthorizer(null),
      createSession: () => db.createSession(),
      applyChangeset: () => db.applyChangeset(new Uint8Array([1])),
      createTagStore: () => db.createTagStore(),
      serialize: () => db.serialize(),
      function: () => db.function('noop', () => 1),
      aggregate: () => db.aggregate('agg', { start: 0, step: (acc) => acc }),
      enableLoadExtension: () => db.enableLoadExtension(false),
      enableDefensive: () => db.enableDefensive(true),
      limits: () => { db.limits.length = 100; },
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
  });

  // loadExtension() checks that extension loading is enabled before reaching
  // the authorizer guard, so it needs a database opened with allowExtension.
  it('rejects loadExtension', () => {
    const db = new Database(':memory:', { allowExtension: true });
    db.enableLoadExtension(true);
    db.exec('CREATE TABLE t (x INTEGER)');
    const cases = {
      loadExtension: () => db.loadExtension('/nonexistent/extension'),
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
  });

  // close() and deserialize() tear down the connection, so the pre-existing
  // callback depth guard already rejects them with its own message.
  it('rejects methods the callback depth guard already covers', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    const snapshot = db.serialize();
    const cases = {
      close: () => db.close(),
      deserialize: () => db.deserialize(snapshot),
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), {
      close: 'ERR_INVALID_STATE: database cannot be closed while in a callback',
      deserialize: 'ERR_INVALID_STATE: database cannot be deserialized ' +
        'while in a callback',
    });
  });

  it('rejects statement methods', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    db.exec('INSERT INTO t VALUES (1)');
    const stmt = db.prepare('SELECT x FROM t');
    const cases = {
      run: () => stmt.run(),
      get: () => stmt.get(),
      all: () => stmt.all(),
      iterate: () => stmt.iterate(),
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
  });

  // An idle statement has no virtual-machine state or locks to release.
  it('allows finalizing an idle statement', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    db.exec('INSERT INTO t VALUES (1)');
    const closeStmt = db.prepare('SELECT x FROM t');
    const disposeStmt = db.prepare('SELECT x FROM t');
    const cases = {
      close: () => closeStmt.close(),
      dispose: () => disposeStmt[Symbol.dispose](),
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), {
      close: 'did not throw',
      dispose: 'did not throw',
    });
  });

  // A paused iterator is busy and may hold locks between sqlite3_step() calls.
  it('rejects finalizing another active statement', () => {
    for (const method of ['close', 'dispose']) {
      const db = new Database(':memory:');
      db.exec('CREATE TABLE t (x INTEGER)');
      db.exec('INSERT INTO t VALUES (1), (2), (3)');
      const stmt = db.prepare('SELECT x FROM t');
      const iter = stmt.iterate();
      iter.next();
      let outcome = 'authorizer callback did not run';

      db.setAuthorizer((actionCode) => {
        if (actionCode === constants.SQLITE_DROP_TABLE) {
          try {
            if (method === 'close') {
              stmt.close();
            } else {
              stmt[Symbol.dispose]();
            }
            outcome = 'did not throw';
          } catch (err) {
            outcome = `${err.code}: ${err.message}`;
          }
        }
        return constants.SQLITE_OK;
      });

      assert.throws(() => db.exec('DROP TABLE t'), {
        code: 'ERR_SQLITE_ERROR',
        message: 'database table is locked',
      });
      assert.strictEqual(outcome, expectedError);
      db.setAuthorizer(null);
      iter.return();
    }
  });

  // Disposal is idempotent, so a statement that is already finalized must stay
  // a no-op even inside a callback. Throwing here would turn a `using` scope's
  // real exception into a SuppressedError.
  it('allows disposing an already-finalized statement', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    const stmt = db.prepare('SELECT x FROM t');
    stmt.close();
    const cases = { dispose: () => stmt[Symbol.dispose]() };

    assert.deepStrictEqual(runInAuthorizer(db, cases), {
      dispose: 'did not throw',
    });
  });

  it('rejects session changeset methods', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER PRIMARY KEY, y TEXT)');
    const session = db.createSession({ table: 't' });
    db.exec("INSERT INTO t VALUES (1, 'a')");
    const cases = {
      changeset: () => session.changeset(),
      patchset: () => session.patchset(),
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
  });

  // A statement being re-prepared inside sqlite3_step() is the case that
  // actually crashes, because that statement's VM is mid-execution.
  it('rejects finalizing the statement being stepped', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    db.exec('INSERT INTO t VALUES (1)');
    const stmt = db.prepare('SELECT x FROM t');
    stmt.get();
    db.exec('ALTER TABLE t ADD COLUMN y INTEGER');

    let outcome = 'authorizer callback did not run';
    let ran = false;
    db.setAuthorizer(() => {
      if (!ran) {
        ran = true;
        try {
          stmt.close();
          outcome = 'did not throw';
        } catch (err) {
          outcome = `${err.code}: ${err.message}`;
        }
      }
      return constants.SQLITE_OK;
    });

    stmt.get();

    assert.strictEqual(outcome, steppingError);
  });

  // Unlike an already-finalized statement, disposing the one being stepped
  // would free the running virtual machine, so it throws.
  it('rejects disposing the statement being stepped', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    db.exec('INSERT INTO t VALUES (1)');
    const stmt = db.prepare('SELECT x FROM t');
    stmt.get();
    db.exec('ALTER TABLE t ADD COLUMN y INTEGER');

    let outcome = 'authorizer callback did not run';
    let ran = false;
    db.setAuthorizer(() => {
      if (!ran) {
        ran = true;
        try {
          stmt[Symbol.dispose]();
          outcome = 'did not throw';
        } catch (err) {
          outcome = `${err.code}: ${err.message}`;
        }
      }
      return constants.SQLITE_OK;
    });

    stmt.get();

    assert.strictEqual(outcome, steppingError);
  });

  it('rejects iterator methods', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    db.exec('INSERT INTO t VALUES (1), (2)');
    const iter = db.prepare('SELECT x FROM t').iterate();
    const cases = {
      next: () => iter.next(),
      return: () => iter.return(),
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
    iter.return();
  });

  // A drained iterator holds no SQLite state, so next() and return() stay
  // available and remain idempotent inside a callback.
  it('allows iterator methods on a drained iterator', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    db.exec('INSERT INTO t VALUES (1)');
    const iter = db.prepare('SELECT x FROM t').iterate();
    for (const row of iter) {
      assert.ok(row);
    }
    const done = {};
    const cases = {
      next: () => { done.next = iter.next().done; },
      return: () => { done.return = iter.return().done; },
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), {
      next: 'did not throw',
      return: 'did not throw',
    });
    assert.deepStrictEqual(done, { next: true, return: true });
  });

  it('rejects tag store methods', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    const sql = db.createTagStore(10);
    const cases = {
      run: () => sql.run`SELECT 1`,
      get: () => sql.get`SELECT 1`,
      all: () => sql.all`SELECT 1`,
      iterate: () => sql.iterate`SELECT 1`,
    };

    assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));
  });

  // clear() only drops cached statements, so invalidating the cache after a
  // schema change is allowed from the callback.
  it('allows clearing a tag store', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    db.exec('INSERT INTO t VALUES (1)');
    const sql = db.createTagStore(10);
    assert.strictEqual(sql.all`SELECT x FROM t`.length, 1);
    assert.strictEqual(sql.size, 1);
    const cases = { clear: () => sql.clear() };

    assert.deepStrictEqual(runInAuthorizer(db, cases), {
      clear: 'did not throw',
    });
    assert.strictEqual(sql.size, 0);
  });

  // A statement may be re-prepared during sqlite3_step() after a schema
  // change, which invokes the authorizer without an explicit prepare() call.
  it('rejects reentry when the authorizer runs during a re-prepare', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    db.exec('INSERT INTO t VALUES (1)');
    const stmt = db.prepare('SELECT x FROM t');
    stmt.get();
    db.exec('ALTER TABLE t ADD COLUMN y INTEGER');

    let outcome = 'authorizer callback did not run';
    let ran = false;
    db.setAuthorizer(() => {
      if (!ran) {
        ran = true;
        try {
          db.prepare('SELECT 1');
          outcome = 'did not throw';
        } catch (err) {
          outcome = `${err.code}: ${err.message}`;
        }
      }
      return constants.SQLITE_OK;
    });

    stmt.get();

    assert.strictEqual(outcome, expectedError);
  });

  it('allows access again after the authorizer returns', () => {
    const db = new Database(':memory:');
    db.exec('CREATE TABLE t (x INTEGER)');
    const cases = { prepare: () => db.prepare('SELECT 1') };

    assert.deepStrictEqual(runInAuthorizer(db, cases), allRejected(cases));

    db.setAuthorizer(() => constants.SQLITE_OK);
    assert.deepStrictEqual(db.prepare('SELECT 1 AS v').get(), { __proto__: null, v: 1 });
  });
});