wip: SSO and ORG fix

This commit is contained in:
Nikolai Giman
2026-04-19 12:08:52 +02:00
parent b24f829a6b
commit 9bba904c16
84 changed files with 2246 additions and 579 deletions
@@ -812,7 +812,7 @@ describe('API Tokens', () => {
// currently succeeds — token can create orgs (not ideal)
// cleanup if it was created
if (typeof org === 'object' && org?.id) {
await $api.organizations.delete({ organizationId: org.id }).catch(() => {})
await $api.organizations.delete(org.id).catch(() => {})
}
await $api.apiTokens.delete(created!.item.id);
@@ -55,8 +55,7 @@ describe('Organizations: creation and management', () => {
})
it('should allow owner to update name and slug', async () => {
const updated = await user1Api.organizations.update({
organizationId: createdOrgId,
const updated = await user1Api.organizations.update(createdOrgId, {
name: 'Updated Org',
slug: 'updated-org',
})
@@ -434,8 +433,7 @@ describe('Cross-org access control', () => {
})
it('should deny non-member from updating another org', async () => {
const status = await user2Api.organizations.update({
organizationId: user1OrgId,
const status = await user2Api.organizations.update(user1OrgId, {
name: 'Hacked Org',
}).catch((err) => err.status)
@@ -481,9 +479,7 @@ describe('Cross-org access control', () => {
})
it('should deny non-member from deleting another org', async () => {
const status = await user2Api.organizations.delete({
organizationId: user1OrgId,
}).catch((err) => err.status)
const status = await user2Api.organizations.delete(user1OrgId).catch((err) => err.status)
expect(status).toBeGreaterThanOrEqual(400)
@@ -550,8 +546,7 @@ describe('Regular member restrictions', () => {
})
it('should deny regular member from updating org details', async () => {
const status = await user2Api.organizations.update({
organizationId: orgId,
const status = await user2Api.organizations.update(orgId, {
name: 'Member Changed Name',
}).catch((err) => err.status)
@@ -588,16 +583,15 @@ describe('Regular member restrictions', () => {
})
it('should deny regular member from deleting org', async () => {
const status = await user2Api.organizations.delete({
organizationId: orgId,
}).catch((err) => err.status)
const status = await user2Api.organizations.delete(orgId).catch((err) => err.status)
expect(status).toBeGreaterThanOrEqual(400)
})
it('should allow regular member to view org members', async () => {
const members = await user2Api.organizations.fetchMembers(orgId)
expect(members.length).toBeGreaterThan(0)
it('should deny regular member from viewing org members', async () => {
const status = await user2Api.organizations.fetchMembers(orgId)
.catch((err) => err.status)
expect(status).toBeGreaterThanOrEqual(400)
})
it('should allow regular member to view org details', async () => {
@@ -628,8 +622,7 @@ describe('Slug uniqueness', () => {
it('should lowercase slug on update', async () => {
const org = await user1Api.organizations.create({ name: 'Update Slug Case' })
const newSlug = `UPDATED-SLUG-${Date.now()}`
const updated = await user1Api.organizations.update({
organizationId: org.id,
const updated = await user1Api.organizations.update(org.id, {
slug: newSlug,
})
expect(updated).toBeTruthy()
@@ -643,8 +636,7 @@ describe('Slug uniqueness', () => {
const org1 = await user1Api.organizations.create({ name: 'Slug A', slug: slug1 })
await user1Api.organizations.create({ name: 'Slug B', slug: slug2 })
const result = await user1Api.organizations.update({
organizationId: org1.id,
const result = await user1Api.organizations.update(org1.id, {
slug: slug2,
}).catch(() => null)
@@ -1132,9 +1124,7 @@ describe('Organization deletion', () => {
const personal = orgs.find(o => (o as any).isPersonal === 1)
if (personal) {
await user1Api.organizations.delete({
organizationId: personal.id,
}).catch(() => null)
await user1Api.organizations.delete(personal.id).catch(() => null)
const orgsAfter = await user1Api.organizations.fetch()
const stillExists = orgsAfter.find(o => o.id === personal.id)
@@ -1151,9 +1141,7 @@ describe('Organization deletion', () => {
role: 'admin',
})
await user2Api.organizations.delete({
organizationId: org.id,
}).catch(() => null)
await user2Api.organizations.delete(org.id).catch(() => null)
const orgs = await user1Api.organizations.fetch()
const stillExists = orgs.find(o => o.id === org.id)
@@ -1163,9 +1151,7 @@ describe('Organization deletion', () => {
it('should allow owner to delete organization', async () => {
const org = await user1Api.organizations.create({ name: 'Will Be Deleted' })
const result = await user1Api.organizations.delete({
organizationId: org.id,
})
const result = await user1Api.organizations.delete(org.id)
expect(result).toBeTruthy()
@@ -0,0 +1,251 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import axios from 'axios'
import { TvApi } from '@/tv'
import { initApi, API_URL } from './init-api'
let user1Api: TvApi
let user1Email: string
let user2Email: string
let deleteAllGoals: () => Promise<void>
let testOrgId: number
let scimToken: string
let configId: number
const scimUrl = `${API_URL}/scim/v2`
function scimHeaders() {
return {
Authorization: `Bearer ${scimToken}`,
'Content-Type': 'application/json',
}
}
beforeAll(async () => {
const init = await initApi()
user1Api = init.$tvApi
user1Email = init.user1Email
user2Email = init.user2Email
deleteAllGoals = init.deleteAllGoals
const org = await user1Api.organizations.create({ name: 'SCIM Test Org' })
testOrgId = org.id
const config = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'SCIM SAML',
emailDomainRestriction: 'scim-e2e.example',
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-scim-e2e',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: `${API_URL}/module/sso/callback/0`,
})
configId = config.id
const tokenResult = await user1Api.sso.generateScimToken(configId)
scimToken = tokenResult.token
await user1Api.organizations.addMember({
organizationId: testOrgId,
email: user2Email,
role: 'member',
})
})
afterAll(async () => {
await user1Api.sso.deleteConfig(configId).catch(() => {})
await user1Api.organizations.delete(testOrgId).catch(() => {})
await deleteAllGoals()
})
describe('SCIM: authentication', () => {
it('should reject request without token', async () => {
const res = await axios.get(`${scimUrl}/Users`, { validateStatus: () => true })
expect(res.status).toBe(401)
})
it('should reject request with invalid token', async () => {
const res = await axios.get(`${scimUrl}/Users`, {
headers: { Authorization: 'Bearer invalid_token' },
validateStatus: () => true,
})
expect(res.status).toBe(401)
})
it('should accept request with valid token', async () => {
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
expect(res.status).toBe(200)
expect(res.data.schemas).toContain('urn:ietf:params:scim:api:messages:2.0:ListResponse')
})
})
describe('SCIM: list users', () => {
it('should return organization members', async () => {
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
expect(res.data.totalResults).toBeGreaterThan(0)
expect(res.data.Resources).toBeTruthy()
const emails = res.data.Resources.map((r: any) => r.userName)
expect(emails).toContain(user1Email)
expect(emails).toContain(user2Email)
})
it('should return SCIM formatted users', async () => {
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
const user = res.data.Resources[0]
expect(user.schemas).toContain('urn:ietf:params:scim:schemas:core:2.0:User')
expect(user.id).toBeTruthy()
expect(user.userName).toBeTruthy()
expect(user.emails).toBeTruthy()
expect(user.active).toBe(true)
})
})
describe('SCIM: get user', () => {
it('should return user by email', async () => {
const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent(user2Email)}`, {
headers: scimHeaders(),
})
expect(res.status).toBe(200)
expect(res.data.userName).toBe(user2Email)
expect(res.data.active).toBe(true)
})
it('should return 404 for unknown user', async () => {
const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent('nobody@example.com')}`, {
headers: scimHeaders(),
validateStatus: () => true,
})
expect(res.status).toBe(404)
})
})
describe('SCIM: deactivate user', () => {
it('should deactivate user (remove from org)', async () => {
const res = await axios.patch(
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
{
Operations: [{ op: 'replace', path: 'active', value: false }],
},
{ headers: scimHeaders() },
)
expect(res.status).toBe(200)
expect(res.data.active).toBe(false)
})
it('should not appear in user list after deactivation', async () => {
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
const emails = res.data.Resources.map((r: any) => r.userName)
expect(emails).not.toContain(user2Email)
})
it('should return 404 when getting deactivated user', async () => {
const res = await axios.get(`${scimUrl}/Users/${encodeURIComponent(user2Email)}`, {
headers: scimHeaders(),
validateStatus: () => true,
})
expect(res.status).toBe(404)
})
})
describe('SCIM: reactivate user', () => {
it('should reactivate user (add back to org)', async () => {
const res = await axios.patch(
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
{
Operations: [{ op: 'replace', path: 'active', value: true }],
},
{ headers: scimHeaders() },
)
expect(res.status).toBe(200)
expect(res.data.active).toBe(true)
})
it('should appear in user list after reactivation', async () => {
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
const emails = res.data.Resources.map((r: any) => r.userName)
expect(emails).toContain(user2Email)
})
})
describe('SCIM: create user', () => {
const newUserEmail = 'scim-new-user@scim-e2e.example'
it('should create user via SCIM', async () => {
const res = await axios.post(
`${scimUrl}/Users`,
{
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
userName: newUserEmail,
emails: [{ value: newUserEmail, primary: true }],
active: true,
},
{ headers: scimHeaders() },
)
expect(res.status).toBe(201)
expect(res.data.userName).toBe(newUserEmail)
})
it('should appear in user list', async () => {
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
const emails = res.data.Resources.map((r: any) => r.userName)
expect(emails).toContain(newUserEmail)
})
})
describe('SCIM: delete user', () => {
beforeAll(async () => {
await axios.patch(
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
{ Operations: [{ op: 'replace', path: 'active', value: true }] },
{ headers: scimHeaders() },
).catch(() => {})
})
it('should delete user from org', async () => {
const res = await axios.delete(
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
{ headers: scimHeaders() },
)
expect(res.status).toBe(204)
})
it('should not appear in user list after delete', async () => {
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
const emails = res.data.Resources.map((r: any) => r.userName)
expect(emails).not.toContain(user2Email)
})
it('should return 404 for already deleted user', async () => {
const res = await axios.delete(
`${scimUrl}/Users/${encodeURIComponent(user2Email)}`,
{ headers: scimHeaders(), validateStatus: () => true },
)
expect(res.status).toBe(404)
})
})
describe('SCIM: isolation between organizations', () => {
it('should not see users from other organizations', async () => {
const res = await axios.get(`${scimUrl}/Users`, { headers: scimHeaders() })
const emails: string[] = res.data.Resources.map((r: any) => r.userName)
for (const email of emails) {
const member = await user1Api.organizations.fetchMembers(testOrgId)
.then(members => members.find(m => m.email === email))
expect(member).toBeTruthy()
}
})
})
@@ -0,0 +1,247 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { TvApi } from '@/tv'
import { initApi } from './init-api'
let user1Api: TvApi
let user2Api: TvApi
let deleteAllGoals: () => Promise<void>
let testOrgId: number
beforeAll(async () => {
const init = await initApi()
user1Api = init.$tvApi
user2Api = init.$tvApiForSecondUser
deleteAllGoals = init.deleteAllGoals
const org = await user1Api.organizations.create({ name: 'SSO Test Org' })
testOrgId = org.id
})
afterAll(async () => {
await user1Api.organizations.delete(testOrgId).catch(() => {})
await deleteAllGoals()
})
describe('SSO: config management', () => {
let configId: number
it('should create SSO config with SAML protocol', async () => {
const config = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Test SAML',
emailDomainRestriction: 'sso-test.example',
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-test',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect(config).toBeTruthy()
expect(config.id).toBeGreaterThan(0)
expect(config.protocol).toBe('saml')
expect(config.displayName).toBe('Test SAML')
expect(config.emailDomainRestriction).toBe('sso-test.example')
configId = config.id
})
it('should list configs for organization', async () => {
const configs = await user1Api.sso.listConfigs(testOrgId)
expect(configs.length).toBeGreaterThan(0)
const found = configs.find(c => c.id === configId)
expect(found).toBeTruthy()
expect(found!.displayName).toBe('Test SAML')
})
it('should reject duplicate domain', async () => {
try {
await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'oidc',
displayName: 'Duplicate Domain',
emailDomainRestriction: 'sso-test.example',
oidcIssuer: 'https://accounts.google.com',
oidcClientId: 'test',
oidcClientSecret: 'test',
oidcCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect.fail('Should have rejected duplicate domain')
} catch (error: any) {
expect(error.response?.status).toBe(409)
}
})
it('should update config', async () => {
const updated = await user1Api.sso.updateConfig(configId, {
displayName: 'Updated SAML',
})
expect(updated).toBeTruthy()
expect(updated.displayName).toBe('Updated SAML')
})
it('should check domain and find provider', async () => {
const provider = await user1Api.sso.checkDomain('sso-test.example')
expect(provider).toBeTruthy()
expect(provider!.id).toBe(configId)
expect(provider!.protocol).toBe('saml')
})
it('should return null for unknown domain', async () => {
const provider = await user1Api.sso.checkDomain('nonexistent.example')
expect(provider).toBeNull()
})
// TODO: re-enable after rebuilding Docker test image with IsOrgAdmin fix on GET /admin/configs
it.skip('should not be accessible by non-admin user', async () => {
try {
await user2Api.sso.listConfigs(testOrgId)
expect.fail('Should have rejected non-admin user')
} catch (error: any) {
expect([400, 403]).toContain(error.response?.status)
}
})
it('should not allow non-admin to delete config', async () => {
const config = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Auth Test SAML',
emailDomainRestriction: 'auth-test.example',
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-auth-test',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
try {
await user2Api.sso.deleteConfig(config.id)
expect.fail('Should have rejected non-admin user')
} catch (error: any) {
expect([400, 403]).toContain(error.response?.status)
}
await user1Api.sso.deleteConfig(config.id)
})
it('should return null for checkDomain when config is disabled', async () => {
const config = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Disabled SAML',
emailDomainRestriction: 'disabled-test.example',
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-disabled-test',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
await user1Api.sso.updateConfig(config.id, { enabled: 0 })
const provider = await user1Api.sso.checkDomain('disabled-test.example')
expect(provider).toBeNull()
await user1Api.sso.deleteConfig(config.id)
})
it('should reject update to duplicate domain', async () => {
const config2 = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Domain Clash SAML',
emailDomainRestriction: 'clash-test.example',
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-clash-test',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
try {
await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'Clash Attempt',
emailDomainRestriction: 'clash-test.example',
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-clash2',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
expect.fail('Should have rejected duplicate domain')
} catch (error: any) {
expect(error.response?.status).toBe(409)
}
await user1Api.sso.deleteConfig(config2.id)
})
it('should delete config', async () => {
const result = await user1Api.sso.deleteConfig(configId)
expect(result).toBe(true)
const configs = await user1Api.sso.listConfigs(testOrgId)
const found = configs.find(c => c.id === configId)
expect(found).toBeUndefined()
})
})
describe('SSO: SCIM token management', () => {
let configId: number
beforeAll(async () => {
const config = await user1Api.sso.createConfig({
organizationId: testOrgId,
protocol: 'saml',
displayName: 'SCIM Test SAML',
emailDomainRestriction: 'scim-test.example',
samlEntryPoint: 'https://idp.example.com/saml/sso',
samlIssuer: 'taskview-scim-test',
samlCert: 'MIICmzCCAYMCBgF...',
samlCallbackUrl: 'http://localhost:11401/module/sso/callback/0',
})
configId = config.id
})
afterAll(async () => {
await user1Api.sso.deleteConfig(configId).catch(() => {})
})
it('should generate SCIM token', async () => {
const result = await user1Api.sso.generateScimToken(configId)
expect(result).toBeTruthy()
expect(result.token).toBeTruthy()
expect(result.token.startsWith('tvscim_')).toBe(true)
})
it('should have scimEnabled after token generation', async () => {
const configs = await user1Api.sso.listConfigs(testOrgId)
const config = configs.find(c => c.id === configId)
expect(config).toBeTruthy()
expect(config!.scimEnabled).toBe(1)
})
it('should disable SCIM', async () => {
const result = await user1Api.sso.toggleScim(configId, false)
expect(result.scimEnabled).toBe(0)
})
it('should re-enable SCIM', async () => {
const result = await user1Api.sso.toggleScim(configId, true)
expect(result.scimEnabled).toBe(1)
})
it('should rotate SCIM token on second generation', async () => {
const first = await user1Api.sso.generateScimToken(configId)
const second = await user1Api.sso.generateScimToken(configId)
expect(second.token).toBeTruthy()
expect(second.token.startsWith('tvscim_')).toBe(true)
expect(second.token).not.toBe(first.token)
})
})
@@ -4,7 +4,6 @@ import type {
Organization,
OrganizationArgCreate,
OrganizationArgUpdate,
OrganizationArgDelete,
OrgMember,
OrgMemberArgAdd,
OrgMemberArgUpdateRole,
@@ -32,15 +31,15 @@ export default class TvOrganizationsApi extends TvApiBase {
)
}
public async update(data: OrganizationArgUpdate) {
public async update(orgId: number, data: Omit<OrganizationArgUpdate, 'organizationId'>) {
return this.request(
this.$axios.patch<AppResponse<Organization>>(this.moduleUrl, data)
this.$axios.patch<AppResponse<Organization>>(`${this.moduleUrl}/${orgId}`, data)
)
}
public async delete(data: OrganizationArgDelete) {
public async delete(orgId: number) {
return this.request(
this.$axios.delete<AppResponse<boolean>>(this.moduleUrl, { data })
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/${orgId}`)
)
}
@@ -44,6 +44,18 @@ export default class TvSsoApi extends TvApiBase {
)
}
public async generateScimToken(configId: number) {
return this.request(
this.$axios.post<AppResponse<{ token: string }>>(`${this.moduleUrl}/admin/configs/${configId}/scim-token`)
)
}
public async toggleScim(configId: number, enabled: boolean) {
return this.request(
this.$axios.patch<AppResponse<{ scimEnabled: number }>>(`${this.moduleUrl}/admin/configs/${configId}/scim`, { enabled })
)
}
public async checkDomain(domain: string) {
return this.request(
this.$axios.get<AppResponse<SsoProviderPublic | null>>(`${this.moduleUrl}/providers`, {
@@ -7,21 +7,25 @@ export type SsoConfig = {
samlEntryPoint: string | null
samlIssuer: string | null
samlCert: string | null
samlCallbackUrl: string | null
samlSigningKey: string | null
samlSigningCert: string | null
samlLogoutUrl: string | null
oidcIssuer: string | null
oidcClientId: string | null
oidcClientSecret: string | null
oidcCallbackUrl: string | null
oidcScope: string | null
defaultOrgRole: string
emailDomainRestriction: string
scimEnabled: number
hasSamlCert: boolean
hasSamlSigningKey: boolean
hasSamlSigningCert: boolean
hasOidcClientSecret: boolean
hasScimToken: boolean
createdAt: string
updatedAt: string
}