Files
authentik-qr-login/test/sessions.test.ts

95 lines
3.7 KiB
TypeScript
Raw Permalink Normal View History

import assert from "node:assert/strict";
import { test } from "node:test";
import { pickDecoys, pickNumber } from "../src/numbers.ts";
import { SessionStore, secretEquals } from "../src/sessions.ts";
const opts = { ttlSeconds: 90, numberMode: "type" as const, maxSessions: 10 };
const newStore = (o = {}) => new SessionStore({ ...opts, ...o });
test("a fresh session carries two DIFFERENT secrets", () => {
const s = newStore().create(0, "10.0.0.1", "ua");
// The poll id is visible on the laptop's screen; the approval token is only
// in the QR. Reusing one value for both would collapse that separation.
assert.notEqual(s.id, s.approvalToken);
assert.equal(s.status, "pending");
});
test("the happy path approves and yields the username exactly once", () => {
const store = newStore();
const s = store.create(0, "10.0.0.1", "ua");
assert.equal(store.decide(s, "michal", "10.0.0.2", s.number), "approved");
assert.equal(store.consume(s.id, 1000), "michal");
// Single-use: a replay of the same id must assert nobody. This is what stops
// a captured session id from being redeemed twice.
assert.equal(store.consume(s.id, 1000), undefined);
assert.equal(store.get(s.id, 1000)?.status, "consumed");
});
test("a wrong number denies outright and never allows a retry", () => {
const store = newStore();
const s = store.create(0, "10.0.0.1", "ua");
const wrong = s.number === 99 ? 98 : s.number + 1;
assert.equal(store.decide(s, "michal", "10.0.0.2", wrong), "denied");
// The correct answer afterwards must NOT rescue it: an unbounded retry loop
// would turn 6.5 bits of entropy back into none.
assert.equal(store.decide(s, "michal", "10.0.0.2", s.number), "denied");
assert.equal(store.consume(s.id, 1000), undefined);
});
test("an explicit deny (null) is honoured", () => {
const store = newStore();
const s = store.create(0, "10.0.0.1", "ua");
assert.equal(store.decide(s, "michal", "10.0.0.2", null), "denied");
assert.equal(store.consume(s.id, 1000), undefined);
});
test("only approved sessions can be consumed", () => {
const store = newStore();
const s = store.create(0, "10.0.0.1", "ua");
assert.equal(store.consume(s.id, 1000), undefined, "pending must not assert a user");
});
test("sessions expire, and expiry frees the approval token", () => {
const store = newStore({ ttlSeconds: 1 });
const s = store.create(0, "10.0.0.1", "ua");
assert.ok(store.get(s.id, 500));
assert.equal(store.get(s.id, 1_001), undefined);
assert.equal(store.byToken(s.approvalToken, 1_001), undefined);
assert.equal(store.size, 0);
});
test("the store is bounded, and sweeping reclaims room", () => {
const store = newStore({ maxSessions: 2, ttlSeconds: 1 });
store.create(0, "10.0.0.1", "ua");
store.create(0, "10.0.0.1", "ua");
assert.throws(() => store.create(0, "10.0.0.1", "ua"), /too many/);
// Once the first two lapse the sweep on create() reclaims them, so a flood
// is bounded memory rather than a permanent outage.
assert.ok(store.create(2_000, "10.0.0.1", "ua"));
});
test("numbers are two digits and decoys are distinct from the answer", () => {
for (let i = 0; i < 200; i++) {
const n = pickNumber();
assert.ok(n >= 10 && n <= 99, `${n} out of range`);
}
const answer = pickNumber();
const choices = pickDecoys(answer);
assert.equal(choices.length, 3);
assert.equal(new Set(choices).size, 3, "decoys must not repeat");
assert.ok(choices.includes(answer), "the answer must be among the choices");
});
test("secretEquals rejects different lengths without throwing", () => {
assert.equal(secretEquals("abc", "abcd"), false);
assert.equal(secretEquals("abc", "abc"), true);
assert.equal(secretEquals("abc", "abd"), false);
});