test: add headless integration test suite for refreshToken() lifecycle
Some checks failed
Stuffle/iam-client-sdk/pipeline/pr-main There was a failure building this commit

This commit is contained in:
2026-05-03 19:18:26 +05:30
parent cf787e3b4c
commit 3c9bc6e57c
7 changed files with 1137 additions and 3 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
.DS_Store
dist
dist
tests/.env.test

835
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,9 @@
"devDependencies": {
"@types/node": "^20.10.0",
"@types/react": "^18.2.0",
"@vitest/coverage-v8": "^4.1.5",
"dotenv": "^17.4.2",
"jsdom": "^29.1.1",
"react": "^18.2.0",
"tsup": "^8.0.0",
"typescript": "^5.3.0",

14
tests/.env.test.example Normal file
View File

@@ -0,0 +1,14 @@
# Copy this to tests/.env.test (gitignored) and fill in a real refresh token.
#
# How to get IAM_REFRESH_TOKEN:
# 1. Open https://nebula.stuffle.io in a browser, log in.
# 2. DevTools → Application → Local Storage → https://nebula.stuffle.io
# 3. Copy value of key: stuffle_iam_refresh_token
# 4. Paste below.
#
# Then run: IAM_REFRESH_TOKEN=<value> npm test
# Or: cp tests/.env.test.example tests/.env.test (and edit)
IAM_ISSUER=https://iam.armco.dev
IAM_CLIENT_ID=client_5bb63e4ae1644c9d97ffc84a5d228c7c
IAM_REFRESH_TOKEN=

View File

@@ -0,0 +1,244 @@
/**
* IAM Client SDK — refreshToken() integration tests
*
* These tests run against the REAL IAM server (iam.armco.dev).
* They require a valid refresh token obtained from a prior login session.
*
* ## How to get a refresh token for the first run:
*
* 1. Open Nebula in browser, log in normally.
* 2. Open DevTools → Application → Local Storage → https://nebula.stuffle.io
* 3. Copy the value of "stuffle_iam_refresh_token"
* 4. Create tests/.env.test (gitignored) with:
*
* IAM_REFRESH_TOKEN=<paste here>
*
* Or set the env var directly:
* IAM_REFRESH_TOKEN=<value> npm test
*
* ## What is tested:
* T1. refreshToken() succeeds with a valid refresh token → returns new tokens
* T2. refreshToken() stores new tokens in localStorage
* T3. getAccessToken() with missing access token triggers refreshToken()
* T4. getAccessToken() with expired access token triggers refreshToken()
* T5. refreshToken() with invalid/expired refresh token clears storage + notifies listeners
* T6. setupAutoRefresh via init() schedules next refresh when token is valid
* T7. simulateExpiry flow: remove access token → getAccessToken() → refresh → isAuthenticated()
*/
import { describe, it, expect, beforeAll, vi } from 'vitest'
import { createStuffleIAMClient } from '../src/index'
import type { StuffleIAMClient } from '../src/client'
// ── Config ────────────────────────────────────────────────────────────────────
const IAM_ISSUER = process.env.IAM_ISSUER ?? 'https://iam.armco.dev'
const IAM_CLIENT_ID = process.env.IAM_CLIENT_ID ?? 'client_5bb63e4ae1644c9d97ffc84a5d228c7c'
const INITIAL_REFRESH_TOKEN = process.env.IAM_REFRESH_TOKEN ?? ''
const SKIP_IF_NO_TOKEN = !INITIAL_REFRESH_TOKEN
? 'IAM_REFRESH_TOKEN env var not set — skipping integration tests'
: false
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeClient(): StuffleIAMClient {
return createStuffleIAMClient({
issuer: IAM_ISSUER,
clientId: IAM_CLIENT_ID,
redirectUri: 'https://nebula.stuffle.io/callback',
scopes: ['openid', 'profile', 'email', 'offline_access'],
storage: 'localStorage',
autoRefresh: false, // disable background timers in tests
}) as unknown as StuffleIAMClient
}
/** Seed localStorage with a refresh token (and optionally a stale access token) */
function seedStorage(refreshToken: string, accessToken?: string): void {
localStorage.setItem('stuffle_iam_refresh_token', refreshToken)
if (accessToken) {
localStorage.setItem('stuffle_iam_access_token', accessToken)
}
}
function clearStorage(): void {
const keys: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k?.startsWith('stuffle_iam_')) keys.push(k!)
}
keys.forEach(k => localStorage.removeItem(k))
}
/** Build a JWT with a past exp claim (signature deliberately invalid — client doesn't verify) */
function expiredJwt(): string {
const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const payload = btoa(JSON.stringify({
sub: 'test-user',
exp: Math.floor(Date.now() / 1000) - 3600,
iat: Math.floor(Date.now() / 1000) - 7200,
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
return `${header}.${payload}.fakesig`
}
// ── Pre-flight check ───────────────────────────────────────────────────────────
beforeAll(async () => {
if (SKIP_IF_NO_TOKEN) {
console.warn(`\n⚠ ${SKIP_IF_NO_TOKEN}\n`)
return
}
// Validate IAM reachability
try {
const res = await fetch(`${IAM_ISSUER}/.well-known/openid-configuration`)
if (!res.ok) throw new Error(`Discovery endpoint returned ${res.status}`)
console.log(`✅ IAM reachable at ${IAM_ISSUER}`)
} catch (e) {
console.error(`❌ IAM not reachable at ${IAM_ISSUER}:`, e)
throw e
}
})
// ── Tests ──────────────────────────────────────────────────────────────────────
describe('refreshToken() — real IAM', () => {
it.skipIf(!!SKIP_IF_NO_TOKEN)('T1: succeeds with valid refresh token and returns new tokens', async () => {
const client = makeClient()
seedStorage(INITIAL_REFRESH_TOKEN)
const result = await client.refreshToken()
expect(result).not.toBeNull()
expect(result!.access_token).toBeTruthy()
expect(result!.refresh_token).toBeTruthy()
expect(typeof result!.expires_in).toBe('number')
expect(result!.expires_in).toBeGreaterThan(0)
console.log(` → new token expires_in: ${result!.expires_in}s`)
})
it.skipIf(!!SKIP_IF_NO_TOKEN)('T2: stores new tokens in localStorage after refresh', async () => {
const client = makeClient()
seedStorage(INITIAL_REFRESH_TOKEN)
await client.refreshToken()
const storedAt = localStorage.getItem('stuffle_iam_access_token')
const storedRt = localStorage.getItem('stuffle_iam_refresh_token')
const storedExp = localStorage.getItem('stuffle_iam_expires_at')
expect(storedAt).toBeTruthy()
expect(storedRt).toBeTruthy()
expect(storedExp).toBeTruthy()
expect(parseInt(storedExp!)).toBeGreaterThan(Date.now())
})
it.skipIf(!!SKIP_IF_NO_TOKEN)('T3: getAccessToken() with NO access token triggers refreshToken()', async () => {
const client = makeClient()
// Only seed refresh token — no access token
seedStorage(INITIAL_REFRESH_TOKEN)
const token = await client.getAccessToken()
expect(token).toBeTruthy()
// Access token should now be in storage
expect(localStorage.getItem('stuffle_iam_access_token')).toBeTruthy()
})
it.skipIf(!!SKIP_IF_NO_TOKEN)('T4: getAccessToken() with expired access token triggers refreshToken()', async () => {
const client = makeClient()
seedStorage(INITIAL_REFRESH_TOKEN, expiredJwt())
const token = await client.getAccessToken()
expect(token).toBeTruthy()
// New token must differ from the fake expired one
const stored = localStorage.getItem('stuffle_iam_access_token')
expect(stored).not.toBe(expiredJwt())
})
it.skipIf(!!SKIP_IF_NO_TOKEN)('T7: simulateExpiry flow — remove access token → getAccessToken() → still authenticated', async () => {
const client = makeClient()
// Step 1: seed a valid session (simulates app on page load with fresh tokens)
seedStorage(INITIAL_REFRESH_TOKEN)
const firstRefresh = await client.refreshToken()
expect(firstRefresh).not.toBeNull()
// Step 2: simulate the hourly expiry — remove just the access token
localStorage.removeItem('stuffle_iam_access_token')
localStorage.setItem('stuffle_iam_expires_at', (Date.now() - 1000).toString())
expect(client.isAuthenticated()).toBe(false) // confirms access token gone
// Step 3: call getAccessToken() — should transparently refresh
const newToken = await client.getAccessToken()
expect(newToken).toBeTruthy()
expect(client.isAuthenticated()).toBe(true)
console.log(' → isAuthenticated() = true after simulated expiry + refresh')
})
})
describe('refreshToken() — failure paths (no real IAM needed)', () => {
it('T5a: returns null when no refresh token in storage', async () => {
const client = makeClient()
clearStorage() // nothing seeded
const result = await client.refreshToken()
expect(result).toBeNull()
})
it('T5b: clears storage and notifies listeners on server rejection (bad refresh token)', async () => {
const client = makeClient()
seedStorage('definitely_invalid_refresh_token_xyz')
const notifications: boolean[] = []
client.subscribe((state) => notifications.push(state.isAuthenticated))
const result = await client.refreshToken()
expect(result).toBeNull()
// Storage must be cleared
expect(localStorage.getItem('stuffle_iam_access_token')).toBeNull()
expect(localStorage.getItem('stuffle_iam_refresh_token')).toBeNull()
// Listeners must be notified with isAuthenticated=false
expect(notifications.length).toBeGreaterThanOrEqual(1)
expect(notifications[notifications.length - 1]).toBe(false)
})
it('T5c: getAccessToken() returns null when both tokens absent', async () => {
const client = makeClient()
clearStorage()
const token = await client.getAccessToken()
expect(token).toBeNull()
})
it('T6: init() with valid (non-expired) access token returns true without refresh', async () => {
// Build a JWT with exp 1 hour in the future
const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const payload = btoa(JSON.stringify({
sub: 'test-user',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
})).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
const fakeValidJwt = `${header}.${payload}.fakesig`
const client = makeClient()
localStorage.setItem('stuffle_iam_access_token', fakeValidJwt)
localStorage.setItem('stuffle_iam_refresh_token', 'some_refresh_token')
localStorage.setItem('stuffle_iam_expires_at', (Date.now() + 3_600_000).toString())
const refreshSpy = vi.spyOn(client, 'refreshToken')
const authenticated = await client.init()
expect(authenticated).toBe(true)
expect(refreshSpy).not.toHaveBeenCalled()
})
})

27
tests/setup.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Vitest global setup for iam-client-sdk tests.
*
* jsdom provides localStorage / sessionStorage.
* Node 18+ provides globalThis.fetch and globalThis.crypto.
* We wire them into the jsdom window so the SDK can find them.
*/
import { beforeEach } from 'vitest'
// Node 18+ crypto → make it available as window.crypto
if (typeof window !== 'undefined' && !window.crypto) {
Object.defineProperty(window, 'crypto', {
value: globalThis.crypto,
writable: false,
})
}
// Clear all stuffle_iam_ localStorage keys before each test
beforeEach(() => {
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k?.startsWith('stuffle_iam_')) keysToRemove.push(k)
}
keysToRemove.forEach(k => localStorage.removeItem(k))
})

14
vitest.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
import { config as dotenvConfig } from 'dotenv'
import { resolve } from 'path'
dotenvConfig({ path: resolve(__dirname, 'tests/.env.test'), override: false })
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
testTimeout: 30_000,
},
})