also looking at this
A read-scoped project key, so a Stacks dashboard can read logs without a person's credentials
#15
9 files
+537
-17
| @@ -0,0 +1,120 @@ | ||
| 1 | import { describe, expect, test } from 'bun:test' | |
| 2 | import { authorizeRead, LAST_USED_RESOLUTION_MS, looksLikeReadKey, newReadKey, sameKey, shouldRecordUse } from '../../app/Support/read-key' | |
| 3 | ||
| 4 | const KEY = newReadKey() | |
| 5 | const project = { id: 'demo', read_key: KEY, is_active: true } | |
| 6 | ||
| 7 | describe('newReadKey', () => { | |
| 8 | test('is prefixed and carries 256 bits of hex', () => { | |
| 9 | expect(KEY.startsWith('lhq_read_')).toBe(true) | |
| 10 | expect(KEY.slice('lhq_read_'.length)).toMatch(/^[0-9a-f]{64}$/) | |
| 11 | }) | |
| 12 | ||
| 13 | test('does not repeat', () => { | |
| 14 | const keys = new Set(Array.from({ length: 200 }, () => newReadKey())) | |
| 15 | expect(keys.size).toBe(200) | |
| 16 | }) | |
| 17 | }) | |
| 18 | ||
| 19 | describe('looksLikeReadKey', () => { | |
| 20 | test('accepts a key it minted', () => { | |
| 21 | expect(looksLikeReadKey(KEY)).toBe(true) | |
| 22 | }) | |
| 23 | ||
| 24 | test('rejects anything else a bearer header might carry', () => { | |
| 25 | // The prefix is what keeps a read key out of Auth.getUserFromToken, so a | |
| 26 | // user's token and an ingest key must never pass for one. | |
| 27 | for (const value of [ | |
| 28 | '', | |
| 29 | null, | |
| 30 | undefined, | |
| 31 | 'loghq_5f3a19b7c4d2e8091ab12cd34ef56ab7', | |
| 32 | 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig', | |
| 33 | `${'lhq_read_'}${'z'.repeat(64)}`, | |
| 34 | `${'lhq_read_'}${'a'.repeat(63)}`, | |
| 35 | `${'lhq_read_'}${'a'.repeat(65)}`, | |
| 36 | KEY.toUpperCase(), | |
| 37 | ]) | |
| 38 | expect(looksLikeReadKey(value)).toBe(false) | |
| 39 | }) | |
| 40 | }) | |
| 41 | ||
| 42 | describe('sameKey', () => { | |
| 43 | test('matches an identical key and nothing else', () => { | |
| 44 | expect(sameKey(KEY, KEY)).toBe(true) | |
| 45 | expect(sameKey(KEY, newReadKey())).toBe(false) | |
| 46 | }) | |
| 47 | ||
| 48 | test('is false on a shared prefix, not just a differing first byte', () => { | |
| 49 | const other = `${KEY.slice(0, -1)}${KEY.endsWith('a') ? 'b' : 'a'}` | |
| 50 | expect(sameKey(other, KEY)).toBe(false) | |
| 51 | }) | |
| 52 | ||
| 53 | test('does not throw on a length mismatch', () => { | |
| 54 | expect(sameKey('short', KEY)).toBe(false) | |
| 55 | expect(sameKey(KEY, 'short')).toBe(false) | |
| 56 | }) | |
| 57 | }) | |
| 58 | ||
| 59 | describe('authorizeRead', () => { | |
| 60 | test('allows the project its own key', () => { | |
| 61 | expect(authorizeRead(project, KEY)).toEqual({ ok: true }) | |
| 62 | }) | |
| 63 | ||
| 64 | test('refuses an unknown project with 404', () => { | |
| 65 | expect(authorizeRead(null, KEY)).toEqual({ ok: false, status: 404, error: 'unknown project' }) | |
| 66 | expect(authorizeRead(undefined, KEY)).toEqual({ ok: false, status: 404, error: 'unknown project' }) | |
| 67 | }) | |
| 68 | ||
| 69 | test('refuses an inactive project with 403, however is_active is stored', () => { | |
| 70 | // sqlite hands booleans back as 0/1. | |
| 71 | expect(authorizeRead({ ...project, is_active: false }, KEY).ok).toBe(false) | |
| 72 | expect(authorizeRead({ ...project, is_active: 0 }, KEY)).toEqual({ ok: false, status: 403, error: 'project inactive' }) | |
| 73 | }) | |
| 74 | ||
| 75 | test('refuses a project with no key issued, whatever is presented', () => { | |
| 76 | // Guards `null === null`: no key configured must never authorise, not even | |
| 77 | // against a caller presenting nothing. | |
| 78 | for (const presented of [KEY, null, undefined, '']) | |
| 79 | expect(authorizeRead({ ...project, read_key: null }, presented).ok).toBe(false) | |
| 80 | expect(authorizeRead({ ...project, read_key: null }, KEY).status).toBe(403) | |
| 81 | }) | |
| 82 | ||
| 83 | test('refuses another project\'s key with 401', () => { | |
| 84 | expect(authorizeRead(project, newReadKey())).toEqual({ ok: false, status: 401, error: 'invalid read key' }) | |
| 85 | }) | |
| 86 | ||
| 87 | test('refuses an ingest key, which grants writes and must not grant reads', () => { | |
| 88 | expect(authorizeRead({ ...project, read_key: KEY }, 'loghq_5f3a19b7c4d2e8091ab12cd34ef56ab7').ok).toBe(false) | |
| 89 | }) | |
| 90 | ||
| 91 | test('refuses a stored value that is not a well-formed key', () => { | |
| 92 | // A column hand-edited to something short must not become a weak key. | |
| 93 | expect(authorizeRead({ ...project, read_key: 'x' }, 'x').ok).toBe(false) | |
| 94 | }) | |
| 95 | }) | |
| 96 | ||
| 97 | describe('shouldRecordUse', () => { | |
| 98 | const now = new Date('2026-10-19T12:00:00.000Z') | |
| 99 | ||
| 100 | test('records the first use', () => { | |
| 101 | expect(shouldRecordUse(null, now)).toBe(true) | |
| 102 | expect(shouldRecordUse(undefined, now)).toBe(true) | |
| 103 | }) | |
| 104 | ||
| 105 | test('records again once the resolution has passed, and not before', () => { | |
| 106 | const justBefore = new Date(now.getTime() - LAST_USED_RESOLUTION_MS + 1).toISOString() | |
| 107 | const atResolution = new Date(now.getTime() - LAST_USED_RESOLUTION_MS).toISOString() | |
| 108 | expect(shouldRecordUse(justBefore, now)).toBe(false) | |
| 109 | expect(shouldRecordUse(atResolution, now)).toBe(true) | |
| 110 | }) | |
| 111 | ||
| 112 | test('records when the stored stamp cannot be read', () => { | |
| 113 | // Better an extra write than a key that never looks used. | |
| 114 | expect(shouldRecordUse('not a date', now)).toBe(true) | |
| 115 | }) | |
| 116 | ||
| 117 | test('accepts a Date as well as a string', () => { | |
| 118 | expect(shouldRecordUse(new Date(now.getTime() - 1000), now)).toBe(false) | |
| 119 | }) | |
| 120 | }) | |