Adds the ability for an owner to share a computer build via a public, unauthenticated link, inspired by a feature request from the original pcbuilder repo (no such feature actually existed there — implemented from scratch here). - New columns on computers: share_token (unique, nullable), share_enabled, share_show_sell_price, share_show_cost - computer_share_service.ts: generates a non-guessable share token (crypto.randomBytes, base64url), enables/disables sharing, and supports rotating the token to invalidate previously distributed links - ComputerShareController (authenticated, owner-scoped like the rest of the app): PUT /computers/:id/share to update settings, POST /computers/:id/share/rotate to rotate the token - PublicComputerController + GET /share/:token (outside any auth/guest group, reachable anonymously): resolves only computers with share_enabled = true, 404 otherwise - ComputerPublicTransformer: dedicated read-only projection that never leaks userId, and only includes sellPrice/cost when the owner opted in via share_show_sell_price / share_show_cost — everything else (title, scores, state, components, pictures) is always visible on a shared link - ComputerShareModal.vue: owner-facing UI (toggle sharing, choose which prices to expose, copy the public link, revoke/rotate it) wired into computers/show.vue via a new 'Share' button - share/show.vue: public page rendered with EmptyLayout (no authenticated header/sidebar) - Boolean columns use a cast since MySQL returns tinyint(1), not native booleans, through Lucid - 6 new functional tests covering: enabling sharing, price visibility toggles, disabling sharing revokes the link, cross-user protection on the share settings endpoint (404), token rotation invalidating the old link, and anonymous access to a shared computer
171 lines
5.4 KiB
TypeScript
171 lines
5.4 KiB
TypeScript
import { test } from '@japa/runner'
|
|
import User from '#models/user'
|
|
import Computer from '#models/computer'
|
|
import ComputerState from '#models/computer_state'
|
|
|
|
async function createUser(email: string) {
|
|
return User.create({
|
|
name: 'Test User',
|
|
email,
|
|
password: 'secret@123A',
|
|
})
|
|
}
|
|
|
|
async function createState(name = 'ON_GOING') {
|
|
return ComputerState.firstOrCreate({ name }, { name })
|
|
}
|
|
|
|
async function createComputer(userId: number, stateId: number) {
|
|
return Computer.create({
|
|
title: 'Shared PC',
|
|
sellPrice: 999,
|
|
gpuScore: 5,
|
|
cpuScore: 5,
|
|
globalScore: 5,
|
|
stateId,
|
|
userId,
|
|
})
|
|
}
|
|
|
|
test.group('Computer sharing', () => {
|
|
test('owner can enable sharing and the public page becomes reachable', async ({
|
|
client,
|
|
assert,
|
|
}) => {
|
|
const owner = await createUser('share-owner-a@example.com')
|
|
const state = await createState()
|
|
const computer = await createComputer(owner.id, state.id)
|
|
|
|
const enableResponse = await client
|
|
.put(`/computers/${computer.id}/share`)
|
|
.loginAs(owner)
|
|
.withCsrfToken()
|
|
.redirects(0)
|
|
.json({ enabled: true, showSellPrice: true, showCost: false })
|
|
|
|
enableResponse.assertStatus(302)
|
|
await computer.refresh()
|
|
assert.isTrue(computer.shareEnabled)
|
|
assert.isString(computer.shareToken)
|
|
|
|
const publicResponse = await client
|
|
.get(`/share/${computer.shareToken}`)
|
|
.header('X-Inertia', 'true')
|
|
.header('X-Inertia-Version', '1')
|
|
.redirects(0)
|
|
publicResponse.assertStatus(200)
|
|
publicResponse.assertBodyContains({ props: { computer: { title: 'Shared PC' } } })
|
|
})
|
|
|
|
test('sell price and cost are hidden unless explicitly enabled', async ({ client }) => {
|
|
const owner = await createUser('share-owner-b@example.com')
|
|
const state = await createState()
|
|
const computer = await createComputer(owner.id, state.id)
|
|
|
|
await client
|
|
.put(`/computers/${computer.id}/share`)
|
|
.loginAs(owner)
|
|
.withCsrfToken()
|
|
.redirects(0)
|
|
.json({ enabled: true, showSellPrice: false, showCost: false })
|
|
await computer.refresh()
|
|
|
|
const publicResponse = await client
|
|
.get(`/share/${computer.shareToken}`)
|
|
.header('X-Inertia', 'true')
|
|
.header('X-Inertia-Version', '1')
|
|
.redirects(0)
|
|
publicResponse.assertStatus(200)
|
|
publicResponse.assertBodyContains({ props: { computer: { sellPrice: null, cost: null } } })
|
|
})
|
|
|
|
test('disabling sharing makes the public link unreachable', async ({ client }) => {
|
|
const owner = await createUser('share-owner-c@example.com')
|
|
const state = await createState()
|
|
const computer = await createComputer(owner.id, state.id)
|
|
|
|
await client
|
|
.put(`/computers/${computer.id}/share`)
|
|
.loginAs(owner)
|
|
.withCsrfToken()
|
|
.redirects(0)
|
|
.json({ enabled: true, showSellPrice: false, showCost: false })
|
|
await computer.refresh()
|
|
const token = computer.shareToken
|
|
|
|
await client
|
|
.put(`/computers/${computer.id}/share`)
|
|
.loginAs(owner)
|
|
.withCsrfToken()
|
|
.redirects(0)
|
|
.json({ enabled: false, showSellPrice: false, showCost: false })
|
|
|
|
const publicResponse = await client.get(`/share/${token}`).redirects(0)
|
|
publicResponse.assertStatus(404)
|
|
})
|
|
|
|
test('a user cannot enable sharing on another user computer', async ({ client }) => {
|
|
const owner = await createUser('share-owner-d@example.com')
|
|
const attacker = await createUser('share-attacker-d@example.com')
|
|
const state = await createState()
|
|
const computer = await createComputer(owner.id, state.id)
|
|
|
|
const response = await client
|
|
.put(`/computers/${computer.id}/share`)
|
|
.loginAs(attacker)
|
|
.withCsrfToken()
|
|
.redirects(0)
|
|
.json({ enabled: true, showSellPrice: true, showCost: true })
|
|
|
|
response.assertStatus(404)
|
|
})
|
|
|
|
test('rotating the share token invalidates the previous link', async ({ client, assert }) => {
|
|
const owner = await createUser('share-owner-e@example.com')
|
|
const state = await createState()
|
|
const computer = await createComputer(owner.id, state.id)
|
|
|
|
await client
|
|
.put(`/computers/${computer.id}/share`)
|
|
.loginAs(owner)
|
|
.withCsrfToken()
|
|
.redirects(0)
|
|
.json({ enabled: true, showSellPrice: false, showCost: false })
|
|
await computer.refresh()
|
|
const oldToken = computer.shareToken
|
|
|
|
await client
|
|
.post(`/computers/${computer.id}/share/rotate`)
|
|
.loginAs(owner)
|
|
.withCsrfToken()
|
|
.redirects(0)
|
|
await computer.refresh()
|
|
|
|
assert.notEqual(computer.shareToken, oldToken)
|
|
|
|
const oldLinkResponse = await client.get(`/share/${oldToken}`).redirects(0)
|
|
oldLinkResponse.assertStatus(404)
|
|
|
|
const newLinkResponse = await client.get(`/share/${computer.shareToken}`).redirects(0)
|
|
newLinkResponse.assertStatus(200)
|
|
})
|
|
|
|
test('an unauthenticated visitor can view a shared computer', async ({ client }) => {
|
|
const owner = await createUser('share-owner-f@example.com')
|
|
const state = await createState()
|
|
const computer = await createComputer(owner.id, state.id)
|
|
|
|
await client
|
|
.put(`/computers/${computer.id}/share`)
|
|
.loginAs(owner)
|
|
.withCsrfToken()
|
|
.redirects(0)
|
|
.json({ enabled: true, showSellPrice: true, showCost: true })
|
|
await computer.refresh()
|
|
|
|
// No .loginAs() here: simulates a fully anonymous visitor.
|
|
const response = await client.get(`/share/${computer.shareToken}`).redirects(0)
|
|
response.assertStatus(200)
|
|
})
|
|
})
|